diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6a92a112f..e5b23a581 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -214,8 +214,10 @@ jobs: run: npm ci --ignore-scripts - name: Set version run: sed -i "s/^version = .*/version = \"${{ needs.version.outputs.version }}\"/" pyproject.toml - - name: Build platform wheels - run: node scripts/build-wheels.mjs --output-dir dist + - name: Inject CLI version + run: node scripts/inject-cli-version.mjs + - name: Build wheel + run: uv build --wheel --out-dir dist - name: Upload artifact uses: actions/upload-artifact@v7.0.0 with: diff --git a/docs/getting-started.md b/docs/getting-started.md index ad401894f..fd6064f2e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ Copilot: In Tokyo it's 75°F and sunny. Great day to be outside! Before you begin, make sure you have: -* **GitHub Copilot CLI** installed and authenticated (the Node.js, Python, and .NET SDKs bundle the CLI automatically—see [Bundled CLI](./setup/bundled-cli.md). Required for Go, Java, and Rust unless using their application-level CLI bundling features.) +* **GitHub Copilot CLI** installed and authenticated (the Node.js, Python, and .NET SDKs provide the CLI automatically—see [Bundled CLI](./setup/bundled-cli.md). Required for Go, Java, and Rust unless using their application-level CLI bundling features.) * Your preferred language runtime: * **Node.js** 20+ or **Python** 3.11+ or **Go** 1.24+ or **Rust** 1.94+ or **Java** 17+ or **.NET** 8.0+ diff --git a/docs/setup/bundled-cli.md b/docs/setup/bundled-cli.md index 94bb61754..26eb62c3f 100644 --- a/docs/setup/bundled-cli.md +++ b/docs/setup/bundled-cli.md @@ -1,12 +1,20 @@ # Default setup (bundled CLI) -The Node.js, Python, and .NET SDKs include the Copilot CLI as a dependency—your app ships with everything it needs, with no extra installation or configuration required. +The Node.js and .NET SDKs include the Copilot CLI as a dependency—your app ships with everything it needs, with no extra installation or configuration required. + +The Python SDK recommends a one-time download step after installation: + +```bash +python -m copilot download-runtime +``` + +This downloads the matching runtime and caches it locally. If you skip this step, the SDK will attempt to download it automatically on first use as a fallback. **Best for:** Most applications—desktop apps, standalone tools, CLI utilities, prototypes, and more. ## How it works -When you install the SDK, the Copilot CLI binary is included automatically. The SDK starts it as a child process and communicates over stdio. There's nothing extra to configure. +When you install the SDK, the Copilot runtime is included automatically (Node.js, .NET) or downloaded via `python -m copilot download-runtime` (Python). The SDK starts it as a child process and communicates over stdio. There's nothing extra to configure. ```mermaid flowchart TB diff --git a/docs/setup/local-cli.md b/docs/setup/local-cli.md index 12a66f155..b8dd735b3 100644 --- a/docs/setup/local-cli.md +++ b/docs/setup/local-cli.md @@ -1,6 +1,6 @@ # Local CLI setup -Use a specific CLI binary instead of the SDK's bundled CLI. This is an advanced option—you supply the CLI path explicitly, and you are responsible for ensuring version compatibility with the SDK. +Use a specific CLI binary instead of the SDK's automatic CLI management. This is an advanced option—you supply the CLI path explicitly, and you are responsible for ensuring version compatibility with the SDK. **Use when:** You need to pin a specific CLI version, or work with the Go SDK (which does not bundle a CLI). diff --git a/python/.gitignore b/python/.gitignore index 8eb101ca3..671fe9a8b 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -169,6 +169,3 @@ uv.lock # Build script caches .cli-cache/ .build-temp/ - -# Bundled CLI binary (only in platform wheels, not in repo) -copilot/bin/ diff --git a/python/README.md b/python/README.md index 9079ac855..fd7b9389a 100644 --- a/python/README.md +++ b/python/README.md @@ -20,6 +20,33 @@ To include OpenTelemetry support: pip install "github-copilot-sdk[telemetry]" ``` +## Runtime + +Published wheels include a pinned runtime version. After installing, download the +runtime: + +```bash +python -m copilot download-runtime +``` + +This caches the runtime binary locally. If you skip this step, the SDK will +attempt to download it automatically on first use as a fallback. + +| Platform | Cache path | +|----------|-----------| +| Linux | `~/.cache/github-copilot-sdk/cli//copilot` | +| macOS | `~/Library/Caches/github-copilot-sdk/cli//copilot` | +| Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\copilot.exe` | + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `COPILOT_CLI_PATH` | Use this specific binary instead of downloading | +| `COPILOT_CLI_EXTRACT_DIR` | Override the cache directory (binary placed directly here) | +| `COPILOT_SKIP_CLI_DOWNLOAD` | Set to `1` to disable auto-download | +| `COPILOT_CLI_DOWNLOAD_BASE_URL` | Override the GitHub Releases download URL | + ## Run the Sample Try the interactive chat sample (from the repo root): diff --git a/python/copilot/__main__.py b/python/copilot/__main__.py new file mode 100644 index 000000000..f6a1bd034 --- /dev/null +++ b/python/copilot/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for `python -m copilot`.""" + +from ._cli_download import main + +if __name__ == "__main__": + main() diff --git a/python/copilot/_cli_download.py b/python/copilot/_cli_download.py new file mode 100644 index 000000000..1a5ebc902 --- /dev/null +++ b/python/copilot/_cli_download.py @@ -0,0 +1,386 @@ +"""Download and cache the Copilot CLI binary. + +This module implements a download-at-first-use strategy for the Copilot CLI +binary, similar to the Rust SDK's build.rs approach but triggered at runtime. +The binary is cached in a shared directory compatible with the Rust SDK: + +- Linux: ~/.cache/github-copilot-sdk/cli/{version}/copilot +- macOS: ~/Library/Caches/github-copilot-sdk/cli/{version}/copilot +- Windows: %LOCALAPPDATA%/github-copilot-sdk/cli/{version}/copilot.exe + +Environment variables: +- COPILOT_CLI_EXTRACT_DIR: Override the cache directory (binary placed directly here). +- COPILOT_SKIP_CLI_DOWNLOAD: Set to "1" or "true" to disable auto-download. +- COPILOT_CLI_DOWNLOAD_BASE_URL: Override the GitHub Releases base URL. +""" + +from __future__ import annotations + +import hashlib +import io +import os +import re +import stat +import sys +import tarfile +import tempfile +import time +import zipfile +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +from ._cli_version import ( + CLI_VERSION, + get_asset_info, + get_checksums_url, + get_download_url, +) + +_CACHE_DIR_NAME = "github-copilot-sdk" +_MAX_RETRIES = 3 + + +def _sanitize_version(version: str) -> str: + """Sanitize version string for use as a directory name. + + Replaces any character not in [a-zA-Z0-9._-] with underscore. + Matches the Rust SDK's sanitization logic. + """ + return re.sub(r"[^a-zA-Z0-9._\-]", "_", version) + + +def get_cache_dir(version: str | None = None) -> Path: + """Return the cache directory for CLI binaries. + + Args: + version: CLI version string. If None, returns the root cache dir. + """ + # COPILOT_CLI_EXTRACT_DIR overrides the entire version-specific directory + # (binary lives directly at $dir/, no version subdir). Matches Rust SDK. + extract_override = os.environ.get("COPILOT_CLI_EXTRACT_DIR") + if extract_override: + return Path(extract_override) + + if sys.platform == "darwin": + root = Path.home() / "Library" / "Caches" / _CACHE_DIR_NAME + elif sys.platform == "win32": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + root = Path(local_app_data) / _CACHE_DIR_NAME + else: + root = Path.home() / "AppData" / "Local" / _CACHE_DIR_NAME + else: + xdg = os.environ.get("XDG_CACHE_HOME") + if xdg: + root = Path(xdg) / _CACHE_DIR_NAME + else: + root = Path.home() / ".cache" / _CACHE_DIR_NAME + + if version: + return root / "cli" / _sanitize_version(version) + return root / "cli" + + +def get_cached_cli_path(version: str | None = None) -> str | None: + """Return the path to the cached CLI binary if it exists. + + Args: + version: CLI version. Defaults to the pinned CLI_VERSION. + + Returns: + Path to the binary, or None if not cached. + """ + ver = version or CLI_VERSION + if not ver: + return None + + try: + _, binary_name = get_asset_info() + except RuntimeError: + return None + binary_path = get_cache_dir(ver) / binary_name + + if binary_path.exists(): + return str(binary_path) + return None + + +def _should_skip_download() -> bool: + """Check if auto-download is disabled via environment variable.""" + val = os.environ.get("COPILOT_SKIP_CLI_DOWNLOAD", "").lower() + return val in ("1", "true", "yes") + + +def _fetch_checksums(version: str) -> dict[str, str]: + """Fetch and parse the SHA256SUMS.txt file. + + Returns a dict mapping filename → sha256 hex digest. + """ + url = get_checksums_url(version) + last_exc: Exception | None = None + for attempt in range(_MAX_RETRIES): + try: + with urlopen(url, timeout=30) as response: + text = response.read().decode("utf-8") + break + except (HTTPError, URLError) as exc: + last_exc = exc + if attempt < _MAX_RETRIES - 1: + time.sleep(2**attempt) + else: + raise RuntimeError( + f"Failed to download checksums from {url}: {last_exc}\n\n" + "If you are in an offline or firewalled environment, set " + "COPILOT_CLI_PATH to point to a manually-installed binary." + ) from last_exc + + checksums: dict[str, str] = {} + for line in text.strip().splitlines(): + parts = line.split() + if len(parts) == 2: + digest, filename = parts + # Some formats use *filename (binary mode indicator) + checksums[filename.lstrip("*")] = digest + return checksums + + +def _verify_checksum(data: bytes, expected_hash: str, filename: str) -> None: + """Verify SHA-256 checksum of downloaded data.""" + actual = hashlib.sha256(data).hexdigest() + if actual != expected_hash: + raise RuntimeError( + f"Checksum mismatch for {filename}:\n expected: {expected_hash}\n actual: {actual}" + ) + + +def _extract_tar_gz(data: bytes, binary_name: str, dest_dir: Path) -> Path: + """Extract the CLI binary from a .tar.gz archive.""" + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: + # Find the binary in the archive (may be at top level or in a subdirectory) + members = tf.getnames() + target_member = None + for name in members: + if name == binary_name or name.endswith(f"/{binary_name}"): + target_member = name + break + + if target_member is None: + raise RuntimeError( + f"Binary '{binary_name}' not found in archive. Archive contains: {members}" + ) + + member = tf.getmember(target_member) + f = tf.extractfile(member) + if f is None: + raise RuntimeError(f"Could not extract '{target_member}' from archive") + + dest_path = dest_dir / binary_name + with open(dest_path, "wb") as out: + out.write(f.read()) + + return dest_path + + +def _extract_zip(data: bytes, binary_name: str, dest_dir: Path) -> Path: + """Extract the CLI binary from a .zip archive.""" + with zipfile.ZipFile(io.BytesIO(data)) as zf: + names = zf.namelist() + target_member = None + for name in names: + if name == binary_name or name.endswith(f"/{binary_name}"): + target_member = name + break + + if target_member is None: + raise RuntimeError( + f"Binary '{binary_name}' not found in archive. Archive contains: {names}" + ) + + dest_path = dest_dir / binary_name + with zf.open(target_member) as src, open(dest_path, "wb") as out: + out.write(src.read()) + + return dest_path + + +def download_cli(version: str | None = None, *, force: bool = False) -> str: + """Download the Copilot CLI binary and cache it. + + Args: + version: CLI version to download. Defaults to the pinned CLI_VERSION. + force: If True, re-download even if already cached. + + Returns: + Path to the cached binary. + + Raises: + RuntimeError: If the version is not set, download fails, or + checksum verification fails. + """ + ver = version or CLI_VERSION + if not ver: + raise RuntimeError( + "No CLI version pinned. This is a development install — " + "set COPILOT_CLI_PATH or install a published wheel." + ) + + archive_name, binary_name = get_asset_info() + cache_dir = get_cache_dir(ver) + binary_path = cache_dir / binary_name + + # Return cached binary if available (unless force) + if not force and binary_path.exists(): + return str(binary_path) + + # Fetch checksums + checksums = _fetch_checksums(ver) + expected_hash = checksums.get(archive_name) + if not expected_hash: + raise RuntimeError( + f"No checksum found for '{archive_name}' in SHA256SUMS.txt. " + f"Available files: {list(checksums.keys())}" + ) + + # Download archive with retries + url = get_download_url(ver, archive_name) + last_exc: Exception | None = None + data: bytes | None = None + for attempt in range(_MAX_RETRIES): + try: + with urlopen(url, timeout=120) as response: + data = response.read() + break + except (HTTPError, URLError) as exc: + last_exc = exc + if attempt < _MAX_RETRIES - 1: + time.sleep(2**attempt) + if data is None: + raise RuntimeError( + f"Failed to download runtime from {url}: {last_exc}\n\n" + "If you are in an offline or firewalled environment, you can:\n" + f"1. Manually download the archive from: {url}\n" + f"2. Extract the '{binary_name}' binary to: {binary_path}\n" + "Or set COPILOT_CLI_PATH to point to an existing binary." + ) from last_exc + + # Verify checksum + _verify_checksum(data, expected_hash, archive_name) + + # Extract to a temporary directory, then atomically move into place. + # This prevents partial/corrupt cache entries if the process is interrupted. + cache_dir.mkdir(parents=True, exist_ok=True) + staging_dir = Path(tempfile.mkdtemp(dir=cache_dir, prefix=".download-")) + try: + if archive_name.endswith(".tar.gz"): + extracted = _extract_tar_gz(data, binary_name, staging_dir) + elif archive_name.endswith(".zip"): + extracted = _extract_zip(data, binary_name, staging_dir) + else: + raise RuntimeError(f"Unknown archive format: {archive_name}") + + # Make executable on Unix + if sys.platform != "win32": + extracted.chmod(extracted.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + # Atomic rename into final location. Handle concurrent processes: + # another process may have written the file while we were downloading. + try: + extracted.replace(binary_path) + except OSError: + if not force and binary_path.exists(): + return str(binary_path) + raise + finally: + # Clean up staging directory + try: + staging_dir.rmdir() + except OSError: + # May not be empty if rename failed or other files were extracted + import shutil + + shutil.rmtree(staging_dir, ignore_errors=True) + + return str(binary_path) + + +def get_or_download_cli(version: str | None = None) -> str | None: + """Get the cached CLI binary, downloading it if necessary. + + Returns None if: + - No version is pinned (dev install) + - Auto-download is disabled via COPILOT_SKIP_CLI_DOWNLOAD + - The platform is unsupported + + Raises RuntimeError on download/verification failures. + """ + ver = version or CLI_VERSION + if not ver: + return None + + # Check cache first + cached = get_cached_cli_path(ver) + if cached: + return cached + + # Check if download is disabled + if _should_skip_download(): + return None + + # Check platform support before attempting download + try: + get_asset_info() + except RuntimeError: + return None + + # Download + return download_cli(ver) + + +def main() -> None: + """CLI entry point for `python -m copilot download-runtime`.""" + import argparse + + parser = argparse.ArgumentParser( + prog="python -m copilot", + description="Copilot SDK utilities", + ) + subparsers = parser.add_subparsers(dest="command") + + # download-runtime subcommand + dl_parser = subparsers.add_parser( + "download-runtime", + help="Download the Copilot runtime", + ) + dl_parser.add_argument( + "--force", + action="store_true", + help="Re-download even if already cached", + ) + dl_parser.add_argument( + "--version", + help="Runtime version to download (default: pinned version)", + ) + + args = parser.parse_args() + + if args.command == "download-runtime": + ver = args.version or CLI_VERSION + if not ver: + print( + "Error: No runtime version pinned (development install). " + "Use --version to specify a version.", + file=sys.stderr, + ) + sys.exit(1) + + print(f"Downloading Copilot runtime v{ver}...") + try: + path = download_cli(ver, force=args.force) + print(f"Runtime cached at: {path}") + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + else: + parser.print_help() + sys.exit(1) diff --git a/python/copilot/_cli_version.py b/python/copilot/_cli_version.py new file mode 100644 index 000000000..4ff9cfb7a --- /dev/null +++ b/python/copilot/_cli_version.py @@ -0,0 +1,95 @@ +"""Copilot CLI version and platform asset information. + +At publish time, CLI_VERSION is overwritten by scripts/inject-cli-version.mjs +with the concrete version string (e.g. "1.0.64-1"). In development (editable +installs, running from source) the sentinel value None disables automatic +download — callers must set an explicit path or COPILOT_CLI_PATH. +""" + +from __future__ import annotations + +import platform +import sys + +# Sentinel: None means "no pinned version" (dev/editable install). +# Overwritten at publish time by scripts/inject-cli-version.mjs. +# DO NOT reformat this line — the inject script matches it exactly. +CLI_VERSION: str | None = None + +# Maps (sys.platform, platform.machine()) → (archive filename, binary name inside archive). +PLATFORM_ASSETS: dict[tuple[str, str], tuple[str, str]] = { + ("linux", "x86_64"): ("copilot-linux-x64.tar.gz", "copilot"), + ("linux", "aarch64"): ("copilot-linux-arm64.tar.gz", "copilot"), + ("linux", "arm64"): ("copilot-linux-arm64.tar.gz", "copilot"), + ("darwin", "x86_64"): ("copilot-darwin-x64.tar.gz", "copilot"), + ("darwin", "arm64"): ("copilot-darwin-arm64.tar.gz", "copilot"), + ("win32", "AMD64"): ("copilot-win32-x64.zip", "copilot.exe"), + ("win32", "ARM64"): ("copilot-win32-arm64.zip", "copilot.exe"), +} + +# Musl (Alpine) variants — detected at runtime via _is_musl(). +_MUSL_ASSETS: dict[str, tuple[str, str]] = { + "x86_64": ("copilot-linuxmusl-x64.tar.gz", "copilot"), + "aarch64": ("copilot-linuxmusl-arm64.tar.gz", "copilot"), + "arm64": ("copilot-linuxmusl-arm64.tar.gz", "copilot"), +} + +_DOWNLOAD_BASE_URL = "https://github.com/github/copilot-cli/releases/download" + + +def _is_musl() -> bool: + """Detect whether the current Linux system uses musl libc (e.g. Alpine).""" + if sys.platform != "linux": + return False + try: + import subprocess + + result = subprocess.run(["ldd", "--version"], capture_output=True, text=True, timeout=5) + # musl's ldd prints "musl libc" in its output + output = result.stdout + result.stderr + return "musl" in output.lower() + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return False + + +def get_platform_key() -> tuple[str, str]: + """Return the (sys.platform, machine) key for the current platform.""" + return (sys.platform, platform.machine()) + + +def get_asset_info() -> tuple[str, str]: + """Return (archive_filename, binary_name) for the current platform. + + Raises RuntimeError if the platform is not supported. + """ + key = get_platform_key() + + # On Linux, check for musl/Alpine first + if key[0] == "linux" and _is_musl(): + musl_info = _MUSL_ASSETS.get(key[1]) + if musl_info: + return musl_info + + info = PLATFORM_ASSETS.get(key) + if info is None: + raise RuntimeError( + f"Unsupported platform: {key[0]}/{key[1]}. " + f"Supported platforms: {', '.join(f'{p}/{m}' for p, m in PLATFORM_ASSETS)}" + ) + return info + + +def get_download_url(version: str, archive_name: str) -> str: + """Return the download URL for a given version and archive.""" + import os + + base = os.environ.get("COPILOT_CLI_DOWNLOAD_BASE_URL", _DOWNLOAD_BASE_URL) + return f"{base}/v{version}/{archive_name}" + + +def get_checksums_url(version: str) -> str: + """Return the URL for the SHA256SUMS.txt file.""" + import os + + base = os.environ.get("COPILOT_CLI_DOWNLOAD_BASE_URL", _DOWNLOAD_BASE_URL) + return f"{base}/v{version}/SHA256SUMS.txt" diff --git a/python/copilot/client.py b/python/copilot/client.py index 2c407149c..381ffec14 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -28,7 +28,6 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime -from pathlib import Path from types import TracebackType from typing import Any, ClassVar, Literal, TypedDict, cast, overload @@ -948,24 +947,15 @@ def _session_lifecycle_event_from_dict(data: dict) -> SessionLifecycleEvent: _CLI_PROCESS_EXIT_TIMEOUT_SECONDS = 5 -def _get_bundled_cli_path() -> str | None: - """Get the path to the bundled CLI binary, if available.""" - # The binary is bundled in copilot/bin/ within the package - bin_dir = Path(__file__).parent / "bin" - if not bin_dir.exists(): - return None +def _get_or_download_cli() -> str | None: + """Get the cached CLI binary, downloading if necessary. - # Determine binary name based on platform - if sys.platform == "win32": - binary_name = "copilot.exe" - else: - binary_name = "copilot" - - binary_path = bin_dir / binary_name - if binary_path.exists(): - return str(binary_path) + Returns the path to the CLI binary, or None if unavailable (dev install + with no pinned version, or auto-download disabled). + """ + from ._cli_download import get_or_download_cli - return None + return get_or_download_cli() def _extract_transform_callbacks( @@ -1166,7 +1156,7 @@ def __init__( else: self._effective_connection_token = None - # Resolve CLI path: explicit > COPILOT_CLI_PATH env var > bundled binary. + # Resolve CLI path: explicit > COPILOT_CLI_PATH env var > downloaded binary. effective_env = options.env if options.env is not None else os.environ self._cli_path_source: str | None = "explicit" if connection.path is None: @@ -1175,14 +1165,15 @@ def __init__( connection.path = env_cli_path self._cli_path_source = "environment" else: - bundled_path = _get_bundled_cli_path() - if bundled_path: - connection.path = bundled_path - self._cli_path_source = "bundled" + downloaded_path = _get_or_download_cli() + if downloaded_path: + connection.path = downloaded_path + self._cli_path_source = "downloaded" else: raise RuntimeError( - "Copilot CLI not found. The bundled CLI binary is not available. " - "Ensure you installed a platform-specific wheel, or set " + "Copilot CLI not found. Install a published wheel (which " + "auto-downloads the CLI on first use), set COPILOT_CLI_PATH, " + "or pass an explicit path via " "RuntimeConnection.for_stdio(path=...) / " "RuntimeConnection.for_tcp(path=...)." ) diff --git a/python/pyproject.toml b/python/pyproject.toml index 596e07be2..fee99135b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -12,7 +12,6 @@ description = "Python SDK for GitHub Copilot CLI" readme = "README.md" requires-python = ">=3.11" license = "MIT" -# license-files is set by scripts/build-wheels.mjs for bundled CLI wheels authors = [ {name = "GitHub", email = "opensource@github.com"} ] @@ -48,8 +47,6 @@ telemetry = [ "opentelemetry-api>=1.0.0", ] -# Use find with a glob so that the copilot.bin subpackage (created dynamically -# by scripts/build-wheels.mjs during publishing) is included in platform wheels. [tool.setuptools.packages.find] where = ["."] include = ["copilot*"] diff --git a/python/scripts/build-wheels.mjs b/python/scripts/build-wheels.mjs deleted file mode 100644 index c9d49b414..000000000 --- a/python/scripts/build-wheels.mjs +++ /dev/null @@ -1,373 +0,0 @@ -#!/usr/bin/env node -/** - * Build platform-specific Python wheels with bundled Copilot CLI binaries. - * - * Downloads the Copilot CLI binary for each platform from the npm registry - * and builds a wheel that includes it. - * - * Usage: - * node scripts/build-wheels.mjs [--platform PLATFORM] [--output-dir DIR] - * - * --platform: Build for specific platform only (linux-x64, linux-arm64, darwin-x64, - * darwin-arm64, win32-x64, win32-arm64). If not specified, builds all. - * --output-dir: Directory for output wheels (default: dist/) - */ - -import { execSync } from "node:child_process"; -import { - createWriteStream, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, - chmodSync, - rmSync, - cpSync, - readdirSync, - statSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { pipeline } from "node:stream/promises"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const pythonDir = dirname(__dirname); -const repoRoot = dirname(pythonDir); - -// Platform mappings: npm package suffix -> [wheel platform tag, binary name] -// Based on Node 24.11 binaries being included in the wheels -const PLATFORMS = { - "linux-x64": ["manylinux_2_28_x86_64", "copilot"], - "linux-arm64": ["manylinux_2_28_aarch64", "copilot"], - "darwin-x64": ["macosx_10_9_x86_64", "copilot"], - "darwin-arm64": ["macosx_11_0_arm64", "copilot"], - "win32-x64": ["win_amd64", "copilot.exe"], - "win32-arm64": ["win_arm64", "copilot.exe"], -}; - -function getCliVersion() { - const packageLockPath = join(repoRoot, "nodejs", "package-lock.json"); - if (!existsSync(packageLockPath)) { - throw new Error( - `package-lock.json not found at ${packageLockPath}. Run 'npm install' in nodejs/ first.` - ); - } - - const packageLock = JSON.parse(readFileSync(packageLockPath, "utf-8")); - const version = packageLock.packages?.["node_modules/@github/copilot"]?.version; - - if (!version) { - throw new Error("Could not find @github/copilot version in package-lock.json"); - } - - return version; -} - -function getPkgVersion() { - const pyprojectPath = join(pythonDir, "pyproject.toml"); - const content = readFileSync(pyprojectPath, "utf-8"); - const match = content.match(/version\s*=\s*"([^"]+)"/); - if (!match) { - throw new Error("Could not find version in pyproject.toml"); - } - return match[1]; -} - -async function downloadCliBinary(platform, cliVersion, cacheDir) { - const [, binaryName] = PLATFORMS[platform]; - const cachedBinary = join(cacheDir, binaryName); - - // Check cache - if (existsSync(cachedBinary)) { - console.log(` Using cached ${binaryName}`); - return cachedBinary; - } - - const tarballUrl = `https://registry.npmjs.org/@github/copilot-${platform}/-/copilot-${platform}-${cliVersion}.tgz`; - console.log(` Downloading from ${tarballUrl}...`); - - // Download tarball - const response = await fetch(tarballUrl); - if (!response.ok) { - throw new Error(`Failed to download: ${response.status} ${response.statusText}`); - } - - // Extract to cache dir - mkdirSync(cacheDir, { recursive: true }); - - const tarballPath = join(cacheDir, `copilot-${platform}-${cliVersion}.tgz`); - const fileStream = createWriteStream(tarballPath); - - await pipeline(response.body, fileStream); - - // Extract binary from tarball using system tar - // On Windows, use the system32 tar to avoid Git Bash tar issues - const tarCmd = process.platform === "win32" - ? `"${process.env.SystemRoot}\\System32\\tar.exe"` - : "tar"; - - try { - execSync(`${tarCmd} -xzf "${tarballPath}" -C "${cacheDir}" --strip-components=1 "package/${binaryName}"`, { - stdio: "inherit", - }); - } catch (e) { - // Clean up on failure - if (existsSync(tarballPath)) { - rmSync(tarballPath); - } - throw new Error(`Failed to extract binary: ${e.message}`); - } - - // Clean up tarball - rmSync(tarballPath); - - // Verify binary exists - if (!existsSync(cachedBinary)) { - throw new Error(`Binary not found after extraction: ${cachedBinary}`); - } - - // Make executable on Unix - if (!binaryName.endsWith(".exe")) { - chmodSync(cachedBinary, 0o755); - } - - const size = statSync(cachedBinary).size / 1024 / 1024; - console.log(` Downloaded ${binaryName} (${size.toFixed(1)} MB)`); - - return cachedBinary; -} - -function getCliLicensePath() { - // Use license from node_modules (requires npm ci in nodejs/ first) - const licensePath = join(repoRoot, "nodejs", "node_modules", "@github", "copilot", "LICENSE.md"); - if (!existsSync(licensePath)) { - throw new Error( - `CLI LICENSE.md not found at ${licensePath}. Run 'npm ci' in nodejs/ first.` - ); - } - return licensePath; -} - -async function buildWheel(platform, pkgVersion, cliVersion, outputDir, licensePath) { - const [wheelTag, binaryName] = PLATFORMS[platform]; - console.log(`\nBuilding wheel for ${platform}...`); - - // Cache directory includes version - const cacheDir = join(pythonDir, ".cli-cache", cliVersion, platform); - - // Download/get cached binary - const binaryPath = await downloadCliBinary(platform, cliVersion, cacheDir); - - // Create temp build directory - const buildDir = join(pythonDir, ".build-temp", platform); - if (existsSync(buildDir)) { - rmSync(buildDir, { recursive: true }); - } - mkdirSync(buildDir, { recursive: true }); - - // Copy package source - const pkgDir = join(buildDir, "copilot"); - cpSync(join(pythonDir, "copilot"), pkgDir, { recursive: true }); - - // Create bin directory and copy binary - const binDir = join(pkgDir, "bin"); - mkdirSync(binDir, { recursive: true }); - cpSync(binaryPath, join(binDir, binaryName)); - - // Create VERSION file - writeFileSync(join(binDir, "VERSION"), cliVersion); - - // Create __init__.py - writeFileSync(join(binDir, "__init__.py"), '"""Bundled Copilot CLI binary."""\n'); - - // Copy and modify pyproject.toml for bundled CLI wheel - let pyprojectContent = readFileSync(join(pythonDir, "pyproject.toml"), "utf-8"); - - // Update SPDX expression and add license-files for both SDK and bundled CLI licenses - pyprojectContent = pyprojectContent.replace( - 'license = "MIT"', - 'license = "MIT AND LicenseRef-Copilot-CLI"\nlicense-files = ["LICENSE", "CLI-LICENSE.md"]' - ); - - // Add package-data configuration - const packageDataConfig = ` -[tool.setuptools.package-data] -"copilot.bin" = ["*"] -`; - pyprojectContent = pyprojectContent.replace("\n[tool.ruff]", `${packageDataConfig}\n[tool.ruff]`); - writeFileSync(join(buildDir, "pyproject.toml"), pyprojectContent); - - // Copy README - if (existsSync(join(pythonDir, "README.md"))) { - cpSync(join(pythonDir, "README.md"), join(buildDir, "README.md")); - } - - // Copy SDK LICENSE - cpSync(join(repoRoot, "LICENSE"), join(buildDir, "LICENSE")); - - // Copy CLI LICENSE - cpSync(licensePath, join(buildDir, "CLI-LICENSE.md")); - - // Build wheel using uv (faster and doesn't require build package to be installed) - const distDir = join(buildDir, "dist"); - execSync("uv build --wheel", { - cwd: buildDir, - stdio: "inherit", - }); - - // Find built wheel - const wheels = readdirSync(distDir).filter((f) => f.endsWith(".whl")); - if (wheels.length === 0) { - throw new Error("No wheel found after build"); - } - - const srcWheel = join(distDir, wheels[0]); - const newName = wheels[0].replace("-py3-none-any.whl", `-py3-none-${wheelTag}.whl`); - const destWheel = join(outputDir, newName); - - // Repack wheel with correct platform tag - await repackWheelWithPlatform(srcWheel, destWheel, wheelTag); - - // Clean up build dir - rmSync(buildDir, { recursive: true }); - - const size = statSync(destWheel).size / 1024 / 1024; - console.log(` Built ${newName} (${size.toFixed(1)} MB)`); - - return destWheel; -} - -async function repackWheelWithPlatform(srcWheel, destWheel, platformTag) { - // Write Python script to temp file to avoid shell escaping issues - const script = ` -import sys -import zipfile -import tempfile -from pathlib import Path - -src_wheel = Path(sys.argv[1]) -dest_wheel = Path(sys.argv[2]) -platform_tag = sys.argv[3] - -with tempfile.TemporaryDirectory() as tmpdir: - tmpdir = Path(tmpdir) - - # Extract wheel - with zipfile.ZipFile(src_wheel, 'r') as zf: - zf.extractall(tmpdir) - - # Restore executable bit on the CLI binary (setuptools strips it) - for bin_path in (tmpdir / 'copilot' / 'bin').iterdir(): - if bin_path.name in ('copilot', 'copilot.exe'): - bin_path.chmod(0o755) - - # Find and update WHEEL file - wheel_info_dirs = list(tmpdir.glob('*.dist-info')) - if not wheel_info_dirs: - raise RuntimeError('No .dist-info directory found in wheel') - - wheel_info_dir = wheel_info_dirs[0] - wheel_file = wheel_info_dir / 'WHEEL' - - with open(wheel_file) as f: - wheel_content = f.read() - - wheel_content = wheel_content.replace('Tag: py3-none-any', f'Tag: py3-none-{platform_tag}') - - with open(wheel_file, 'w') as f: - f.write(wheel_content) - - # Regenerate RECORD file - record_file = wheel_info_dir / 'RECORD' - records = [] - for path in tmpdir.rglob('*'): - if path.is_file() and path.name != 'RECORD': - rel_path = path.relative_to(tmpdir) - records.append(f'{rel_path},,') - records.append(f'{wheel_info_dir.name}/RECORD,,') - - with open(record_file, 'w') as f: - f.write('\\n'.join(records)) - - # Create new wheel - dest_wheel.parent.mkdir(parents=True, exist_ok=True) - if dest_wheel.exists(): - dest_wheel.unlink() - - with zipfile.ZipFile(dest_wheel, 'w', zipfile.ZIP_DEFLATED) as zf: - for path in tmpdir.rglob('*'): - if path.is_file(): - zf.write(path, path.relative_to(tmpdir)) -`; - - // Write script to temp file - const scriptPath = join(pythonDir, ".build-temp", "repack_wheel.py"); - mkdirSync(dirname(scriptPath), { recursive: true }); - writeFileSync(scriptPath, script); - - try { - execSync(`python "${scriptPath}" "${srcWheel}" "${destWheel}" "${platformTag}"`, { - stdio: "inherit", - }); - } finally { - // Clean up script - rmSync(scriptPath); - } -} - -async function main() { - const args = process.argv.slice(2); - let platform = null; - let outputDir = join(pythonDir, "dist"); - - // Parse args - for (let i = 0; i < args.length; i++) { - if (args[i] === "--platform" && args[i + 1]) { - platform = args[++i]; - if (!PLATFORMS[platform]) { - console.error(`Invalid platform: ${platform}`); - console.error(`Valid platforms: ${Object.keys(PLATFORMS).join(", ")}`); - process.exit(1); - } - } else if (args[i] === "--output-dir" && args[i + 1]) { - outputDir = args[++i]; - } - } - - const cliVersion = getCliVersion(); - const pkgVersion = getPkgVersion(); - - console.log(`CLI version: ${cliVersion}`); - console.log(`Package version: ${pkgVersion}`); - - mkdirSync(outputDir, { recursive: true }); - - // Get CLI license from node_modules - const licensePath = getCliLicensePath(); - - const platforms = platform ? [platform] : Object.keys(PLATFORMS); - const wheels = []; - - for (const p of platforms) { - try { - const wheel = await buildWheel(p, pkgVersion, cliVersion, outputDir, licensePath); - wheels.push(wheel); - } catch (e) { - console.error(`Error building wheel for ${p}:`, e.message); - if (platform) { - process.exit(1); - } - } - } - - console.log(`\nBuilt ${wheels.length} wheel(s):`); - for (const wheel of wheels) { - console.log(` ${wheel}`); - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/python/scripts/inject-cli-version.mjs b/python/scripts/inject-cli-version.mjs new file mode 100644 index 000000000..359e7f680 --- /dev/null +++ b/python/scripts/inject-cli-version.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * inject-cli-version.mjs + * + * Reads the pinned @github/copilot version from nodejs/package-lock.json and + * writes it into python/copilot/_cli_version.py, replacing the `CLI_VERSION = None` + * sentinel with the concrete version string. + * + * Run from the repository root: + * node python/scripts/inject-cli-version.mjs + */ + +import { readFileSync, writeFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, "..", ".."); + +// Read version from nodejs/package-lock.json +const lockPath = join(repoRoot, "nodejs", "package-lock.json"); +const lock = JSON.parse(readFileSync(lockPath, "utf-8")); + +// The version is in packages["node_modules/@github/copilot"].version +const copilotPkg = lock.packages?.["node_modules/@github/copilot"]; +if (!copilotPkg?.version) { + console.error( + "Error: Could not find @github/copilot version in nodejs/package-lock.json" + ); + process.exit(1); +} +const version = copilotPkg.version; +console.log(`Injecting CLI_VERSION = "${version}"`); + +// Patch _cli_version.py +const versionFile = join(__dirname, "..", "copilot", "_cli_version.py"); +let content = readFileSync(versionFile, "utf-8"); + +const sentinel = 'CLI_VERSION: str | None = None'; +const replacement = `CLI_VERSION: str | None = "${version}"`; + +if (!content.includes(sentinel)) { + // Check if already injected + if (content.includes(`CLI_VERSION: str | None = "`)) { + console.log("CLI_VERSION already injected, updating..."); + content = content.replace(/CLI_VERSION: str \| None = ".*?"/, `CLI_VERSION: str | None = "${version}"`); + } else { + console.error(`Error: Could not find sentinel '${sentinel}' in _cli_version.py`); + process.exit(1); + } +} else { + content = content.replace(sentinel, replacement); +} + +writeFileSync(versionFile, content); +console.log(`Done. _cli_version.py now has CLI_VERSION = "${version}"`);