diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 9e978a22f..9dec01667 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -62,6 +62,9 @@ jobs: - name: Typecheck SDK run: npm run typecheck + - name: Build SDK + run: npm run build + - name: Install test harness dependencies working-directory: ./test/harness run: npm ci --ignore-scripts diff --git a/nodejs/README.md b/nodejs/README.md index e9d23c529..6a9059e20 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -850,6 +850,26 @@ try { - Node.js >= 18.0.0 - GitHub Copilot CLI installed and in PATH (or provide custom `cliPath`) +### CJS / CommonJS Support + +The SDK ships both ESM and CJS builds. Node.js and bundlers (esbuild, webpack, etc.) automatically select the correct format via the `exports` field in `package.json`: + +- `import` / `from` → ESM (`dist/index.js`) +- `require()` → CJS (`dist/cjs/index.cjs`) + +This means the SDK works out of the box in CJS environments such as VS Code extensions bundled with `esbuild format:"cjs"`. + +### System-installed CLI (winget, brew, apt) + +If you installed the Copilot CLI separately rather than relying on the SDK's bundled copy, pass `cliPath` explicitly: + +```typescript +const client = new CopilotClient({ + cliPath: '/usr/local/bin/copilot', // macOS/Linux + // cliPath: 'C:\\path\\to\\copilot.exe', // Windows (winget, etc.) +}); +``` + ## License MIT diff --git a/nodejs/esbuild-copilotsdk-nodejs.ts b/nodejs/esbuild-copilotsdk-nodejs.ts index 059b8cfa6..f65a47236 100644 --- a/nodejs/esbuild-copilotsdk-nodejs.ts +++ b/nodejs/esbuild-copilotsdk-nodejs.ts @@ -4,6 +4,7 @@ import { execSync } from "child_process"; const entryPoints = globSync("src/**/*.ts"); +// ESM build await esbuild.build({ entryPoints, outbase: "src", @@ -15,5 +16,22 @@ await esbuild.build({ outExtension: { ".js": ".js" }, }); +// CJS build — uses .js extension with a "type":"commonjs" package.json marker +await esbuild.build({ + entryPoints, + outbase: "src", + outdir: "dist/cjs", + format: "cjs", + platform: "node", + target: "es2022", + sourcemap: false, + outExtension: { ".js": ".js" }, + logOverride: { "empty-import-meta": "silent" }, +}); + +// Mark the CJS directory so Node treats .js files as CommonJS +import { writeFileSync } from "fs"; +writeFileSync("dist/cjs/package.json", JSON.stringify({ type: "commonjs" }) + "\n"); + // Generate .d.ts files execSync("tsc", { stdio: "inherit" }); diff --git a/nodejs/package.json b/nodejs/package.json index 214ef3466..6b0d30f2c 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -6,16 +6,28 @@ }, "version": "0.1.8", "description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC", - "main": "./dist/index.js", + "main": "./dist/cjs/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/cjs/index.js" + } }, "./extension": { - "import": "./dist/extension.js", - "types": "./dist/extension.d.ts" + "import": { + "types": "./dist/extension.d.ts", + "default": "./dist/extension.js" + }, + "require": { + "types": "./dist/extension.d.ts", + "default": "./dist/cjs/extension.js" + } } }, "type": "module", diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index b8e7b31dc..46d932242 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -14,6 +14,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import { Socket } from "node:net"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -91,14 +92,35 @@ function getNodeExecPath(): string { /** * Gets the path to the bundled CLI from the @github/copilot package. * Uses index.js directly rather than npm-loader.js (which spawns the native binary). + * + * In ESM, uses import.meta.resolve directly. In CJS (e.g., VS Code extensions + * bundled with esbuild format:"cjs"), import.meta is empty so we fall back to + * walking node_modules to find the package. */ function getBundledCliPath(): string { - // Find the actual location of the @github/copilot package by resolving its sdk export - const sdkUrl = import.meta.resolve("@github/copilot/sdk"); - const sdkPath = fileURLToPath(sdkUrl); - // sdkPath is like .../node_modules/@github/copilot/sdk/index.js - // Go up two levels to get the package root, then append index.js - return join(dirname(dirname(sdkPath)), "index.js"); + if (typeof import.meta.resolve === "function") { + // ESM: resolve via import.meta.resolve + const sdkUrl = import.meta.resolve("@github/copilot/sdk"); + const sdkPath = fileURLToPath(sdkUrl); + // sdkPath is like .../node_modules/@github/copilot/sdk/index.js + // Go up two levels to get the package root, then append index.js + return join(dirname(dirname(sdkPath)), "index.js"); + } + + // CJS fallback: the @github/copilot package has ESM-only exports so + // require.resolve cannot reach it. Walk the module search paths instead. + const req = createRequire(__filename); + const searchPaths = req.resolve.paths("@github/copilot") ?? []; + for (const base of searchPaths) { + const candidate = join(base, "@github", "copilot", "index.js"); + if (existsSync(candidate)) { + return candidate; + } + } + throw new Error( + `Could not find @github/copilot package. Searched ${searchPaths.length} paths. ` + + `Ensure it is installed, or pass cliPath/cliUrl to CopilotClient.` + ); } /** diff --git a/nodejs/test/cjs-compat.test.ts b/nodejs/test/cjs-compat.test.ts new file mode 100644 index 000000000..f57403725 --- /dev/null +++ b/nodejs/test/cjs-compat.test.ts @@ -0,0 +1,72 @@ +/** + * Dual ESM/CJS build compatibility tests + * + * Verifies that both the ESM and CJS builds exist and work correctly, + * so consumers using either module system get a working package. + * + * See: https://github.com/github/copilot-sdk/issues/528 + */ + +import { describe, expect, it } from "vitest"; +import { existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +const distDir = join(import.meta.dirname, "../dist"); + +describe("Dual ESM/CJS build (#528)", () => { + it("ESM dist file should exist", () => { + expect(existsSync(join(distDir, "index.js"))).toBe(true); + }); + + it("CJS dist file should exist", () => { + expect(existsSync(join(distDir, "cjs/index.js"))).toBe(true); + }); + + it("CJS build is requireable and exports CopilotClient", () => { + const script = ` + const sdk = require(${JSON.stringify(join(distDir, "cjs/index.js"))}); + if (typeof sdk.CopilotClient !== 'function') { + console.error('CopilotClient is not a function'); + process.exit(1); + } + console.log('CJS require: OK'); + `; + const output = execFileSync(process.execPath, ["--eval", script], { + encoding: "utf-8", + timeout: 10000, + cwd: join(import.meta.dirname, ".."), + }); + expect(output).toContain("CJS require: OK"); + }); + + it("CJS build resolves bundled CLI path", () => { + const script = ` + const sdk = require(${JSON.stringify(join(distDir, "cjs/index.js"))}); + const client = new sdk.CopilotClient({ autoStart: false }); + console.log('CJS CLI resolved: OK'); + `; + const output = execFileSync(process.execPath, ["--eval", script], { + encoding: "utf-8", + timeout: 10000, + cwd: join(import.meta.dirname, ".."), + }); + expect(output).toContain("CJS CLI resolved: OK"); + }); + + it("ESM build resolves bundled CLI path", () => { + const esmPath = join(distDir, "index.js"); + const script = ` + import { pathToFileURL } from 'node:url'; + const sdk = await import(pathToFileURL(${JSON.stringify(esmPath)}).href); + const client = new sdk.CopilotClient({ autoStart: false }); + console.log('ESM CLI resolved: OK'); + `; + const output = execFileSync(process.execPath, ["--input-type=module", "--eval", script], { + encoding: "utf-8", + timeout: 10000, + cwd: join(import.meta.dirname, ".."), + }); + expect(output).toContain("ESM CLI resolved: OK"); + }); +});