The build_npm_package.py script now accepts '@valknarthing/llmx' as a valid --package argument and normalizes it to 'llmx' internally for processing. This allows the workflow to use the full scoped package name for clarity while maintaining backward compatibility with the 'llmx' identifier.
313 lines
11 KiB
Python
Executable File
313 lines
11 KiB
Python
Executable File
#!/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", "@valknarthing/llmx"),
|
|
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
|
|
# Normalize scoped package name to internal identifier
|
|
if package == "@valknarthing/llmx":
|
|
package = "llmx"
|
|
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())
|