diff --git a/src/cli/commands/connect.test.ts b/src/cli/commands/connect.test.ts new file mode 100644 index 000000000..0417b8446 --- /dev/null +++ b/src/cli/commands/connect.test.ts @@ -0,0 +1,203 @@ +import { Command } from 'commander'; +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { registerConnectCommands } from './connect.js'; +import { CliDetectError } from '../lib/detect-cli.js'; + +class ExitSignal extends Error { + constructor(public readonly code: number) { + super(`exit:${code}`); + } +} + +interface Harness { + program: Command; + logs: string[]; + errors: string[]; + exitCode: number | undefined; + connect: ReturnType; +} + +function createHarness(connectImpl?: (cli: string) => Promise<{ cli: string; version: string; binPath: string; manifestPath: string }>): Harness { + const logs: string[] = []; + const errors: string[] = []; + let exitCode: number | undefined; + const exit = (code: number): never => { + exitCode = code; + throw new ExitSignal(code); + }; + const connect = vi.fn( + connectImpl ?? + (async (cli: string) => ({ + cli, + version: '1.2.3', + binPath: `/usr/local/bin/${cli}`, + manifestPath: '/tmp/agent-relay/connections.json', + })), + ); + + const program = new Command(); + program.exitOverride(); + registerConnectCommands(program, { + connect: connect as any, + log: (msg) => logs.push(msg), + error: (msg) => errors.push(msg), + exit, + }); + return { + program, + logs, + errors, + get exitCode() { + return exitCode; + }, + connect, + } as Harness; +} + +async function run(program: Command, args: string[]): Promise { + try { + await program.parseAsync(args, { from: 'user' }); + return undefined; + } catch (err) { + if (err instanceof ExitSignal) { + return err.code; + } + throw err; + } +} + +describe('registerConnectCommands', () => { + it('runs the happy path for claude', async () => { + const h = createHarness(); + const code = await run(h.program, ['connect', 'claude']); + expect(code).toBeUndefined(); + expect(h.connect).toHaveBeenCalledWith('claude', undefined); + expect(h.logs.join('\n')).toContain('Connected claude 1.2.3'); + expect(h.logs.join('\n')).toContain('Manifest:'); + }); + + it('runs the happy path for codex', async () => { + const h = createHarness(); + await run(h.program, ['connect', 'codex']); + expect(h.connect).toHaveBeenCalledWith('codex', undefined); + }); + + it('runs the happy path for gemini', async () => { + const h = createHarness(); + await run(h.program, ['connect', 'gemini']); + expect(h.connect).toHaveBeenCalledWith('gemini', undefined); + }); + + it('prints the deprecation banner for unknown providers and exits 1', async () => { + const h = createHarness(); + const code = await run(h.program, ['connect', 'anthropic']); + expect(code).toBe(1); + expect(h.errors.join('\n')).toContain('[DEPRECATED]'); + expect(h.errors.join('\n')).toContain('agent-relay cloud connect anthropic'); + expect(h.connect).not.toHaveBeenCalled(); + }); + + it('still accepts legacy cloud-connect options before printing the deprecation banner', async () => { + const h = createHarness(); + const code = await run(h.program, [ + 'connect', + 'anthropic', + '--timeout', + '300', + '--language', + 'typescript', + '--cloud-url', + 'https://cloud.example.test', + ]); + expect(code).toBe(1); + expect(h.errors.join('\n')).toContain('agent-relay cloud connect anthropic'); + expect(h.connect).not.toHaveBeenCalled(); + }); + + it('accepts arbitrary legacy unknown options without crashing before the deprecation banner', async () => { + // commander rejects unknown options at parse time by default. Scripted + // callers may still pass forgotten legacy flags beyond --timeout / + // --language / --cloud-url; allowUnknownOption(true) lets the action + // run, then the unknown-cli check below fires the deprecation banner. + const h = createHarness(); + const code = await run(h.program, [ + 'connect', + 'anthropic', + '--region', + 'us-east-1', + '--really-old-flag', + ]); + expect(code).toBe(1); + expect(h.errors.join('\n')).toContain('[DEPRECATED]'); + expect(h.errors.join('\n')).toContain('agent-relay cloud connect anthropic'); + expect(h.connect).not.toHaveBeenCalled(); + }); + + it('surfaces NEEDS_CLI_INSTALL via stderr and exits 2', async () => { + const h = createHarness(async () => { + throw new CliDetectError( + 'NEEDS_CLI_INSTALL', + 2, + 'NEEDS_CLI_INSTALL: claude not found on PATH. Install: https://docs.anthropic.com/claude-code/install', + ); + }); + const code = await run(h.program, ['connect', 'claude']); + expect(code).toBe(2); + expect(h.errors.join('\n')).toContain('NEEDS_CLI_INSTALL'); + expect(h.errors.join('\n')).toContain('claude not found on PATH'); + expect(h.errors.join('\n')).toContain('https://docs.anthropic.com/claude-code/install'); + }); + + it('surfaces CLI_VERSION_FAILED via stderr and exits 3', async () => { + const h = createHarness(async () => { + throw new CliDetectError('CLI_VERSION_FAILED', 3, 'claude found but --version failed'); + }); + const code = await run(h.program, ['connect', 'claude']); + expect(code).toBe(3); + expect(h.errors.join('\n')).toContain('--version failed'); + }); + + it('exits 4 for unexpected errors', async () => { + const h = createHarness(async () => { + throw new Error('boom'); + }); + const code = await run(h.program, ['connect', 'claude']); + expect(code).toBe(4); + expect(h.errors.join('\n')).toContain('boom'); + }); + + it('end-to-end writes a manifest entry through the real connections-file helper', async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'connect-cmd-')); + try { + const { upsertConnectionsManifest } = await import('../lib/connections-file.js'); + const harness = createHarness(async (cli) => { + const { manifestPath } = await upsertConnectionsManifest( + { + cli: cli as 'claude', + binPath: `/usr/local/bin/${cli}`, + version: '9.9.9', + rawVersionOutput: `${cli} 9.9.9`, + connectedAt: '2026-05-16T00:00:00.000Z', + }, + { xdgConfigHome: tmp }, + ); + return { cli, version: '9.9.9', binPath: `/usr/local/bin/${cli}`, manifestPath }; + }); + await run(harness.program, ['connect', 'claude']); + const manifestPath = path.join(tmp, 'agent-relay', 'connections.json'); + const body = JSON.parse(await readFile(manifestPath, 'utf8')); + expect(body.clis.claude.version).toBe('9.9.9'); + if (process.platform !== 'win32') { + expect(statSync(manifestPath).mode & 0o777).toBe(0o600); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/commands/connect.ts b/src/cli/commands/connect.ts index 5dbca902a..6c15f8248 100644 --- a/src/cli/commands/connect.ts +++ b/src/cli/commands/connect.ts @@ -1,17 +1,75 @@ import { Command } from 'commander'; -export function registerConnectCommands(program: Command): void { +import { + CliDetectError, + SUPPORTED_CLIS, + type SupportedCli, + connectCli, + type DetectCliDeps, + type ConnectCliResult, +} from '../lib/detect-cli.js'; + +export interface ConnectCommandDeps { + connect?: (cli: SupportedCli, deps?: DetectCliDeps) => Promise; + log?: (message: string) => void; + error?: (message: string) => void; + exit?: (code: number) => never; + detectDeps?: DetectCliDeps; +} + +const DEPRECATION_BANNER = (providerArg: string): string => + '\x1b[33m[DEPRECATED]\x1b[0m `agent-relay connect ` has moved. Use:\n\n' + + ` agent-relay cloud connect ${providerArg}\n`; + +export function registerConnectCommands( + program: Command, + deps: ConnectCommandDeps = {}, +): void { + const connect = deps.connect ?? connectCli; + const log = deps.log ?? ((m: string) => process.stdout.write(`${m}\n`)); + const error = deps.error ?? ((m: string) => process.stderr.write(`${m}\n`)); + const exit = deps.exit ?? ((code: number) => process.exit(code) as never); + program - .command('connect ') - .description('[DEPRECATED] Use `agent-relay cloud connect ` instead') - .option('--timeout ', 'Timeout in seconds (default: 300)', '300') - .option('--language ', 'Sandbox language/image (default: typescript)', 'typescript') - .option('--cloud-url ', 'Cloud API URL') - .action(async (providerArg: string) => { - console.error( - '\x1b[33m[DEPRECATED]\x1b[0m `agent-relay connect` has moved. Use:\n\n' + - ` agent-relay cloud connect ${providerArg}\n` - ); - process.exit(1); + .command('connect ') + .description( + `Connect a local AI CLI (${SUPPORTED_CLIS.join(' | ')}). Detects on PATH, version-checks, and writes ~/.config/agent-relay/connections.json. ` + + 'Other provider arguments still print the legacy deprecation banner.', + ) + .option('--timeout ', 'Deprecated cloud connect timeout option') + .option('--language ', 'Deprecated cloud connect language/image option') + .option('--cloud-url ', 'Deprecated cloud connect API URL option') + // commander rejects unknown options before `.action` runs by default, so + // scripted callers passing any other legacy `agent-relay cloud connect` + // flag (beyond the three we explicitly handle above) would crash with + // "error: unknown option ..." instead of seeing the deprecation banner. + // Accept unknown options at parse time and surface them through the + // existing `` validation path so the banner can fire. + .allowUnknownOption(true) + .action(async (cliArg: string) => { + const normalized = cliArg.toLowerCase().trim(); + if (!isSupportedCli(normalized)) { + error(DEPRECATION_BANNER(cliArg)); + exit(1); + return; + } + try { + const result = await connect(normalized, deps.detectDeps); + log(`\x1b[32m✓\x1b[0m Connected ${result.cli} ${result.version} (${result.binPath})`); + log(` Manifest: ${result.manifestPath}`); + } catch (err) { + if (err instanceof CliDetectError) { + error(err.message); + exit(err.exitCode); + return; + } + const message = err instanceof Error ? err.message : String(err); + error(message); + exit(4); + } }); } + +function isSupportedCli(value: string): value is SupportedCli { + return (SUPPORTED_CLIS as readonly string[]).includes(value); +} diff --git a/src/cli/lib/connections-file.test.ts b/src/cli/lib/connections-file.test.ts new file mode 100644 index 000000000..19c38d749 --- /dev/null +++ b/src/cli/lib/connections-file.test.ts @@ -0,0 +1,253 @@ +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + CONNECTIONS_MANIFEST_VERSION, + connectionsFilePath, + readConnectionsManifest, + upsertConnectionsManifest, + xdgConfigHome, +} from './connections-file.js'; + +let tmpRoot: string; + +beforeEach(() => { + tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'connections-file-')); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe('xdgConfigHome / connectionsFilePath', () => { + it('prefers the explicit override', () => { + expect(xdgConfigHome({ xdgConfigHome: tmpRoot })).toBe(tmpRoot); + }); + + it('falls back to ~/.config when nothing is set', () => { + const home = path.join(tmpRoot, 'fake-home'); + const result = xdgConfigHome({ homeDir: home, xdgConfigHome: '' }); + expect(result).toBe(path.join(home, '.config')); + }); + + it('points at /agent-relay/connections.json', () => { + expect(connectionsFilePath({ xdgConfigHome: tmpRoot })).toBe( + path.join(tmpRoot, 'agent-relay', 'connections.json'), + ); + }); +}); + +describe('readConnectionsManifest', () => { + it('returns an empty manifest when the file is missing', async () => { + const result = await readConnectionsManifest({ + xdgConfigHome: tmpRoot, + now: () => '2026-05-16T00:00:00.000Z', + }); + expect(result).toEqual({ + version: CONNECTIONS_MANIFEST_VERSION, + updatedAt: '2026-05-16T00:00:00.000Z', + clis: {}, + }); + }); + + it('preserves an existing future-incompatible version as opaque', async () => { + await upsertConnectionsManifest( + { + cli: 'claude', + binPath: '/bin/claude', + version: '1.0.0', + rawVersionOutput: 'claude 1.0.0', + connectedAt: '2026-05-16T00:00:00.000Z', + }, + { xdgConfigHome: tmpRoot, now: () => '2026-05-16T00:00:00.000Z' }, + ); + // Now hand-write a manifest with version 999. + const fs = await import('node:fs/promises'); + const manifestPath = connectionsFilePath({ xdgConfigHome: tmpRoot }); + const existing = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + existing.version = 999; + await fs.writeFile(manifestPath, JSON.stringify(existing)); + + const result = await readConnectionsManifest({ xdgConfigHome: tmpRoot }); + expect(result.version).toBe(999); + expect(result.clis.claude?.binPath).toBe('/bin/claude'); + }); + + it('treats JSON parse failures as missing with a warning', async () => { + const fs = await import('node:fs/promises'); + const manifestPath = connectionsFilePath({ xdgConfigHome: tmpRoot }); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, 'NOT JSON'); + + const warnings: string[] = []; + const result = await readConnectionsManifest({ + xdgConfigHome: tmpRoot, + now: () => '2026-05-16T00:00:00.000Z', + warn: (msg) => warnings.push(msg), + }); + expect(result.clis).toEqual({}); + expect(warnings).toHaveLength(1); + }); +}); + +describe('upsertConnectionsManifest', () => { + it('creates the manifest with mode 0600 and parent dir mode 0700', async () => { + if (process.platform === 'win32') { + return; + } + const { manifestPath } = await upsertConnectionsManifest( + { + cli: 'claude', + binPath: '/usr/local/bin/claude', + version: '1.2.3', + rawVersionOutput: 'claude 1.2.3', + connectedAt: '2026-05-16T00:00:00.000Z', + }, + { xdgConfigHome: tmpRoot, now: () => '2026-05-16T00:00:00.000Z' }, + ); + const parentMode = statSync(path.dirname(manifestPath)).mode & 0o777; + const fileMode = statSync(manifestPath).mode & 0o777; + expect(parentMode).toBe(0o700); + expect(fileMode).toBe(0o600); + }); + + it('idempotently merges entries across multiple writes', async () => { + const opts = { xdgConfigHome: tmpRoot }; + await upsertConnectionsManifest( + { + cli: 'claude', + binPath: '/bin/claude', + version: '1.0.0', + rawVersionOutput: 'claude 1.0.0', + connectedAt: '2026-05-16T00:00:00.000Z', + }, + opts, + ); + await upsertConnectionsManifest( + { + cli: 'codex', + binPath: '/bin/codex', + version: '0.5.0', + rawVersionOutput: 'codex 0.5.0', + connectedAt: '2026-05-16T00:00:01.000Z', + }, + opts, + ); + const manifest = await readConnectionsManifest(opts); + expect(manifest.clis.claude).toBeDefined(); + expect(manifest.clis.codex).toBeDefined(); + expect(manifest.clis.claude?.version).toBe('1.0.0'); + expect(manifest.clis.codex?.version).toBe('0.5.0'); + }); + + it('overwrites the same cli entry on re-upsert', async () => { + const opts = { xdgConfigHome: tmpRoot }; + await upsertConnectionsManifest( + { + cli: 'claude', + binPath: '/bin/claude', + version: '1.0.0', + rawVersionOutput: 'claude 1.0.0', + connectedAt: '2026-05-16T00:00:00.000Z', + }, + opts, + ); + await upsertConnectionsManifest( + { + cli: 'claude', + binPath: '/opt/claude', + version: '1.1.0', + rawVersionOutput: 'claude 1.1.0', + connectedAt: '2026-05-17T00:00:00.000Z', + }, + opts, + ); + const manifest = await readConnectionsManifest(opts); + expect(manifest.clis.claude?.binPath).toBe('/opt/claude'); + expect(manifest.clis.claude?.version).toBe('1.1.0'); + }); + + it('writes pretty-printed JSON ending with a newline', async () => { + const { manifestPath } = await upsertConnectionsManifest( + { + cli: 'gemini', + binPath: '/bin/gemini', + version: '0.1.0', + rawVersionOutput: 'gemini 0.1.0', + connectedAt: '2026-05-16T00:00:00.000Z', + }, + { xdgConfigHome: tmpRoot, now: () => '2026-05-16T00:00:00.000Z' }, + ); + const body = await readFile(manifestPath, 'utf8'); + expect(body.endsWith('\n')).toBe(true); + expect(body).toContain(' "clis"'); + }); + + it('preserves a higher existing manifest version rather than downgrading it on upsert', async () => { + // Forward-compat: if a future agent-relay release has bumped the manifest + // version and written additional fields, an older binary that upserts here + // must not regress the version back to its own (smaller) value. + // readConnectionsManifest already preserves it on read; upsert must + // preserve it on write too. + const opts = { xdgConfigHome: tmpRoot, now: () => '2026-05-16T00:00:00.000Z' }; + await upsertConnectionsManifest( + { + cli: 'claude', + binPath: '/bin/claude', + version: '1.0.0', + rawVersionOutput: 'claude 1.0.0', + connectedAt: '2026-05-16T00:00:00.000Z', + }, + opts, + ); + // Hand-bump the on-disk version to a future value. + const fs = await import('node:fs/promises'); + const manifestPath = connectionsFilePath(opts); + const existing = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + existing.version = 999; + await fs.writeFile(manifestPath, JSON.stringify(existing)); + // Re-upsert with the same CLI; manifest.version must stay at 999. + const { manifest } = await upsertConnectionsManifest( + { + cli: 'codex', + binPath: '/bin/codex', + version: '0.5.0', + rawVersionOutput: 'codex 0.5.0', + connectedAt: '2026-05-16T00:00:01.000Z', + }, + opts, + ); + expect(manifest.version).toBe(999); + const onDisk = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + expect(onDisk.version).toBe(999); + }); + + it('refreshes updatedAt on each upsert', async () => { + const opts = (now: string) => ({ xdgConfigHome: tmpRoot, now: () => now }); + await upsertConnectionsManifest( + { + cli: 'claude', + binPath: '/bin/claude', + version: '1.0.0', + rawVersionOutput: 'claude 1.0.0', + connectedAt: '2026-05-16T00:00:00.000Z', + }, + opts('2026-05-16T00:00:00.000Z'), + ); + const { manifest } = await upsertConnectionsManifest( + { + cli: 'codex', + binPath: '/bin/codex', + version: '0.5.0', + rawVersionOutput: 'codex 0.5.0', + connectedAt: '2026-05-16T00:00:01.000Z', + }, + opts('2026-05-17T00:00:00.000Z'), + ); + expect(manifest.updatedAt).toBe('2026-05-17T00:00:00.000Z'); + }); +}); diff --git a/src/cli/lib/connections-file.ts b/src/cli/lib/connections-file.ts new file mode 100644 index 000000000..cf047cb74 --- /dev/null +++ b/src/cli/lib/connections-file.ts @@ -0,0 +1,192 @@ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { SupportedCli } from './detect-cli.js'; + +export const CONNECTIONS_MANIFEST_VERSION = 1; + +export interface CliEntry { + cli: SupportedCli; + binPath: string; + version: string; + rawVersionOutput: string; + connectedAt: string; +} + +export interface CliEntryRecord { + binPath: string; + version: string; + rawVersionOutput: string; + connectedAt: string; +} + +export interface ConnectionsManifest { + version: number; + updatedAt: string; + clis: Partial>; +} + +export interface ConnectionsFileDeps { + xdgConfigHome?: string; + homeDir?: string; + now?: () => string; + warn?: (message: string) => void; +} + +export function xdgConfigHome(deps: ConnectionsFileDeps = {}): string { + const fromArg = deps.xdgConfigHome; + if (fromArg && fromArg.length > 0) { + return fromArg; + } + const fromEnv = process.env.XDG_CONFIG_HOME; + if (fromEnv && fromEnv.length > 0) { + return fromEnv; + } + const home = deps.homeDir ?? os.homedir(); + return path.join(home, '.config'); +} + +export function connectionsFilePath(deps: ConnectionsFileDeps = {}): string { + return path.join(xdgConfigHome(deps), 'agent-relay', 'connections.json'); +} + +function emptyManifest(now: string): ConnectionsManifest { + return { version: CONNECTIONS_MANIFEST_VERSION, updatedAt: now, clis: {} }; +} + +function isErrnoException(err: unknown): err is NodeJS.ErrnoException { + return typeof err === 'object' && err !== null && 'code' in err; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function coerceClisField(value: unknown): ConnectionsManifest['clis'] { + if (!isPlainObject(value)) { + return {}; + } + const clis: ConnectionsManifest['clis'] = {}; + for (const [key, entry] of Object.entries(value)) { + if (key !== 'claude' && key !== 'codex' && key !== 'gemini') { + continue; + } + if (!isPlainObject(entry)) { + continue; + } + const binPath = typeof entry.binPath === 'string' ? entry.binPath : ''; + const version = typeof entry.version === 'string' ? entry.version : 'unknown'; + const rawVersionOutput = + typeof entry.rawVersionOutput === 'string' ? entry.rawVersionOutput : ''; + const connectedAt = typeof entry.connectedAt === 'string' ? entry.connectedAt : ''; + clis[key as SupportedCli] = { binPath, version, rawVersionOutput, connectedAt }; + } + return clis; +} + +function coerceManifest(parsed: unknown, now: string): ConnectionsManifest { + if (!isPlainObject(parsed)) { + return emptyManifest(now); + } + const version = typeof parsed.version === 'number' ? parsed.version : CONNECTIONS_MANIFEST_VERSION; + const updatedAt = typeof parsed.updatedAt === 'string' ? parsed.updatedAt : now; + return { + version, + updatedAt, + clis: coerceClisField(parsed.clis), + }; +} + +export async function readConnectionsManifest( + deps: ConnectionsFileDeps = {}, +): Promise { + const now = deps.now ? deps.now() : new Date().toISOString(); + const filePath = connectionsFilePath(deps); + let contents: string; + try { + contents = await readFile(filePath, 'utf8'); + } catch (err) { + if (isErrnoException(err) && err.code === 'ENOENT') { + return emptyManifest(now); + } + throw err; + } + try { + const parsed = JSON.parse(contents); + return coerceManifest(parsed, now); + } catch (err) { + const warn = deps.warn ?? ((message: string) => process.stderr.write(`${message}\n`)); + const message = err instanceof Error ? err.message : String(err); + warn(`[agent-relay] connections.json was unreadable (${message}); replacing.`); + return emptyManifest(now); + } +} + +export interface UpsertResult { + manifestPath: string; + manifest: ConnectionsManifest; +} + +export async function upsertConnectionsManifest( + entry: CliEntry, + deps: ConnectionsFileDeps = {}, +): Promise { + const now = deps.now ? deps.now() : new Date().toISOString(); + const manifestPath = connectionsFilePath(deps); + const parentDir = path.dirname(manifestPath); + + await mkdir(parentDir, { mode: 0o700, recursive: true }); + await ensureDirectoryMode(parentDir, 0o700); + + const existing = await readConnectionsManifest(deps); + const merged: ConnectionsManifest = { + // Never downgrade an existing manifest. If a future agent-relay release + // bumps CONNECTIONS_MANIFEST_VERSION and writes extra fields, an older + // binary running upsert here would otherwise reset the version back to + // its own (smaller) value and trick the future binary into ignoring its + // own forward-compatible fields. readConnectionsManifest already + // preserves the higher version on read; we now preserve it on write too. + version: Math.max(existing.version, CONNECTIONS_MANIFEST_VERSION), + updatedAt: now, + clis: { + ...existing.clis, + [entry.cli]: { + binPath: entry.binPath, + version: entry.version, + rawVersionOutput: entry.rawVersionOutput, + connectedAt: entry.connectedAt, + }, + }, + }; + + const body = `${JSON.stringify(merged, null, 2)}\n`; + await writeFile(manifestPath, body, { mode: 0o600 }); + await ensureFileMode(manifestPath, 0o600); + + return { manifestPath, manifest: merged }; +} + +async function ensureDirectoryMode(dirPath: string, mode: number): Promise { + try { + const stats = await stat(dirPath); + if ((stats.mode & 0o777) !== mode) { + const fs = await import('node:fs/promises'); + await fs.chmod(dirPath, mode); + } + } catch { + // Best-effort; if chmod is unsupported (e.g., Windows) we don't fail the write. + } +} + +async function ensureFileMode(filePath: string, mode: number): Promise { + try { + const stats = await stat(filePath); + if ((stats.mode & 0o777) !== mode) { + const fs = await import('node:fs/promises'); + await fs.chmod(filePath, mode); + } + } catch { + // Best-effort. + } +} diff --git a/src/cli/lib/detect-cli.test.ts b/src/cli/lib/detect-cli.test.ts new file mode 100644 index 000000000..181a106be --- /dev/null +++ b/src/cli/lib/detect-cli.test.ts @@ -0,0 +1,306 @@ +import { EventEmitter } from 'node:events'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + CliDetectError, + INSTALL_DOCS, + SUPPORTED_CLIS, + findCli, + probeVersion, +} from './detect-cli.js'; + +class FakeChild extends EventEmitter { + pid = 12345; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + lastSignal: NodeJS.Signals | number | undefined; + kill(signal?: NodeJS.Signals | number): boolean { + this.killed = true; + this.lastSignal = signal; + return true; + } +} + +describe('SUPPORTED_CLIS / INSTALL_DOCS', () => { + it('contains exactly claude, codex, gemini', () => { + expect([...SUPPORTED_CLIS]).toEqual(['claude', 'codex', 'gemini']); + }); + + it('has an INSTALL_DOCS entry for every supported cli', () => { + for (const cli of SUPPORTED_CLIS) { + expect(INSTALL_DOCS[cli]).toMatch(/^https?:\/\//); + } + }); +}); + +describe('findCli', () => { + it('returns the first executable hit on PATH', async () => { + const accessed: string[] = []; + const result = await findCli('claude', { + platform: 'darwin', + pathEnv: '/a/bin:/b/bin', + accessExecutable: async (filePath) => { + accessed.push(filePath); + if (filePath === path.join('/b/bin', 'claude')) { + return; + } + throw new Error('not found'); + }, + resolveRealPath: (filePath) => filePath.replace('/b/bin', '/real/bin'), + }); + expect(result.binPath).toBe(path.join('/real/bin', 'claude')); + expect(accessed[0]).toBe(path.join('/a/bin', 'claude')); + }); + + it('skips non-executable matches', async () => { + let calls = 0; + const result = await findCli('codex', { + platform: 'linux', + pathEnv: '/x/bin:/y/bin', + accessExecutable: async (filePath) => { + calls += 1; + if (filePath === path.join('/x/bin', 'codex')) { + const err = new Error('not exec') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return; + }, + resolveRealPath: (filePath) => filePath, + }); + expect(calls).toBe(2); + expect(result.binPath).toBe(path.join('/y/bin', 'codex')); + }); + + it('throws NEEDS_CLI_INSTALL with the docs URL when no candidate is found', async () => { + await expect( + findCli('gemini', { + platform: 'linux', + pathEnv: '/a/bin', + accessExecutable: async () => { + throw new Error('absent'); + }, + }), + ).rejects.toMatchObject({ + code: 'NEEDS_CLI_INSTALL', + exitCode: 2, + message: expect.stringMatching(/NEEDS_CLI_INSTALL.*gemini.*not found on PATH/), + }); + await expect( + findCli('gemini', { + platform: 'linux', + pathEnv: '/a/bin', + accessExecutable: async () => { + throw new Error('absent'); + }, + }), + ).rejects.toMatchObject({ + message: expect.stringContaining(INSTALL_DOCS.gemini), + }); + }); + + it('falls back to the original path when realpath fails', async () => { + const result = await findCli('claude', { + platform: 'linux', + pathEnv: '/only/bin', + accessExecutable: async () => undefined, + resolveRealPath: () => { + throw new Error('symlink loop'); + }, + }); + expect(result.binPath).toBe(path.join('/only/bin', 'claude')); + }); + + it('uses PATHEXT candidate names on win32', async () => { + const probed: string[] = []; + const result = await findCli('codex', { + platform: 'win32', + pathEnv: 'C:/x', + pathExt: '.EXE;.CMD', + accessExecutable: async (filePath) => { + probed.push(filePath); + if (filePath.endsWith('codex.cmd')) { + return; + } + throw new Error('miss'); + }, + resolveRealPath: (filePath) => filePath, + }); + expect(result.binPath).toContain('codex.cmd'); + expect(probed.some((p) => p.endsWith('codex.exe'))).toBe(true); + }); +}); + +describe('probeVersion', () => { + let child: FakeChild; + let spawnCalls: Array<{ + command: string; + args: readonly string[]; + cwd: string; + env: NodeJS.ProcessEnv; + shell?: boolean | string; + }>; + + const stubSpawn = () => { + child = new FakeChild(); + spawnCalls = []; + return (( + command: string, + args: readonly string[], + opts: { cwd: string; env: NodeJS.ProcessEnv; shell?: boolean | string }, + ) => { + spawnCalls.push({ command, args, cwd: opts.cwd, env: opts.env, shell: opts.shell }); + return child as unknown as ReturnType extends never ? never : any; + }) as any; + }; + + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('parses semver from stdout', async () => { + const spawn = stubSpawn(); + const promise = probeVersion('/usr/bin/claude', { spawn, tmpDir: '/tmp' }); + process.nextTick(() => { + child.stdout.emit('data', Buffer.from('claude version 1.2.3 (commit abcdef)\n')); + child.emit('close', 0, null); + }); + const result = await promise; + expect(result.version).toBe('1.2.3'); + expect(result.raw).toContain('claude version 1.2.3'); + expect(spawnCalls[0]?.args).toEqual(['--version']); + expect(spawnCalls[0]?.cwd).toBe('/tmp'); + expect(spawnCalls[0]?.env.PATH).toBeDefined(); + expect(Object.keys(spawnCalls[0]?.env ?? {}).every((key) => + ['PATH', 'HOME', 'XDG_CONFIG_HOME', 'SystemRoot', 'TEMP', 'TMP'].includes(key), + )).toBe(true); + }); + + it('stores "unknown" when no semver token is present', async () => { + const spawn = stubSpawn(); + const promise = probeVersion('/usr/bin/codex', { spawn }); + process.nextTick(() => { + child.stdout.emit('data', Buffer.from('beta build\n')); + child.emit('close', 0, null); + }); + const result = await promise; + expect(result.version).toBe('unknown'); + expect(result.raw).toBe('beta build\n'); + }); + + it('rejects with CLI_VERSION_FAILED on non-zero exit', async () => { + const spawn = stubSpawn(); + const promise = probeVersion('/usr/bin/gemini', { spawn }); + process.nextTick(() => { + child.stderr.emit('data', Buffer.from('gemini: error: bad install\n')); + child.emit('close', 1, null); + }); + await expect(promise).rejects.toBeInstanceOf(CliDetectError); + await expect(promise).rejects.toMatchObject({ + code: 'CLI_VERSION_FAILED', + exitCode: 3, + message: expect.stringContaining('gemini: error: bad install'), + }); + }); + + it('rejects with CLI_VERSION_FAILED on timeout and sends SIGKILL', async () => { + vi.useFakeTimers(); + const spawn = stubSpawn(); + const promise = probeVersion('/usr/bin/claude', { spawn, versionTimeoutMs: 25 }); + vi.advanceTimersByTime(30); + child.emit('close', null, 'SIGKILL'); + await expect(promise).rejects.toMatchObject({ + code: 'CLI_VERSION_FAILED', + message: expect.stringMatching(/timed out after 25ms/), + }); + expect(child.killed).toBe(true); + expect(child.lastSignal).toBe('SIGKILL'); + }); + + it('rejects with CLI_VERSION_FAILED if spawn throws synchronously', async () => { + const spawn = (() => { + throw new Error('spawn refused'); + }) as any; + await expect(probeVersion('/usr/bin/claude', { spawn })).rejects.toMatchObject({ + code: 'CLI_VERSION_FAILED', + exitCode: 3, + message: expect.stringContaining('spawn refused'), + }); + }); + + it('rejects with CLI_VERSION_FAILED when the child emits an error event', async () => { + const spawn = stubSpawn(); + const promise = probeVersion('/usr/bin/codex', { spawn }); + process.nextTick(() => { + child.emit('error', new Error('ENOENT')); + }); + await expect(promise).rejects.toMatchObject({ code: 'CLI_VERSION_FAILED' }); + }); + + it('spawns .cmd shims through the shell on win32 so npm-installed CLIs work', async () => { + // child_process.spawn cannot execute .cmd/.bat directly on Windows + // without `shell: true`; the previous direct-spawn would fail with + // EINVAL/ENOENT before probing the version at all. + const spawn = stubSpawn(); + const promise = probeVersion('C:\\Users\\agent\\AppData\\npm\\codex.cmd', { + spawn, + platform: 'win32', + tmpDir: 'C:\\tmp', + }); + process.nextTick(() => { + child.stdout.emit('data', Buffer.from('codex 0.4.2\n')); + child.emit('close', 0, null); + }); + await promise; + expect(spawnCalls[0]?.shell).toBe(true); + }); + + it('spawns .bat shims through the shell on win32', async () => { + const spawn = stubSpawn(); + const promise = probeVersion('C:\\bin\\gemini.bat', { + spawn, + platform: 'win32', + }); + process.nextTick(() => { + child.stdout.emit('data', Buffer.from('gemini 1.0.0\n')); + child.emit('close', 0, null); + }); + await promise; + expect(spawnCalls[0]?.shell).toBe(true); + }); + + it('does not enable shell mode on POSIX so argv-array safety is preserved', async () => { + const spawn = stubSpawn(); + const promise = probeVersion('/usr/local/bin/claude', { + spawn, + platform: 'linux', + }); + process.nextTick(() => { + child.stdout.emit('data', Buffer.from('claude 1.0.0\n')); + child.emit('close', 0, null); + }); + await promise; + expect(spawnCalls[0]?.shell).toBeUndefined(); + }); + + it('does not enable shell mode for non-.cmd/.bat shims on win32', async () => { + const spawn = stubSpawn(); + const promise = probeVersion('C:\\bin\\claude.exe', { + spawn, + platform: 'win32', + }); + process.nextTick(() => { + child.stdout.emit('data', Buffer.from('claude 1.0.0\n')); + child.emit('close', 0, null); + }); + await promise; + expect(spawnCalls[0]?.shell).toBeUndefined(); + }); +}); diff --git a/src/cli/lib/detect-cli.ts b/src/cli/lib/detect-cli.ts new file mode 100644 index 000000000..1f0a0d622 --- /dev/null +++ b/src/cli/lib/detect-cli.ts @@ -0,0 +1,291 @@ +import { spawn } from 'node:child_process'; +import { access, constants } from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { upsertConnectionsManifest } from './connections-file.js'; + +export const SUPPORTED_CLIS = ['claude', 'codex', 'gemini'] as const; +export type SupportedCli = (typeof SUPPORTED_CLIS)[number]; + +export const INSTALL_DOCS: Record = { + claude: 'https://docs.anthropic.com/claude-code/install', + codex: 'https://github.com/openai/codex#installation', + gemini: 'https://github.com/google-gemini/gemini-cli#installation', +}; + +export type CliDetectErrorCode = + | 'NEEDS_CLI_INSTALL' + | 'CLI_VERSION_FAILED' + | 'UNSUPPORTED_CLI'; + +export class CliDetectError extends Error { + readonly code: CliDetectErrorCode; + readonly exitCode: number; + + constructor(code: CliDetectErrorCode, exitCode: number, message: string) { + super(message); + this.name = 'CliDetectError'; + this.code = code; + this.exitCode = exitCode; + } +} + +export interface SpawnLike { + pid?: number; + on(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; + on(event: 'error', listener: (err: Error) => void): unknown; + stdout: { + on(event: 'data', listener: (chunk: Buffer) => void): unknown; + } | null; + stderr: { + on(event: 'data', listener: (chunk: Buffer) => void): unknown; + } | null; + kill(signal?: NodeJS.Signals | number): boolean; +} + +export interface SpawnFn { + ( + command: string, + args: readonly string[], + options: { cwd: string; env: NodeJS.ProcessEnv; stdio: 'pipe'; shell?: boolean | string }, + ): SpawnLike; +} + +export interface DetectCliDeps { + pathEnv?: string; + pathExt?: string; + platform?: NodeJS.Platform; + accessExecutable?: (filePath: string) => Promise; + resolveRealPath?: (filePath: string) => string; + spawn?: SpawnFn; + versionTimeoutMs?: number; + tmpDir?: string; +} + +export interface FindCliResult { + binPath: string; +} + +export interface ProbeVersionResult { + version: string; + raw: string; +} + +export interface ConnectCliResult { + cli: SupportedCli; + version: string; + binPath: string; + manifestPath: string; +} + +const DEFAULT_VERSION_TIMEOUT_MS = 5000; + +function defaultAccessExecutable(filePath: string): Promise { + return access(filePath, constants.X_OK); +} + +function defaultResolveRealPath(filePath: string): string { + return realpathSync(filePath); +} + +const defaultSpawn: SpawnFn = (command, args, options) => + spawn(command, args, options) as unknown as SpawnLike; + +function splitPathEnv(pathEnv: string, platform: NodeJS.Platform): string[] { + const delimiter = platform === 'win32' ? ';' : ':'; + return pathEnv.split(delimiter).filter((segment) => segment.length > 0); +} + +function candidateNames(cli: SupportedCli, platform: NodeJS.Platform, pathExt: string): string[] { + if (platform !== 'win32') { + return [cli]; + } + const exts = pathExt + .split(';') + .map((ext) => ext.trim().toLowerCase()) + .filter((ext) => ext.length > 0); + if (exts.length === 0) { + return [cli, `${cli}.cmd`, `${cli}.exe`]; + } + return exts.map((ext) => `${cli}${ext}`); +} + +export async function findCli( + cli: SupportedCli, + deps: DetectCliDeps = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const pathEnv = deps.pathEnv ?? process.env.PATH ?? ''; + const pathExt = deps.pathExt ?? process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD'; + const accessExecutable = deps.accessExecutable ?? defaultAccessExecutable; + const resolveRealPath = deps.resolveRealPath ?? defaultResolveRealPath; + + const segments = splitPathEnv(pathEnv, platform); + const names = candidateNames(cli, platform, pathExt); + + for (const segment of segments) { + for (const name of names) { + const candidate = path.join(segment, name); + try { + await accessExecutable(candidate); + } catch { + continue; + } + try { + const resolved = resolveRealPath(candidate); + return { binPath: resolved }; + } catch { + return { binPath: candidate }; + } + } + } + + throw new CliDetectError( + 'NEEDS_CLI_INSTALL', + 2, + `NEEDS_CLI_INSTALL: ${cli} not found on PATH. Install: ${INSTALL_DOCS[cli]}`, + ); +} + +const VERSION_PATTERN = /(\d+\.\d+\.\d+(?:-[A-Za-z0-9.+-]+)?)/; + +function buildChildEnv(): NodeJS.ProcessEnv { + const allow = ['PATH', 'HOME', 'XDG_CONFIG_HOME', 'SystemRoot', 'TEMP', 'TMP']; + const env: NodeJS.ProcessEnv = {}; + for (const key of allow) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + return env; +} + +export async function probeVersion( + binPath: string, + deps: DetectCliDeps = {}, +): Promise { + const spawnImpl = deps.spawn ?? defaultSpawn; + const timeoutMs = deps.versionTimeoutMs ?? DEFAULT_VERSION_TIMEOUT_MS; + const cwd = deps.tmpDir ?? os.tmpdir(); + const env = buildChildEnv(); + const platform = deps.platform ?? process.platform; + + // On Windows, `child_process.spawn` can't execute `.cmd` / `.bat` shims + // (which is how npm-installed CLIs like `claude.cmd` / `codex.cmd` ship) + // unless we go through the shell. Without `shell: true`, the spawn fails + // with EINVAL / ENOENT and the user sees "CLI_VERSION_FAILED: failed to + // spawn …" on every connect attempt. Only enable shell mode on Windows so + // the POSIX path keeps its argv-array safety (`binPath` here comes from + // detect-cli's PATH walk and is already an absolute file path). + const useShell = + platform === 'win32' && /\.(cmd|bat)$/i.test(binPath); + + return new Promise((resolve, reject) => { + let child: SpawnLike; + try { + child = spawnImpl( + binPath, + ['--version'], + useShell + ? { cwd, env, stdio: 'pipe', shell: true } + : { cwd, env, stdio: 'pipe' }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + reject( + new CliDetectError( + 'CLI_VERSION_FAILED', + 3, + `CLI_VERSION_FAILED: failed to spawn ${binPath} --version: ${message}`, + ), + ); + return; + } + + let stdout = ''; + let stderr = ''; + let timedOut = false; + let settled = false; + + const timer = setTimeout(() => { + timedOut = true; + try { + child.kill('SIGKILL'); + } catch { + // best-effort kill + } + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + + child.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject( + new CliDetectError( + 'CLI_VERSION_FAILED', + 3, + `CLI_VERSION_FAILED: ${binPath} --version failed: ${err.message}`, + ), + ); + }); + + child.on('close', (code: number | null, signal: NodeJS.Signals | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (timedOut) { + reject( + new CliDetectError( + 'CLI_VERSION_FAILED', + 3, + `CLI_VERSION_FAILED: ${binPath} --version timed out after ${timeoutMs}ms`, + ), + ); + return; + } + if (code !== 0) { + const firstStderr = stderr.split(/\r?\n/)[0] ?? ''; + const reason = firstStderr.length > 0 ? firstStderr : `exit ${code} signal ${signal ?? 'none'}`; + reject( + new CliDetectError( + 'CLI_VERSION_FAILED', + 3, + `CLI_VERSION_FAILED: ${binPath} found but \`--version\` failed: ${reason}`, + ), + ); + return; + } + const raw = stdout; + const match = raw.match(VERSION_PATTERN); + const version = match ? match[1] : 'unknown'; + resolve({ version, raw }); + }); + }); +} + +export async function connectCli( + cli: SupportedCli, + deps: DetectCliDeps = {}, +): Promise { + const { binPath } = await findCli(cli, deps); + const { version, raw } = await probeVersion(binPath, deps); + const connectedAt = new Date().toISOString(); + const { manifestPath } = await upsertConnectionsManifest({ + cli, + binPath, + version, + rawVersionOutput: raw, + connectedAt, + }); + return { cli, version, binPath, manifestPath }; +}