From 6b4e2bdd8dbe77edd459471d050c69f2f0d6d6d4 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 10 Feb 2026 14:40:26 +0000 Subject: [PATCH] chore: split NPM packages --- .github/workflows/rust-release.yml | 39 +++---- codex-cli/bin/codex.js | 57 +++++++++- codex-cli/scripts/README.md | 3 + codex-cli/scripts/build_npm_package.py | 138 ++++++++++++++++++++++--- scripts/stage_npm_packages.py | 21 ++-- 5 files changed, 220 insertions(+), 38 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2886f7c8872..193bcd85915 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -593,18 +593,20 @@ jobs: version="${{ needs.release.outputs.version }}" tag="${{ needs.release.outputs.tag }}" mkdir -p dist/npm - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "codex-npm-${version}.tgz" \ - --dir dist/npm - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ - --dir dist/npm - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "codex-sdk-npm-${version}.tgz" \ - --dir dist/npm + patterns=( + "codex-npm-${version}.tgz" + "codex-linux-*-npm-${version}.tgz" + "codex-darwin-*-npm-${version}.tgz" + "codex-win32-*-npm-${version}.tgz" + "codex-responses-api-proxy-npm-${version}.tgz" + "codex-sdk-npm-${version}.tgz" + ) + for pattern in "${patterns[@]}"; do + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "$pattern" \ + --dir dist/npm + done # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -618,14 +620,15 @@ jobs: tag_args+=(--tag "${NPM_TAG}") fi - tarballs=( - "codex-npm-${VERSION}.tgz" - "codex-responses-api-proxy-npm-${VERSION}.tgz" - "codex-sdk-npm-${VERSION}.tgz" - ) + shopt -s nullglob + tarballs=(dist/npm/*-npm-"${VERSION}".tgz) + if [[ ${#tarballs[@]} -eq 0 ]]; then + echo "No npm tarballs found in dist/npm for version ${VERSION}" + exit 1 + fi for tarball in "${tarballs[@]}"; do - npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}" + npm publish "${GITHUB_WORKSPACE}/${tarball}" "${tag_args[@]}" done update-branch: diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 6bc1ad5c627..67ab3e2d95d 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -3,12 +3,23 @@ import { spawn } from "node:child_process"; import { existsSync } from "fs"; +import { createRequire } from "node:module"; import path from "path"; import { fileURLToPath } from "url"; // __dirname equivalent in ESM const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); + +const PLATFORM_PACKAGE_BY_TARGET = { + "x86_64-unknown-linux-musl": "@openai/codex-linux-x64", + "aarch64-unknown-linux-musl": "@openai/codex-linux-arm64", + "x86_64-apple-darwin": "@openai/codex-darwin-x64", + "aarch64-apple-darwin": "@openai/codex-darwin-arm64", + "x86_64-pc-windows-msvc": "@openai/codex-win32-x64", + "aarch64-pc-windows-msvc": "@openai/codex-win32-arm64", +}; const { platform, arch } = process; @@ -59,9 +70,51 @@ if (!targetTriple) { throw new Error(`Unsupported platform: ${platform} (${arch})`); } -const vendorRoot = path.join(__dirname, "..", "vendor"); -const archRoot = path.join(vendorRoot, targetTriple); +const platformPackage = PLATFORM_PACKAGE_BY_TARGET[targetTriple]; +if (!platformPackage) { + throw new Error(`Unsupported target triple: ${targetTriple}`); +} + const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex"; +const localVendorRoot = path.join(__dirname, "..", "vendor"); +const localBinaryPath = path.join( + localVendorRoot, + targetTriple, + "codex", + codexBinaryName, +); + +let vendorRoot; +try { + const packageJsonPath = require.resolve(`${platformPackage}/package.json`); + vendorRoot = path.join(path.dirname(packageJsonPath), "vendor"); +} catch { + if (existsSync(localBinaryPath)) { + vendorRoot = localVendorRoot; + } else { + const packageManager = detectPackageManager(); + const updateCommand = + packageManager === "bun" + ? "bun install -g @openai/codex@latest" + : "npm install -g @openai/codex@latest"; + throw new Error( + `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`, + ); + } +} + +if (!vendorRoot) { + const packageManager = detectPackageManager(); + const updateCommand = + packageManager === "bun" + ? "bun install -g @openai/codex@latest" + : "npm install -g @openai/codex@latest"; + throw new Error( + `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`, + ); +} + +const archRoot = path.join(vendorRoot, targetTriple); const binaryPath = path.join(archRoot, "codex", codexBinaryName); // Use an asynchronous spawn instead of spawnSync so that Node is able to diff --git a/codex-cli/scripts/README.md b/codex-cli/scripts/README.md index 052cf81a372..b9d55bad28e 100644 --- a/codex-cli/scripts/README.md +++ b/codex-cli/scripts/README.md @@ -14,6 +14,9 @@ example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0` This downloads the native artifacts once, hydrates `vendor/` for each package, and writes tarballs to `dist/npm/`. +When `--package codex` is provided, the staging helper builds the lightweight +`@openai/codex` meta package plus all `@openai/codex-` native packages. + If you need to invoke `build_npm_package.py` directly, run `codex-cli/scripts/install_native_deps.py` first and pass `--vendor-src` pointing to the directory that contains the populated `vendor/` tree. diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index bf0eb5f4699..066f09e7e97 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -15,14 +15,68 @@ RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm" CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript" +CODEX_PLATFORM_PACKAGES: dict[str, dict[str, str]] = { + "codex-linux-x64": { + "npm_name": "@openai/codex-linux-x64", + "target_triple": "x86_64-unknown-linux-musl", + "os": "linux", + "cpu": "x64", + }, + "codex-linux-arm64": { + "npm_name": "@openai/codex-linux-arm64", + "target_triple": "aarch64-unknown-linux-musl", + "os": "linux", + "cpu": "arm64", + }, + "codex-darwin-x64": { + "npm_name": "@openai/codex-darwin-x64", + "target_triple": "x86_64-apple-darwin", + "os": "darwin", + "cpu": "x64", + }, + "codex-darwin-arm64": { + "npm_name": "@openai/codex-darwin-arm64", + "target_triple": "aarch64-apple-darwin", + "os": "darwin", + "cpu": "arm64", + }, + "codex-win32-x64": { + "npm_name": "@openai/codex-win32-x64", + "target_triple": "x86_64-pc-windows-msvc", + "os": "win32", + "cpu": "x64", + }, + "codex-win32-arm64": { + "npm_name": "@openai/codex-win32-arm64", + "target_triple": "aarch64-pc-windows-msvc", + "os": "win32", + "cpu": "arm64", + }, +} + +PACKAGE_EXPANSIONS: dict[str, list[str]] = { + "codex": ["codex", *CODEX_PLATFORM_PACKAGES], +} + PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { - "codex": ["codex", "rg"], + "codex": [], + "codex-linux-x64": ["codex", "rg"], + "codex-linux-arm64": ["codex", "rg"], + "codex-darwin-x64": ["codex", "rg"], + "codex-darwin-arm64": ["codex", "rg"], + "codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], + "codex-win32-arm64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], "codex-responses-api-proxy": ["codex-responses-api-proxy"], "codex-sdk": ["codex"], } -WINDOWS_ONLY_COMPONENTS: dict[str, list[str]] = { - "codex": ["codex-windows-sandbox-setup", "codex-command-runner"], + +PACKAGE_TARGET_FILTERS: dict[str, str] = { + package_name: package_config["target_triple"] + for package_name, package_config in CODEX_PLATFORM_PACKAGES.items() } + +PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS) + COMPONENT_DEST_DIR: dict[str, str] = { "codex": "codex", "codex-responses-api-proxy": "codex-responses-api-proxy", @@ -36,7 +90,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.") parser.add_argument( "--package", - choices=("codex", "codex-responses-api-proxy", "codex-sdk"), + choices=PACKAGE_CHOICES, default="codex", help="Which npm package to stage (default: codex).", ) @@ -98,6 +152,7 @@ def main() -> int: vendor_src = args.vendor_src.resolve() if args.vendor_src else None native_components = PACKAGE_NATIVE_COMPONENTS.get(package, []) + target_filter = PACKAGE_TARGET_FILTERS.get(package) if native_components: if vendor_src is None: @@ -108,7 +163,12 @@ def main() -> int: "pointing to a directory containing pre-installed binaries." ) - copy_native_binaries(vendor_src, staging_dir, package, native_components) + copy_native_binaries( + vendor_src, + staging_dir, + native_components, + target_filter={target_filter} if target_filter else None, + ) if release_version: staging_dir_str = str(staging_dir) @@ -125,6 +185,12 @@ def main() -> int: "Verify the responses API proxy:\n" f" node {staging_dir_str}/bin/codex-responses-api-proxy.js --help\n\n" ) + elif package in CODEX_PLATFORM_PACKAGES: + print( + f"Staged version {version} for release in {staging_dir_str}\n\n" + "Verify native payload contents:\n" + f" ls {staging_dir_str}/vendor\n\n" + ) else: print( f"Staged version {version} for release in {staging_dir_str}\n\n" @@ -160,6 +226,9 @@ def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]: def stage_sources(staging_dir: Path, version: str, package: str) -> None: + package_json: dict + package_json_path: Path | None = None + if package == "codex": bin_dir = staging_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) @@ -173,6 +242,33 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None: shutil.copy2(readme_src, staging_dir / "README.md") package_json_path = CODEX_CLI_ROOT / "package.json" + elif package in CODEX_PLATFORM_PACKAGES: + platform_package = CODEX_PLATFORM_PACKAGES[package] + + readme_src = REPO_ROOT / "README.md" + if readme_src.exists(): + shutil.copy2(readme_src, staging_dir / "README.md") + + with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh: + codex_package_json = json.load(fh) + + package_json = { + "name": platform_package["npm_name"], + "version": version, + "license": codex_package_json.get("license", "Apache-2.0"), + "os": [platform_package["os"]], + "cpu": [platform_package["cpu"]], + "files": ["vendor"], + "repository": codex_package_json.get("repository"), + } + + engines = codex_package_json.get("engines") + if isinstance(engines, dict): + package_json["engines"] = engines + + package_manager = codex_package_json.get("packageManager") + if isinstance(package_manager, str): + package_json["packageManager"] = package_manager elif package == "codex-responses-api-proxy": bin_dir = staging_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) @@ -190,11 +286,20 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None: 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_json_path is not None: + with open(package_json_path, "r", encoding="utf-8") as fh: + package_json = json.load(fh) + package_json["version"] = version - if package == "codex-sdk": + if package == "codex": + package_json["files"] = ["bin"] + package_json["optionalDependencies"] = { + CODEX_PLATFORM_PACKAGES[platform_package]["npm_name"]: version + for platform_package in PACKAGE_EXPANSIONS["codex"] + if platform_package != "codex" + } + + elif package == "codex-sdk": scripts = package_json.get("scripts") if isinstance(scripts, dict): scripts.pop("prepare", None) @@ -240,8 +345,8 @@ def stage_codex_sdk_sources(staging_dir: Path) -> None: def copy_native_binaries( vendor_src: Path, staging_dir: Path, - package: str, components: list[str], + target_filter: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): @@ -256,15 +361,18 @@ def copy_native_binaries( shutil.rmtree(vendor_dest) vendor_dest.mkdir(parents=True, exist_ok=True) + copied_targets: set[str] = set() + for target_dir in vendor_src.iterdir(): if not target_dir.is_dir(): continue - if "windows" in target_dir.name: - components_set.update(WINDOWS_ONLY_COMPONENTS.get(package, [])) + if target_filter is not None and target_dir.name not in target_filter: + continue dest_target_dir = vendor_dest / target_dir.name dest_target_dir.mkdir(parents=True, exist_ok=True) + copied_targets.add(target_dir.name) for component in components_set: dest_dir_name = COMPONENT_DEST_DIR.get(component) @@ -282,6 +390,12 @@ def copy_native_binaries( shutil.rmtree(dest_component_dir) shutil.copytree(src_component_dir, dest_component_dir) + if target_filter is not None: + missing_targets = sorted(target_filter - copied_targets) + if missing_targets: + missing_list = ", ".join(missing_targets) + raise RuntimeError(f"Missing target directories in vendor source: {missing_list}") + def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: output_path = output_path.resolve() diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index f87a75815fa..ff08111b9b0 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -25,7 +25,7 @@ _BUILD_MODULE = importlib.util.module_from_spec(_SPEC) _SPEC.loader.exec_module(_BUILD_MODULE) PACKAGE_NATIVE_COMPONENTS = getattr(_BUILD_MODULE, "PACKAGE_NATIVE_COMPONENTS", {}) -WINDOWS_ONLY_COMPONENTS = getattr(_BUILD_MODULE, "WINDOWS_ONLY_COMPONENTS", {}) +PACKAGE_EXPANSIONS = getattr(_BUILD_MODULE, "PACKAGE_EXPANSIONS", {}) def parse_args() -> argparse.Namespace: @@ -64,10 +64,19 @@ def collect_native_components(packages: list[str]) -> set[str]: components: set[str] = set() for package in packages: components.update(PACKAGE_NATIVE_COMPONENTS.get(package, [])) - components.update(WINDOWS_ONLY_COMPONENTS.get(package, [])) return components +def expand_packages(packages: list[str]) -> list[str]: + expanded: list[str] = [] + for package in packages: + for expanded_package in PACKAGE_EXPANSIONS.get(package, [package]): + if expanded_package in expanded: + continue + expanded.append(expanded_package) + return expanded + + def resolve_release_workflow(version: str) -> dict: stdout = subprocess.check_output( [ @@ -128,14 +137,14 @@ def main() -> int: runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) - packages = list(args.packages) + packages = expand_packages(list(args.packages)) native_components = collect_native_components(packages) vendor_temp_root: Path | None = None vendor_src: Path | None = None resolved_head_sha: str | None = None - final_messsages = [] + final_messages = [] try: if native_components: @@ -174,12 +183,12 @@ def main() -> int: if not args.keep_staging_dirs: shutil.rmtree(staging_dir, ignore_errors=True) - final_messsages.append(f"Staged {package} at {pack_output}") + final_messages.append(f"Staged {package} at {pack_output}") finally: if vendor_temp_root is not None and not args.keep_staging_dirs: shutil.rmtree(vendor_temp_root, ignore_errors=True) - for msg in final_messsages: + for msg in final_messages: print(msg) return 0