#!/usr/bin/env python3 """Stage and optionally package the @valknar/llmx npm module.""" import argparse import json import shutil import subprocess import sys import tempfile from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CODEX_CLI_ROOT = SCRIPT_DIR.parent REPO_ROOT = CODEX_CLI_ROOT.parent RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "llmx-rs" / "responses-api-proxy" / "npm" CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript" PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { "llmx": ["llmx", "rg"], "llmx-responses-api-proxy": ["llmx-responses-api-proxy"], "llmx-sdk": ["llmx"], } COMPONENT_DEST_DIR: dict[str, str] = { "llmx": "llmx", "llmx-responses-api-proxy": "llmx-responses-api-proxy", "rg": "path", } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the LLMX CLI npm package.") parser.add_argument( "--package", choices=("llmx", "llmx-responses-api-proxy", "llmx-sdk"), default="llmx", help="Which npm package to stage (default: llmx).", ) parser.add_argument( "--version", help="Version number to write to package.json inside the staged package.", ) parser.add_argument( "--release-version", help=( "Version to stage for npm release." ), ) parser.add_argument( "--staging-dir", type=Path, help=( "Directory to stage the package contents. Defaults to a new temporary directory " "if omitted. The directory must be empty when provided." ), ) parser.add_argument( "--tmp", dest="staging_dir", type=Path, help=argparse.SUPPRESS, ) parser.add_argument( "--pack-output", type=Path, help="Path where the generated npm tarball should be written.", ) parser.add_argument( "--vendor-src", type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) return parser.parse_args() def main() -> int: args = parse_args() package = args.package version = args.version release_version = args.release_version if release_version: if version and version != release_version: raise RuntimeError("--version and --release-version must match when both are provided.") version = release_version if not version: raise RuntimeError("Must specify --version or --release-version.") staging_dir, created_temp = prepare_staging_dir(args.staging_dir) try: stage_sources(staging_dir, version, package) vendor_src = args.vendor_src.resolve() if args.vendor_src else None native_components = PACKAGE_NATIVE_COMPONENTS.get(package, []) if native_components: if vendor_src is None: components_str = ", ".join(native_components) raise RuntimeError( "Native components " f"({components_str}) required for package '{package}'. Provide --vendor-src " "pointing to a directory containing pre-installed binaries." ) copy_native_binaries(vendor_src, staging_dir, native_components) if release_version: staging_dir_str = str(staging_dir) if package == "llmx": print( f"Staged version {version} for release in {staging_dir_str}\n\n" "Verify the CLI:\n" f" node {staging_dir_str}/bin/llmx.js --version\n" f" node {staging_dir_str}/bin/llmx.js --help\n\n" ) elif package == "llmx-responses-api-proxy": print( f"Staged version {version} for release in {staging_dir_str}\n\n" "Verify the responses API proxy:\n" f" node {staging_dir_str}/bin/llmx-responses-api-proxy.js --help\n\n" ) else: print( f"Staged version {version} for release in {staging_dir_str}\n\n" "Verify the SDK contents:\n" f" ls {staging_dir_str}/dist\n" f" ls {staging_dir_str}/vendor\n" " node -e \"import('./dist/index.js').then(() => console.log('ok'))\"\n\n" ) else: print(f"Staged package in {staging_dir}") if args.pack_output is not None: output_path = run_npm_pack(staging_dir, args.pack_output) print(f"npm pack output written to {output_path}") finally: if created_temp: # Preserve the staging directory for further inspection. pass return 0 def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]: if staging_dir is not None: staging_dir = staging_dir.resolve() staging_dir.mkdir(parents=True, exist_ok=True) if any(staging_dir.iterdir()): raise RuntimeError(f"Staging directory {staging_dir} is not empty.") return staging_dir, False temp_dir = Path(tempfile.mkdtemp(prefix="codex-npm-stage-")) return temp_dir, True def stage_sources(staging_dir: Path, version: str, package: str) -> None: if package == "llmx": bin_dir = staging_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(CODEX_CLI_ROOT / "bin" / "llmx.js", bin_dir / "llmx.js") rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" if rg_manifest.exists(): shutil.copy2(rg_manifest, bin_dir / "rg") readme_src = REPO_ROOT / "README.md" if readme_src.exists(): shutil.copy2(readme_src, staging_dir / "README.md") package_json_path = CODEX_CLI_ROOT / "package.json" elif package == "llmx-responses-api-proxy": bin_dir = staging_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) launcher_src = RESPONSES_API_PROXY_NPM_ROOT / "bin" / "llmx-responses-api-proxy.js" shutil.copy2(launcher_src, bin_dir / "llmx-responses-api-proxy.js") readme_src = RESPONSES_API_PROXY_NPM_ROOT / "README.md" if readme_src.exists(): shutil.copy2(readme_src, staging_dir / "README.md") package_json_path = RESPONSES_API_PROXY_NPM_ROOT / "package.json" elif package == "llmx-sdk": package_json_path = CODEX_SDK_ROOT / "package.json" stage_codex_sdk_sources(staging_dir) else: raise RuntimeError(f"Unknown package '{package}'.") with open(package_json_path, "r", encoding="utf-8") as fh: package_json = json.load(fh) package_json["version"] = version if package == "llmx-sdk": scripts = package_json.get("scripts") if isinstance(scripts, dict): scripts.pop("prepare", None) files = package_json.get("files") if isinstance(files, list): if "vendor" not in files: files.append("vendor") else: package_json["files"] = ["dist", "vendor"] with open(staging_dir / "package.json", "w", encoding="utf-8") as out: json.dump(package_json, out, indent=2) out.write("\n") def run_command(cmd: list[str], cwd: Path | None = None) -> None: print("+", " ".join(cmd)) subprocess.run(cmd, cwd=cwd, check=True) def stage_codex_sdk_sources(staging_dir: Path) -> None: package_root = CODEX_SDK_ROOT run_command(["pnpm", "install", "--frozen-lockfile"], cwd=package_root) run_command(["pnpm", "run", "build"], cwd=package_root) dist_src = package_root / "dist" if not dist_src.exists(): raise RuntimeError("codex-sdk build did not produce a dist directory.") shutil.copytree(dist_src, staging_dir / "dist") readme_src = package_root / "README.md" if readme_src.exists(): shutil.copy2(readme_src, staging_dir / "README.md") license_src = REPO_ROOT / "LICENSE" if license_src.exists(): shutil.copy2(license_src, staging_dir / "LICENSE") def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[str]) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") components_set = {component for component in components if component in COMPONENT_DEST_DIR} if not components_set: return vendor_dest = staging_dir / "vendor" if vendor_dest.exists(): shutil.rmtree(vendor_dest) vendor_dest.mkdir(parents=True, exist_ok=True) for target_dir in vendor_src.iterdir(): if not target_dir.is_dir(): continue dest_target_dir = vendor_dest / target_dir.name dest_target_dir.mkdir(parents=True, exist_ok=True) for component in components_set: dest_dir_name = COMPONENT_DEST_DIR.get(component) if dest_dir_name is None: continue src_component_dir = target_dir / dest_dir_name if not src_component_dir.exists(): print( f"⚠️ Skipping {target_dir.name}/{dest_dir_name}: component not found (build may have failed)" ) continue dest_component_dir = dest_target_dir / dest_dir_name if dest_component_dir.exists(): shutil.rmtree(dest_component_dir) shutil.copytree(src_component_dir, dest_component_dir) def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: output_path = output_path.resolve() output_path.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str: pack_dir = Path(pack_dir_str) stdout = subprocess.check_output( ["npm", "pack", "--json", "--pack-destination", str(pack_dir)], cwd=staging_dir, text=True, ) try: pack_output = json.loads(stdout) except json.JSONDecodeError as exc: raise RuntimeError("Failed to parse npm pack output.") from exc if not pack_output: raise RuntimeError("npm pack did not produce an output tarball.") tarball_name = pack_output[0].get("filename") or pack_output[0].get("name") if not tarball_name: raise RuntimeError("Unable to determine npm pack output filename.") tarball_path = pack_dir / tarball_name if not tarball_path.exists(): raise RuntimeError(f"Expected npm pack output not found: {tarball_path}") shutil.move(str(tarball_path), output_path) return output_path if __name__ == "__main__": import sys sys.exit(main())