From 0d3f6f05102a45f1363162c6894d4be67b8bddf3 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 14:29:53 +0000 Subject: [PATCH 01/10] Optionally download CLI at runtime in Node SDK --- nodejs/.gitignore | 3 + nodejs/README.md | 63 ++++- nodejs/examples/basic-example.ts | 20 +- nodejs/package-lock.json | 75 +++++- nodejs/package.json | 7 +- nodejs/scripts/generate-versions.ts | 18 ++ nodejs/src/acquisition.ts | 343 ++++++++++++++++++++++++++++ nodejs/src/client.ts | 19 +- nodejs/src/index.ts | 1 + nodejs/src/types.ts | 12 +- nodejs/test/acquisition.test.ts | 88 +++++++ nodejs/test/client.test.ts | 31 +++ nodejs/test/e2e/acquisition.test.ts | 133 +++++++++++ 13 files changed, 799 insertions(+), 14 deletions(-) create mode 100644 nodejs/scripts/generate-versions.ts create mode 100644 nodejs/src/acquisition.ts create mode 100644 nodejs/test/acquisition.test.ts create mode 100644 nodejs/test/e2e/acquisition.test.ts diff --git a/nodejs/.gitignore b/nodejs/.gitignore index 60d34ef2..3fa3650e 100644 --- a/nodejs/.gitignore +++ b/nodejs/.gitignore @@ -133,5 +133,8 @@ dist dist/ build/ +# Generated version constants (regenerated at build time) +src/generated/ + # macOS .DS_Store diff --git a/nodejs/README.md b/nodejs/README.md index 9ad030aa..5ec20b48 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -55,9 +55,10 @@ new CopilotClient(options?: CopilotClientOptions) **Options:** -- `cliPath?: string` - Path to CLI executable (default: "copilot" from PATH) +- `cliPath?: string` - Path to CLI executable (default: "copilot" from PATH). Mutually exclusive with `acquisition`. - `cliArgs?: string[]` - Extra arguments prepended before SDK-managed flags (e.g. `["./dist-cli/index.js"]` when using `node`) -- `cliUrl?: string` - URL of existing CLI server to connect to (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). When provided, the client will not spawn a CLI process. +- `cliUrl?: string` - URL of existing CLI server to connect to (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). When provided, the client will not spawn a CLI process. Mutually exclusive with `acquisition`. +- `acquisition?: AcquisitionOptions` - Auto-download CLI if not present. See [CLI Acquisition](#cli-acquisition). Mutually exclusive with `cliPath` and `cliUrl`. - `port?: number` - Server port (default: 0 for random) - `useStdio?: boolean` - Use stdio transport instead of TCP (default: true) - `logLevel?: string` - Log level (default: "info") @@ -645,7 +646,63 @@ try { ## Requirements - Node.js >= 18.0.0 -- GitHub Copilot CLI installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI installed and in PATH, or: + - Provide a custom `cliPath`, or + - Use `acquisition` to auto-download the CLI (see below) + +## CLI Acquisition + +The SDK can automatically download and manage the Copilot CLI for you. This is useful when you can't rely on the CLI being pre-installed. + +```typescript +const client = new CopilotClient({ + acquisition: { + downloadDir: "~/.myapp/copilot-cli", // Where to store CLI versions + }, +}); + +await client.start(); // Downloads CLI if needed, then starts +``` + +### How it works + +1. **Check existing downloads**: Looks for any CLI version in `downloadDir` that is both >= your `minVersion` (if specified) and protocol-compatible with this SDK. + +2. **If a suitable version exists**: Uses the highest compatible version. No download needed. + +3. **If no suitable version exists**: Downloads the CLI version this SDK was built for. + +This means SDK upgrades don't force re-downloads — your existing CLI is reused as long as it still works. + +### Options + +```typescript +interface AcquisitionOptions { + // Required: Directory for CLI downloads. Should be app-specific. + downloadDir: string; + + // Optional: Minimum CLI version required (e.g., "0.0.405"). + // If your existing version is lower, a new version will be downloaded. + minVersion?: string; + + // Optional: Progress callback for download updates. + onProgress?: (progress: { bytesDownloaded: number; totalBytes: number }) => void; +} +``` + +### Example with progress reporting + +```typescript +const client = new CopilotClient({ + acquisition: { + downloadDir: path.join(os.homedir(), ".myapp", "copilot-cli"), + onProgress: ({ bytesDownloaded, totalBytes }) => { + const pct = totalBytes > 0 ? Math.round((bytesDownloaded / totalBytes) * 100) : 0; + console.log(`Downloading CLI: ${pct}%`); + }, + }, +}); +``` ## License diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index b0b99313..ad22f5cf 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -3,6 +3,8 @@ *--------------------------------------------------------------------------------------------*/ import { z } from "zod"; +import { join } from "node:path"; +import { homedir } from "node:os"; import { CopilotClient, defineTool } from "../src/index.js"; console.log("🚀 Starting Copilot SDK Example\n"); @@ -20,10 +22,22 @@ const lookupFactTool = defineTool("lookup_fact", { handler: ({ topic }) => facts[topic.toLowerCase()] ?? `No fact stored for ${topic}.`, }); -// Create client - will auto-start CLI server (searches PATH for "copilot") -const client = new CopilotClient({ logLevel: "info" }); +// Create client with automatic CLI acquisition +const client = new CopilotClient({ + logLevel: "info", + acquisition: { + downloadDir: join(homedir(), ".copilot-sdk-example", "cli"), + onProgress: ({ bytesDownloaded, totalBytes }) => { + if (totalBytes > 0) { + const pct = Math.round((bytesDownloaded / totalBytes) * 100); + process.stdout.write(`\r⬇️ Downloading CLI: ${pct}%`); + } + }, + }, +}); + const session = await client.createSession({ tools: [lookupFactTool] }); -console.log(`✅ Session created: ${session.sessionId}\n`); +console.log(`\n✅ Session created: ${session.sessionId}\n`); // Listen to events session.on((event) => { diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 4928a21d..3ef7d94e 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -10,11 +10,13 @@ "license": "MIT", "dependencies": { "@github/copilot": "^0.0.402", + "semver": "^7.7.3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, "devDependencies": { "@types/node": "^25.2.0", + "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "esbuild": "^0.27.2", @@ -25,7 +27,7 @@ "prettier": "^3.4.0", "quicktype-core": "^23.2.6", "rimraf": "^6.1.2", - "semver": "^7.7.3", + "tar": "^7.5.7", "tsx": "^4.20.6", "typescript": "^5.0.0", "vitest": "^4.0.18" @@ -874,6 +876,19 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1294,6 +1309,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -1884,6 +1906,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/collection-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collection-utils/-/collection-utils-1.0.1.tgz", @@ -2785,6 +2817,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3244,7 +3289,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3460,6 +3504,23 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -3959,6 +4020,16 @@ "node": ">=8" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index d2af6828..0426d1c6 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -16,7 +16,8 @@ }, "type": "module", "scripts": { - "clean": "rimraf --glob dist *.tgz", + "clean": "rimraf --glob dist *.tgz src/generated", + "prebuild": "tsx scripts/generate-versions.ts", "build": "tsx esbuild-copilotsdk-nodejs.ts", "test": "vitest run", "test:watch": "vitest", @@ -41,11 +42,13 @@ "license": "MIT", "dependencies": { "@github/copilot": "^0.0.402", + "semver": "^7.7.3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, "devDependencies": { "@types/node": "^25.2.0", + "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "esbuild": "^0.27.2", @@ -56,7 +59,7 @@ "prettier": "^3.4.0", "quicktype-core": "^23.2.6", "rimraf": "^6.1.2", - "semver": "^7.7.3", + "tar": "^7.5.7", "tsx": "^4.20.6", "typescript": "^5.0.0", "vitest": "^4.0.18" diff --git a/nodejs/scripts/generate-versions.ts b/nodejs/scripts/generate-versions.ts new file mode 100644 index 00000000..90e80ce2 --- /dev/null +++ b/nodejs/scripts/generate-versions.ts @@ -0,0 +1,18 @@ +// Generates src/generated/versions.ts from package-lock.json @github/copilot version. +// Run automatically via prebuild hook. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageLock = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package-lock.json"), "utf-8")); +const cliVersion = packageLock.packages["node_modules/@github/copilot"].version; + +const code = `// Generated by scripts/generate-versions.ts - DO NOT EDIT +export const PREFERRED_CLI_VERSION = "${cliVersion}"; +`; + +fs.mkdirSync(path.join(__dirname, "..", "src", "generated"), { recursive: true }); +fs.writeFileSync(path.join(__dirname, "..", "src", "generated", "versions.ts"), code); +console.log(`Generated src/generated/versions.ts with PREFERRED_CLI_VERSION="${cliVersion}"`); diff --git a/nodejs/src/acquisition.ts b/nodejs/src/acquisition.ts new file mode 100644 index 00000000..cd332c1b --- /dev/null +++ b/nodejs/src/acquisition.ts @@ -0,0 +1,343 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, chmodSync, copyFileSync } from "node:fs"; +import { rename, rm } from "node:fs/promises"; +import { homedir, platform, arch, tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { extract } from "tar"; +import * as semver from "semver"; +import { CopilotClient } from "./client.js"; +import { PREFERRED_CLI_VERSION } from "./generated/versions.js"; + +export { PREFERRED_CLI_VERSION }; + +export interface AcquisitionOptions { + /** + * Directory where CLI versions will be downloaded and stored. + * Should be app-specific (e.g., `~/.myapp/copilot-cli/`). + */ + downloadDir: string; + + /** + * Minimum CLI version required. + * Format: "major.minor.patch" (e.g., "0.0.402"). No "v" prefix. + */ + minVersion?: string; + + /** + * Callback for download progress updates. + */ + onProgress?: (progress: AcquisitionProgress) => void; +} + +export interface AcquisitionProgress { + bytesDownloaded: number; + totalBytes: number; +} + +const RELEASES_BASE_URL = "https://github.com/github/copilot-cli/releases/download"; + +function getPlatformAsset(): { platform: string; arch: string; ext: string } { + const p = platform(); + const a = arch(); + + let platformName: string; + let archName: string; + let ext: string; + + switch (p) { + case "win32": + platformName = "win32"; + ext = "zip"; + break; + case "darwin": + platformName = "darwin"; + ext = "tar.gz"; + break; + case "linux": + platformName = "linux"; + ext = "tar.gz"; + break; + default: + throw new Error(`Unsupported platform: ${p}`); + } + + switch (a) { + case "x64": + archName = "x64"; + break; + case "arm64": + archName = "arm64"; + break; + default: + throw new Error(`Unsupported architecture: ${a}`); + } + + return { platform: platformName, arch: archName, ext }; +} + +function getDownloadUrl(version: string): string { + const { platform: p, arch: a, ext } = getPlatformAsset(); + return `${RELEASES_BASE_URL}/v${version}/copilot-${p}-${a}.${ext}`; +} + +function getCliExecutablePath(versionDir: string): string { + const execName = platform() === "win32" ? "copilot.exe" : "copilot"; + return join(versionDir, execName); +} + +async function checkProtocolCompatibility(cliPath: string): Promise { + const client = new CopilotClient({ + cliPath, + autoStart: false, + logLevel: "none", + }); + + try { + await client.start(); + return true; + } catch { + return false; + } finally { + try { + await client.forceStop(); + } catch { + // Ignore cleanup errors + } + } +} + +function listExistingVersions(downloadDir: string): string[] { + if (!existsSync(downloadDir)) { + return []; + } + + const versions: string[] = []; + const entries = readdirSync(downloadDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith("copilot-")) { + const version = entry.name.replace("copilot-", ""); + if (semver.valid(version)) { + const execPath = getCliExecutablePath(join(downloadDir, entry.name)); + if (existsSync(execPath)) { + versions.push(version); + } + } + } + } + + return versions.sort((a, b) => semver.compare(b, a)); +} + +async function findSuitableVersion( + downloadDir: string, + minVersion: string | undefined +): Promise { + const versions = listExistingVersions(downloadDir); + + for (const version of versions) { + if (minVersion && semver.lt(version, minVersion)) { + continue; + } + + // Skip protocol check if version matches PREFERRED_CLI_VERSION (known compatible) + if (version === PREFERRED_CLI_VERSION) { + return version; + } + + const cliPath = getCliExecutablePath(join(downloadDir, `copilot-${version}`)); + if (await checkProtocolCompatibility(cliPath)) { + return version; + } + } + + return null; +} + +async function downloadCli( + version: string, + downloadDir: string, + onProgress?: (progress: AcquisitionProgress) => void +): Promise { + const url = getDownloadUrl(version); + const versionDir = join(downloadDir, `copilot-${version}`); + const tempDir = join(tmpdir(), `copilot-download-${Date.now()}-${Math.random().toString(36).slice(2)}`); + + mkdirSync(downloadDir, { recursive: true }); + mkdirSync(tempDir, { recursive: true }); + + try { + const response = await fetch(url, { redirect: "follow" }); + + if (!response.ok) { + throw new Error(`Failed to download CLI v${version}: ${response.status} ${response.statusText}`); + } + + const contentLength = parseInt(response.headers.get("content-length") || "0", 10); + const { ext } = getPlatformAsset(); + const archivePath = join(tempDir, `copilot.${ext}`); + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("Failed to get response body reader"); + } + + const writeStream = createWriteStream(archivePath); + let bytesDownloaded = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + writeStream.write(value); + bytesDownloaded += value.length; + + onProgress?.({ + bytesDownloaded, + totalBytes: contentLength, + }); + } + + await new Promise((resolve, reject) => { + writeStream.end((err: Error | null | undefined) => { + if (err) reject(err); + else resolve(); + }); + }); + + const extractDir = join(tempDir, "extracted"); + mkdirSync(extractDir, { recursive: true }); + + if (ext === "zip") { + await extractZip(archivePath, extractDir); + } else { + await extract({ file: archivePath, cwd: extractDir }); + } + + const execName = platform() === "win32" ? "copilot.exe" : "copilot"; + const extractedExec = findExecutable(extractDir, execName); + + if (!extractedExec) { + throw new Error(`Could not find ${execName} in downloaded archive`); + } + + mkdirSync(dirname(versionDir), { recursive: true }); + const tempVersionDir = join(tempDir, `copilot-${version}`); + mkdirSync(tempVersionDir, { recursive: true }); + + const destExec = join(tempVersionDir, execName); + copyFileSync(extractedExec, destExec); + + if (platform() !== "win32") { + chmodSync(destExec, 0o755); + } + + if (existsSync(versionDir)) { + await rm(versionDir, { recursive: true, force: true }); + } + await rename(tempVersionDir, versionDir); + + return getCliExecutablePath(versionDir); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +} + +async function extractZip(zipPath: string, destDir: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("powershell", [ + "-NoProfile", + "-Command", + `Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force`, + ]); + + proc.on("error", reject); + proc.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Expand-Archive failed with code ${code}`)); + }); + }); +} + +function findExecutable(dir: string, execName: string): string | null { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + const found = findExecutable(fullPath, execName); + if (found) return found; + } else if (entry.name === execName) { + return fullPath; + } + } + + return null; +} + +function cleanupOldVersions(downloadDir: string, keepVersion: string): void { + if (!existsSync(downloadDir)) return; + + for (const entry of readdirSync(downloadDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith("copilot-")) { + const version = entry.name.replace("copilot-", ""); + if (version !== keepVersion) { + try { + rmSync(join(downloadDir, entry.name), { recursive: true, force: true }); + } catch { + // Skip if locked + } + } + } + } +} + +function validateOptions(options: AcquisitionOptions): void { + const normalizedDir = resolve(options.downloadDir); + const forbiddenDir = resolve(homedir(), ".copilot"); + if (normalizedDir === forbiddenDir) { + throw new Error( + `Cannot use '${options.downloadDir}' as downloadDir. ` + + "This directory is reserved for the global Copilot CLI installation. " + + "Please use an app-specific directory (e.g., '~/.myapp/copilot-cli/')." + ); + } + + if (options.minVersion && !semver.valid(options.minVersion)) { + throw new Error( + `Invalid minVersion format: '${options.minVersion}'. ` + + "Expected format: 'major.minor.patch' (e.g., '0.0.402')" + ); + } +} + +export async function acquireCli(options: AcquisitionOptions): Promise { + validateOptions(options); + + const { downloadDir, minVersion, onProgress } = options; + + const effectiveVersion = + minVersion && semver.gt(minVersion, PREFERRED_CLI_VERSION) + ? minVersion + : PREFERRED_CLI_VERSION; + + const suitableVersion = await findSuitableVersion(downloadDir, minVersion); + + if (suitableVersion) { + cleanupOldVersions(downloadDir, suitableVersion); + return getCliExecutablePath(join(downloadDir, `copilot-${suitableVersion}`)); + } + + const cliPath = await downloadCli(effectiveVersion, downloadDir, onProgress); + cleanupOldVersions(downloadDir, effectiveVersion); + + return cliPath; +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 20dc17f8..8fdb0842 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -21,6 +21,7 @@ import { } from "vscode-jsonrpc/node.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; +import { acquireCli, type AcquisitionOptions } from "./acquisition.js"; import type { ConnectionState, CopilotClientOptions, @@ -109,11 +110,12 @@ export class CopilotClient { private state: ConnectionState = "disconnected"; private sessions: Map = new Map(); private options: Required< - Omit + Omit > & { cliUrl?: string; githubToken?: string; useLoggedInUser?: boolean; + acquisition?: AcquisitionOptions; }; private isExternalServer: boolean = false; private forceStopping: boolean = false; @@ -152,6 +154,14 @@ export class CopilotClient { throw new Error("cliUrl is mutually exclusive with useStdio and cliPath"); } + if (options.cliPath && options.acquisition) { + throw new Error("cliPath is mutually exclusive with acquisition (acquisition auto-determines the CLI path)"); + } + + if (options.cliUrl && options.acquisition) { + throw new Error("cliUrl is mutually exclusive with acquisition (external server doesn't need local CLI)"); + } + // Validate auth options with external server if (options.cliUrl && (options.githubToken || options.useLoggedInUser !== undefined)) { throw new Error( @@ -181,6 +191,7 @@ export class CopilotClient { githubToken: options.githubToken, // Default useLoggedInUser to false when githubToken is provided, otherwise true useLoggedInUser: options.useLoggedInUser ?? (options.githubToken ? false : true), + acquisition: options.acquisition, }; } @@ -241,6 +252,12 @@ export class CopilotClient { this.state = "connecting"; try { + // Run CLI acquisition if configured + if (this.options.acquisition) { + const cliPath = await acquireCli(this.options.acquisition); + this.options.cliPath = cliPath; + } + // Only start CLI server process if not connecting to external server if (!this.isExternalServer) { await this.startCLIServer(); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 4f9fcbf6..cf561238 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -11,6 +11,7 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; export { defineTool } from "./types.js"; +export type { AcquisitionOptions, AcquisitionProgress } from "./acquisition.js"; export type { ConnectionState, CopilotClientOptions, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e3b18ffe..6f76f87c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -10,9 +10,8 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; export type SessionEvent = GeneratedSessionEvent; -/** - * Options for creating a CopilotClient - */ +import type { AcquisitionOptions } from "./acquisition.js"; + export interface CopilotClientOptions { /** * Path to the Copilot CLI executable @@ -89,6 +88,13 @@ export interface CopilotClientOptions { * @default true (but defaults to false when githubToken is provided) */ useLoggedInUser?: boolean; + + /** + * Acquisition options for automatically downloading the Copilot CLI. + * When provided, the SDK will manage CLI downloads to the specified directory. + * Mutually exclusive with cliPath and cliUrl. + */ + acquisition?: AcquisitionOptions; } /** diff --git a/nodejs/test/acquisition.test.ts b/nodejs/test/acquisition.test.ts new file mode 100644 index 00000000..def95d3b --- /dev/null +++ b/nodejs/test/acquisition.test.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import { acquireCli, PREFERRED_CLI_VERSION } from "../src/acquisition.js"; + +describe("acquisition", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `copilot-acquisition-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("validation", () => { + it("should reject ~/.copilot as downloadDir", async () => { + const copilotDir = join(homedir(), ".copilot"); + + await expect( + acquireCli({ + downloadDir: copilotDir, + }) + ).rejects.toThrow("reserved for the global Copilot CLI installation"); + }); + + it("should reject invalid minVersion format", async () => { + await expect( + acquireCli({ + downloadDir: testDir, + minVersion: "invalid", + }) + ).rejects.toThrow("Invalid minVersion format"); + }); + + it("should accept valid semver minVersion", async () => { + // This will fail because there's no downloaded CLI, but it should pass validation + try { + await acquireCli({ + downloadDir: testDir, + minVersion: "0.0.400", + }); + } catch (e) { + // Expected to fail during download since we don't have network + expect((e as Error).message).not.toContain("Invalid minVersion format"); + } + }); + }); + + describe("version selection", () => { + it("should export PREFERRED_CLI_VERSION", () => { + expect(PREFERRED_CLI_VERSION).toBeDefined(); + expect(typeof PREFERRED_CLI_VERSION).toBe("string"); + expect(PREFERRED_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + }); + + describe("progress reporting", () => { + it("should call onProgress callback during download", async () => { + const progressUpdates: { bytesDownloaded: number; totalBytes: number }[] = []; + + try { + await acquireCli({ + downloadDir: testDir, + onProgress: (progress) => { + progressUpdates.push({ ...progress }); + }, + }); + } catch { + // Download will fail but we should have progress updates + } + + // Note: We can't guarantee progress updates if network fails immediately + // So we just verify the callback mechanism works + }); + }); +}); diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 364ff382..1fa90b65 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -215,4 +215,35 @@ describe("CopilotClient", () => { }).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/); }); }); + + describe("Acquisition options", () => { + it("should throw error when acquisition is used with cliPath", () => { + expect(() => { + new CopilotClient({ + cliPath: "/path/to/cli", + acquisition: { downloadDir: "/tmp/cli" }, + logLevel: "error", + }); + }).toThrow(/cliPath is mutually exclusive with acquisition/); + }); + + it("should throw error when acquisition is used with cliUrl", () => { + expect(() => { + new CopilotClient({ + cliUrl: "localhost:8080", + acquisition: { downloadDir: "/tmp/cli" }, + logLevel: "error", + }); + }).toThrow(/cliUrl is mutually exclusive with acquisition/); + }); + + it("should accept acquisition option alone", () => { + const client = new CopilotClient({ + acquisition: { downloadDir: "/tmp/cli" }, + logLevel: "error", + }); + + expect((client as any).options.acquisition).toEqual({ downloadDir: "/tmp/cli" }); + }); + }); }); diff --git a/nodejs/test/e2e/acquisition.test.ts b/nodejs/test/e2e/acquisition.test.ts new file mode 100644 index 00000000..c5b1fac8 --- /dev/null +++ b/nodejs/test/e2e/acquisition.test.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync, existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import { CopilotClient } from "../../src/index.js"; +import { acquireCli, PREFERRED_CLI_VERSION } from "../../src/acquisition.js"; + +/** + * E2E tests for CLI acquisition. + * + * These tests require network access to download from GitHub Releases. + * They are skipped in CI environments without network access. + */ +describe("CLI Acquisition E2E", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `copilot-acquisition-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it("should download CLI and start client successfully", async () => { + const progressUpdates: { bytesDownloaded: number; totalBytes: number }[] = []; + + const client = new CopilotClient({ + acquisition: { + downloadDir: testDir, + onProgress: (progress) => { + progressUpdates.push({ ...progress }); + }, + }, + autoStart: false, + logLevel: "error", + }); + + try { + await client.start(); + + // Verify client is connected + expect(client.getState()).toBe("connected"); + + // Verify CLI was downloaded + const entries = readdirSync(testDir); + expect(entries.some(e => e.startsWith("copilot-"))).toBe(true); + + // Verify progress was reported (at least some bytes downloaded) + expect(progressUpdates.length).toBeGreaterThan(0); + expect(progressUpdates[progressUpdates.length - 1].bytesDownloaded).toBeGreaterThan(0); + + // Verify ping works + const pong = await client.ping("test"); + expect(pong.message).toBe("pong: test"); + } finally { + await client.forceStop(); + } + }, 120000); // 2 minute timeout for download + + it("should reuse existing compatible CLI without downloading", async () => { + // First, download the CLI + const cliPath = await acquireCli({ + downloadDir: testDir, + }); + + expect(existsSync(cliPath)).toBe(true); + + // Now create a client - should reuse without downloading + let downloadStarted = false; + const client = new CopilotClient({ + acquisition: { + downloadDir: testDir, + onProgress: () => { + downloadStarted = true; + }, + }, + autoStart: false, + logLevel: "error", + }); + + try { + await client.start(); + expect(client.getState()).toBe("connected"); + // No download should have occurred + expect(downloadStarted).toBe(false); + } finally { + await client.forceStop(); + } + }, 120000); + + it("should reject ~/.copilot as downloadDir", async () => { + const copilotDir = join(homedir(), ".copilot"); + + const client = new CopilotClient({ + acquisition: { + downloadDir: copilotDir, + }, + autoStart: false, + logLevel: "error", + }); + + await expect(client.start()).rejects.toThrow("reserved for the global Copilot CLI installation"); + }); + + it("should clean up old versions after acquisition", async () => { + // Create a fake old version directory + const oldVersionDir = join(testDir, "copilot-0.0.1"); + mkdirSync(oldVersionDir, { recursive: true }); + + // Download the current version + await acquireCli({ + downloadDir: testDir, + }); + + // Old version should be cleaned up + expect(existsSync(oldVersionDir)).toBe(false); + + // Current version should exist + const entries = readdirSync(testDir); + expect(entries.length).toBe(1); + expect(entries[0]).toBe(`copilot-${PREFERRED_CLI_VERSION}`); + }, 120000); +}); From 00fe72e3fa1b91422fef7c409dc06253d774e0ec Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 14:40:02 +0000 Subject: [PATCH 02/10] Simplify extraction --- nodejs/examples/basic-example.ts | 5 ++- nodejs/package-lock.json | 64 -------------------------------- nodejs/package.json | 1 - nodejs/src/acquisition.ts | 18 +++------ 4 files changed, 8 insertions(+), 80 deletions(-) diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index ad22f5cf..096b4f84 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -2,9 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { z } from "zod"; -import { join } from "node:path"; import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; import { CopilotClient, defineTool } from "../src/index.js"; console.log("🚀 Starting Copilot SDK Example\n"); @@ -27,6 +27,7 @@ const client = new CopilotClient({ logLevel: "info", acquisition: { downloadDir: join(homedir(), ".copilot-sdk-example", "cli"), + minVersion: "0.0.400", onProgress: ({ bytesDownloaded, totalBytes }) => { if (totalBytes > 0) { const pct = Math.round((bytesDownloaded / totalBytes) * 100); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3ef7d94e..b79f08ea 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -27,7 +27,6 @@ "prettier": "^3.4.0", "quicktype-core": "^23.2.6", "rimraf": "^6.1.2", - "tar": "^7.5.7", "tsx": "^4.20.6", "typescript": "^5.0.0", "vitest": "^4.0.18" @@ -876,19 +875,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1906,16 +1892,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/collection-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collection-utils/-/collection-utils-1.0.1.tgz", @@ -2817,19 +2793,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3504,23 +3467,6 @@ "node": ">=8" } }, - "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -4020,16 +3966,6 @@ "node": ">=8" } }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 0426d1c6..47a3e822 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -59,7 +59,6 @@ "prettier": "^3.4.0", "quicktype-core": "^23.2.6", "rimraf": "^6.1.2", - "tar": "^7.5.7", "tsx": "^4.20.6", "typescript": "^5.0.0", "vitest": "^4.0.18" diff --git a/nodejs/src/acquisition.ts b/nodejs/src/acquisition.ts index cd332c1b..5c25fb3f 100644 --- a/nodejs/src/acquisition.ts +++ b/nodejs/src/acquisition.ts @@ -7,7 +7,6 @@ import { rename, rm } from "node:fs/promises"; import { homedir, platform, arch, tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { spawn } from "node:child_process"; -import { extract } from "tar"; import * as semver from "semver"; import { CopilotClient } from "./client.js"; import { PREFERRED_CLI_VERSION } from "./generated/versions.js"; @@ -212,11 +211,7 @@ async function downloadCli( const extractDir = join(tempDir, "extracted"); mkdirSync(extractDir, { recursive: true }); - if (ext === "zip") { - await extractZip(archivePath, extractDir); - } else { - await extract({ file: archivePath, cwd: extractDir }); - } + await extractArchive(archivePath, extractDir); const execName = platform() === "win32" ? "copilot.exe" : "copilot"; const extractedExec = findExecutable(extractDir, execName); @@ -251,18 +246,15 @@ async function downloadCli( } } -async function extractZip(zipPath: string, destDir: string): Promise { +// tar is built-in on Windows 10+, macOS, and Linux +async function extractArchive(archivePath: string, destDir: string): Promise { return new Promise((resolve, reject) => { - const proc = spawn("powershell", [ - "-NoProfile", - "-Command", - `Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force`, - ]); + const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], { stdio: "ignore" }); proc.on("error", reject); proc.on("exit", (code) => { if (code === 0) resolve(); - else reject(new Error(`Expand-Archive failed with code ${code}`)); + else reject(new Error(`Archive extraction failed for ${archivePath} (exit code ${code})`)); }); }); } From 092ab218492b64c92357ad824088b37815e55bf7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 14:58:06 +0000 Subject: [PATCH 03/10] Fix generated code location --- nodejs/.gitignore | 2 +- nodejs/scripts/generate-versions.ts | 8 ++++---- nodejs/src/acquisition.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/nodejs/.gitignore b/nodejs/.gitignore index 3fa3650e..3388c6d1 100644 --- a/nodejs/.gitignore +++ b/nodejs/.gitignore @@ -134,7 +134,7 @@ dist/ build/ # Generated version constants (regenerated at build time) -src/generated/ +src/generatedOnBuild/ # macOS .DS_Store diff --git a/nodejs/scripts/generate-versions.ts b/nodejs/scripts/generate-versions.ts index 90e80ce2..512891d0 100644 --- a/nodejs/scripts/generate-versions.ts +++ b/nodejs/scripts/generate-versions.ts @@ -1,4 +1,4 @@ -// Generates src/generated/versions.ts from package-lock.json @github/copilot version. +// Generates src/generatedOnBuild/versions.ts from package-lock.json @github/copilot version. // Run automatically via prebuild hook. import fs from "node:fs"; @@ -13,6 +13,6 @@ const code = `// Generated by scripts/generate-versions.ts - DO NOT EDIT export const PREFERRED_CLI_VERSION = "${cliVersion}"; `; -fs.mkdirSync(path.join(__dirname, "..", "src", "generated"), { recursive: true }); -fs.writeFileSync(path.join(__dirname, "..", "src", "generated", "versions.ts"), code); -console.log(`Generated src/generated/versions.ts with PREFERRED_CLI_VERSION="${cliVersion}"`); +fs.mkdirSync(path.join(__dirname, "..", "src", "generatedOnBuild"), { recursive: true }); +fs.writeFileSync(path.join(__dirname, "..", "src", "generatedOnBuild", "versions.ts"), code); +console.log(`Generated src/generatedOnBuild/versions.ts with PREFERRED_CLI_VERSION="${cliVersion}"`); diff --git a/nodejs/src/acquisition.ts b/nodejs/src/acquisition.ts index 5c25fb3f..df4506d9 100644 --- a/nodejs/src/acquisition.ts +++ b/nodejs/src/acquisition.ts @@ -9,7 +9,7 @@ import { join, dirname, resolve } from "node:path"; import { spawn } from "node:child_process"; import * as semver from "semver"; import { CopilotClient } from "./client.js"; -import { PREFERRED_CLI_VERSION } from "./generated/versions.js"; +import { PREFERRED_CLI_VERSION } from "./generatedOnBuild/versions.js"; export { PREFERRED_CLI_VERSION }; From 78712c941e271791e8740adbea04a6d9589497fd Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 15:01:56 +0000 Subject: [PATCH 04/10] More feedback --- nodejs/README.md | 5 ++++- nodejs/package.json | 2 +- nodejs/test/e2e/acquisition.test.ts | 6 ------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/nodejs/README.md b/nodejs/README.md index 5ec20b48..f18499dd 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -655,9 +655,12 @@ try { The SDK can automatically download and manage the Copilot CLI for you. This is useful when you can't rely on the CLI being pre-installed. ```typescript +import { homedir } from "node:os"; +import { join } from "node:path"; + const client = new CopilotClient({ acquisition: { - downloadDir: "~/.myapp/copilot-cli", // Where to store CLI versions + downloadDir: join(homedir(), ".myapp", "copilot-cli"), // Where to store CLI versions }, }); diff --git a/nodejs/package.json b/nodejs/package.json index 47a3e822..ab9ab52b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -16,7 +16,7 @@ }, "type": "module", "scripts": { - "clean": "rimraf --glob dist *.tgz src/generated", + "clean": "rimraf --glob dist *.tgz src/generatedOnBuild", "prebuild": "tsx scripts/generate-versions.ts", "build": "tsx esbuild-copilotsdk-nodejs.ts", "test": "vitest run", diff --git a/nodejs/test/e2e/acquisition.test.ts b/nodejs/test/e2e/acquisition.test.ts index c5b1fac8..dfef6401 100644 --- a/nodejs/test/e2e/acquisition.test.ts +++ b/nodejs/test/e2e/acquisition.test.ts @@ -9,12 +9,6 @@ import { tmpdir, homedir } from "node:os"; import { CopilotClient } from "../../src/index.js"; import { acquireCli, PREFERRED_CLI_VERSION } from "../../src/acquisition.js"; -/** - * E2E tests for CLI acquisition. - * - * These tests require network access to download from GitHub Releases. - * They are skipped in CI environments without network access. - */ describe("CLI Acquisition E2E", () => { let testDir: string; From 9fe41d32f414d1e2eaa503b81f28adbdbb861929 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 15:02:30 +0000 Subject: [PATCH 05/10] Fix formatting --- nodejs/src/acquisition.ts | 24 ++++++++++++++++++++---- nodejs/src/client.ts | 8 ++++++-- nodejs/test/acquisition.test.ts | 5 ++++- nodejs/test/e2e/acquisition.test.ts | 11 ++++++++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/nodejs/src/acquisition.ts b/nodejs/src/acquisition.ts index df4506d9..10ba6f70 100644 --- a/nodejs/src/acquisition.ts +++ b/nodejs/src/acquisition.ts @@ -2,7 +2,15 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, chmodSync, copyFileSync } from "node:fs"; +import { + createWriteStream, + existsSync, + mkdirSync, + readdirSync, + rmSync, + chmodSync, + copyFileSync, +} from "node:fs"; import { rename, rm } from "node:fs/promises"; import { homedir, platform, arch, tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; @@ -164,7 +172,10 @@ async function downloadCli( ): Promise { const url = getDownloadUrl(version); const versionDir = join(downloadDir, `copilot-${version}`); - const tempDir = join(tmpdir(), `copilot-download-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const tempDir = join( + tmpdir(), + `copilot-download-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); mkdirSync(downloadDir, { recursive: true }); mkdirSync(tempDir, { recursive: true }); @@ -173,7 +184,9 @@ async function downloadCli( const response = await fetch(url, { redirect: "follow" }); if (!response.ok) { - throw new Error(`Failed to download CLI v${version}: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to download CLI v${version}: ${response.status} ${response.statusText}` + ); } const contentLength = parseInt(response.headers.get("content-length") || "0", 10); @@ -254,7 +267,10 @@ async function extractArchive(archivePath: string, destDir: string): Promise { if (code === 0) resolve(); - else reject(new Error(`Archive extraction failed for ${archivePath} (exit code ${code})`)); + else + reject( + new Error(`Archive extraction failed for ${archivePath} (exit code ${code})`) + ); }); }); } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 8fdb0842..7622dbd4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -155,11 +155,15 @@ export class CopilotClient { } if (options.cliPath && options.acquisition) { - throw new Error("cliPath is mutually exclusive with acquisition (acquisition auto-determines the CLI path)"); + throw new Error( + "cliPath is mutually exclusive with acquisition (acquisition auto-determines the CLI path)" + ); } if (options.cliUrl && options.acquisition) { - throw new Error("cliUrl is mutually exclusive with acquisition (external server doesn't need local CLI)"); + throw new Error( + "cliUrl is mutually exclusive with acquisition (external server doesn't need local CLI)" + ); } // Validate auth options with external server diff --git a/nodejs/test/acquisition.test.ts b/nodejs/test/acquisition.test.ts index def95d3b..fcfefb35 100644 --- a/nodejs/test/acquisition.test.ts +++ b/nodejs/test/acquisition.test.ts @@ -12,7 +12,10 @@ describe("acquisition", () => { let testDir: string; beforeEach(() => { - testDir = join(tmpdir(), `copilot-acquisition-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + testDir = join( + tmpdir(), + `copilot-acquisition-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); mkdirSync(testDir, { recursive: true }); }); diff --git a/nodejs/test/e2e/acquisition.test.ts b/nodejs/test/e2e/acquisition.test.ts index dfef6401..900c705c 100644 --- a/nodejs/test/e2e/acquisition.test.ts +++ b/nodejs/test/e2e/acquisition.test.ts @@ -13,7 +13,10 @@ describe("CLI Acquisition E2E", () => { let testDir: string; beforeEach(() => { - testDir = join(tmpdir(), `copilot-acquisition-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); + testDir = join( + tmpdir(), + `copilot-acquisition-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); mkdirSync(testDir, { recursive: true }); }); @@ -47,7 +50,7 @@ describe("CLI Acquisition E2E", () => { // Verify CLI was downloaded const entries = readdirSync(testDir); - expect(entries.some(e => e.startsWith("copilot-"))).toBe(true); + expect(entries.some((e) => e.startsWith("copilot-"))).toBe(true); // Verify progress was reported (at least some bytes downloaded) expect(progressUpdates.length).toBeGreaterThan(0); @@ -103,7 +106,9 @@ describe("CLI Acquisition E2E", () => { logLevel: "error", }); - await expect(client.start()).rejects.toThrow("reserved for the global Copilot CLI installation"); + await expect(client.start()).rejects.toThrow( + "reserved for the global Copilot CLI installation" + ); }); it("should clean up old versions after acquisition", async () => { From e8376063c3e98d9f75cd11b65e10ce239fd75049 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 15:05:35 +0000 Subject: [PATCH 06/10] Fix CI --- .github/workflows/nodejs-sdk-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 9eded8d6..1975d95a 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -59,7 +59,7 @@ jobs: run: npm run lint - name: Typecheck SDK - run: npm run typecheck + run: npm run build && npm run typecheck - name: Install test harness dependencies working-directory: ./test/harness From b9486f79a7befc68fc30012eab467400dcd1eb84 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 15:15:27 +0000 Subject: [PATCH 07/10] Fix zip extraction --- nodejs/src/acquisition.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/nodejs/src/acquisition.ts b/nodejs/src/acquisition.ts index 10ba6f70..a3cf68ae 100644 --- a/nodejs/src/acquisition.ts +++ b/nodejs/src/acquisition.ts @@ -215,7 +215,7 @@ async function downloadCli( } await new Promise((resolve, reject) => { - writeStream.end((err: Error | null | undefined) => { + writeStream.close((err: Error | null | undefined) => { if (err) reject(err); else resolve(); }); @@ -259,18 +259,27 @@ async function downloadCli( } } -// tar is built-in on Windows 10+, macOS, and Linux +// tar is built-in on Windows 10+, macOS, and Linux and handles .zip and .tar.gz async function extractArchive(archivePath: string, destDir: string): Promise { return new Promise((resolve, reject) => { - const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], { stdio: "ignore" }); + const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], { stdio: "pipe" }); + + let stderr = ""; + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); proc.on("error", reject); proc.on("exit", (code) => { if (code === 0) resolve(); - else + else { + const msg = stderr ? `: ${stderr.trim()}` : ""; reject( - new Error(`Archive extraction failed for ${archivePath} (exit code ${code})`) + new Error( + `Archive extraction failed for ${archivePath} (exit code ${code})${msg}` + ) ); + } }); }); } From f69d2961809ce84987d53f2544422c006eb829d2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 15:21:32 +0000 Subject: [PATCH 08/10] Use Expand-Archive on Windows to avoid incompatibility based on which type of `tar` you have on path (windows or bash/wsl) --- nodejs/src/acquisition.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/nodejs/src/acquisition.ts b/nodejs/src/acquisition.ts index a3cf68ae..e0ae7c89 100644 --- a/nodejs/src/acquisition.ts +++ b/nodejs/src/acquisition.ts @@ -259,10 +259,22 @@ async function downloadCli( } } -// tar is built-in on Windows 10+, macOS, and Linux and handles .zip and .tar.gz +// tar is built-in on macOS and Linux. Windows uses PowerShell Expand-Archive for .zip files. async function extractArchive(archivePath: string, destDir: string): Promise { return new Promise((resolve, reject) => { - const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], { stdio: "pipe" }); + const isWindows = platform() === "win32"; + + const proc = isWindows + ? spawn( + "powershell", + [ + "-NoProfile", + "-Command", + `$ProgressPreference='SilentlyContinue'; Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`, + ], + { stdio: "pipe" } + ) + : spawn("tar", ["-xf", archivePath, "-C", destDir], { stdio: "pipe" }); let stderr = ""; proc.stderr?.on("data", (data) => { From 97b15357376dc271f273d4bcfda358bf8bab281a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 15:22:30 +0000 Subject: [PATCH 09/10] Simplify example --- nodejs/examples/basic-example.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index 096b4f84..4425c254 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -27,7 +27,6 @@ const client = new CopilotClient({ logLevel: "info", acquisition: { downloadDir: join(homedir(), ".copilot-sdk-example", "cli"), - minVersion: "0.0.400", onProgress: ({ bytesDownloaded, totalBytes }) => { if (totalBytes > 0) { const pct = Math.round((bytesDownloaded / totalBytes) * 100); From 92b32097d9d20cebe9b47344e3b1cd0201b9c977 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Feb 2026 15:27:40 +0000 Subject: [PATCH 10/10] Fix comment --- nodejs/src/acquisition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs/src/acquisition.ts b/nodejs/src/acquisition.ts index e0ae7c89..4d9f7a81 100644 --- a/nodejs/src/acquisition.ts +++ b/nodejs/src/acquisition.ts @@ -24,7 +24,7 @@ export { PREFERRED_CLI_VERSION }; export interface AcquisitionOptions { /** * Directory where CLI versions will be downloaded and stored. - * Should be app-specific (e.g., `~/.myapp/copilot-cli/`). + * Should be app-specific, e.g., `path.join(os.homedir(), '.myapp', 'copilot-cli')`. */ downloadDir: string;