Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions .github/workflows/rust-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
57 changes: 55 additions & 2 deletions codex-cli/bin/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions codex-cli/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<platform>` 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.
138 changes: 126 additions & 12 deletions codex-cli/scripts/build_npm_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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).",
)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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():
Expand All @@ -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)
Expand All @@ -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()
Expand Down
Loading
Loading