diff --git a/CLAUDE.md b/CLAUDE.md index 41b8334e..6cdab761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | | `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | +| `ccs docker --help` | `src/commands/docker/help-subcommand.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | | `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | | `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` | diff --git a/src/docker/docker-assets.ts b/src/docker/docker-assets.ts index 85edbf26..1ebf58aa 100644 --- a/src/docker/docker-assets.ts +++ b/src/docker/docker-assets.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; import type { DockerConfigSummary } from './docker-types'; @@ -22,10 +21,33 @@ export interface DockerAssetPaths { entrypoint: string; } +let hasWarnedAboutVersionFallback = false; + function getPackageRoot(): string { return path.resolve(__dirname, '..', '..'); } +function ensureBundledAsset(assetPath: string): void { + try { + require.resolve(assetPath); + } catch { + throw new Error( + `Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.` + ); + } +} + +function warnAboutVersionFallback(error: unknown): void { + if (hasWarnedAboutVersionFallback) { + return; + } + hasWarnedAboutVersionFallback = true; + const detail = error instanceof Error ? error.message : String(error); + console.error( + `[!] Failed to determine the installed CCS version from package metadata; falling back to latest.\n${detail}` + ); +} + export function getDockerAssetPaths(): DockerAssetPaths { const packageRoot = getPackageRoot(); const dockerDir = path.join(packageRoot, 'docker'); @@ -37,13 +59,10 @@ export function getDockerAssetPaths(): DockerAssetPaths { entrypoint: path.join(dockerDir, 'entrypoint-integrated.sh'), }; - for (const assetPath of Object.values(assets)) { - if (!fs.existsSync(assetPath)) { - throw new Error( - `Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.` - ); - } - } + ensureBundledAsset(assets.composeFile); + ensureBundledAsset(assets.dockerfile); + ensureBundledAsset(assets.supervisordConfig); + ensureBundledAsset(assets.entrypoint); return assets; } @@ -51,11 +70,10 @@ export function getDockerAssetPaths(): DockerAssetPaths { export function getInstalledCcsVersion(): string { const packageJsonPath = path.join(getPackageRoot(), 'package.json'); try { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { - version?: string; - }; + const packageJson = require(packageJsonPath) as { version?: string }; return packageJson.version?.trim() || 'latest'; - } catch { + } catch (error) { + warnAboutVersionFallback(error); return 'latest'; } } diff --git a/src/docker/docker-bootstrap.ts b/src/docker/docker-bootstrap.ts index 108d3f25..5b381239 100644 --- a/src/docker/docker-bootstrap.ts +++ b/src/docker/docker-bootstrap.ts @@ -21,9 +21,9 @@ async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configP return { binaryPath, configPath }; } -async function runCliproxy(): Promise { +async function runCliproxy(): Promise { const { binaryPath, configPath } = await prepareIntegratedRuntime(); - await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const child = spawn(binaryPath, ['--config', configPath], { stdio: 'inherit', env: { @@ -34,27 +34,30 @@ async function runCliproxy(): Promise { child.on('error', reject); child.on('close', (code) => { - process.exit(code ?? 1); + resolve(code ?? 1); }); - child.on('spawn', () => resolve()); }); } -async function main(): Promise { +async function main(): Promise { const command = process.argv[2]; if (command !== 'run-cliproxy') { console.error('[X] Usage: node dist/docker/docker-bootstrap.js run-cliproxy'); - process.exit(1); + return 1; } - await runCliproxy(); + return runCliproxy(); } if (require.main === module) { - void main().catch((error) => { - console.error( - `[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}` - ); - process.exit(1); - }); + void main() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((error) => { + console.error( + `[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}` + ); + process.exitCode = 1; + }); } diff --git a/src/docker/docker-executor.ts b/src/docker/docker-executor.ts index 72168fa0..e04a0e01 100644 --- a/src/docker/docker-executor.ts +++ b/src/docker/docker-executor.ts @@ -18,6 +18,9 @@ import type { DockerUpOptions, } from './docker-types'; +const LOCAL_DOCKER_SYNC_TIMEOUT_MS = 10_000; +const REMOTE_DOCKER_SYNC_TIMEOUT_MS = 30_000; + function quotePosix(value: string): string { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } @@ -35,6 +38,37 @@ interface DockerSyncRunOptions { cwd?: string; env?: NodeJS.ProcessEnv; remote?: boolean; + timeoutMs?: number; +} + +function formatTimeout(timeoutMs: number): string { + return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`; +} + +function buildTimeoutMessage( + command: string, + args: string[], + timeoutMs: number, + remote: boolean +): string { + return [ + `Command timed out after ${formatTimeout(timeoutMs)} while running a ${ + remote ? 'remote' : 'local' + } Docker command.`, + `Command: ${renderCommand(command, args)}`, + remote + ? 'Check SSH reachability and the remote Docker host, then try again.' + : 'Check Docker availability on this machine, then try again.', + ].join('\n'); +} + +function applyDefaultTimeouts(options: DockerSyncRunOptions): DockerSyncRunOptions { + return { + ...options, + timeoutMs: + options.timeoutMs ?? + (options.remote ? REMOTE_DOCKER_SYNC_TIMEOUT_MS : LOCAL_DOCKER_SYNC_TIMEOUT_MS), + }; } function runSync( @@ -42,21 +76,32 @@ function runSync( args: string[], options: DockerSyncRunOptions = {} ): DockerCommandResult { + const normalizedOptions = applyDefaultTimeouts(options); const result = spawnSync(command, args, { - cwd: options.cwd, - env: options.env, + cwd: normalizedOptions.cwd, + env: normalizedOptions.env, encoding: 'utf8', stdio: 'pipe', + timeout: normalizedOptions.timeoutMs, windowsHide: true, }); - const stderr = [normalizeOutput(result.stderr), result.error?.message].filter(Boolean).join('\n'); + const errorMessage = + result.error && 'code' in result.error && result.error.code === 'ETIMEDOUT' + ? buildTimeoutMessage( + command, + args, + normalizedOptions.timeoutMs ?? LOCAL_DOCKER_SYNC_TIMEOUT_MS, + normalizedOptions.remote ?? false + ) + : result.error?.message; + const stderr = [normalizeOutput(result.stderr), errorMessage].filter(Boolean).join('\n'); return { command: renderCommand(command, args), exitCode: result.status ?? 1, stdout: normalizeOutput(result.stdout), stderr, - remote: options.remote ?? false, + remote: normalizedOptions.remote ?? false, }; } @@ -78,12 +123,27 @@ function runStreaming(command: string, args: string[]): Promise { }); } +let cachedLocalComposePrefix: string[] | undefined; + function resolveLocalComposePrefix(): string[] { - if (runSync('docker', ['compose', 'version']).exitCode === 0) { - return ['docker', 'compose']; + if (cachedLocalComposePrefix) { + return [...cachedLocalComposePrefix]; } - if (runSync('docker-compose', ['version']).exitCode === 0) { - return ['docker-compose']; + if ( + runSync('docker', ['compose', 'version'], { + timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS, + }).exitCode === 0 + ) { + cachedLocalComposePrefix = ['docker', 'compose']; + return [...cachedLocalComposePrefix]; + } + if ( + runSync('docker-compose', ['version'], { + timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS, + }).exitCode === 0 + ) { + cachedLocalComposePrefix = ['docker-compose']; + return [...cachedLocalComposePrefix]; } throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.'); } @@ -129,7 +189,7 @@ export class DockerExecutor { return createDockerConfigSummary(options); } - async up(options: DockerUpOptions): Promise { + up(options: DockerUpOptions): void { if (options.host) { this.stageRemoteAssets(options.host); } @@ -144,11 +204,11 @@ export class DockerExecutor { ); } - async down(options: DockerCommandTarget): Promise { + down(options: DockerCommandTarget): void { this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options); } - async status(options: DockerCommandTarget): Promise { + status(options: DockerCommandTarget): DockerStatusResult { const compose = this.runCompose(['ps'], options); this.ensureSuccess(compose, 'Docker status', options); let supervisor: DockerCommandResult | undefined; @@ -161,7 +221,7 @@ export class DockerExecutor { return { compose, supervisor }; } - async update(options: DockerCommandTarget): Promise { + update(options: DockerCommandTarget): void { const script = 'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy'; this.ensureSuccess( @@ -268,7 +328,11 @@ export class DockerExecutor { args: string[], options: DockerSyncRunOptions = {} ): DockerCommandResult { - return this.deps.runSync?.(command, args, options) ?? runSync(command, args, options); + const normalizedOptions = applyDefaultTimeouts(options); + return ( + this.deps.runSync?.(command, args, normalizedOptions) ?? + runSync(command, args, normalizedOptions) + ); } private async runStreaming(command: string, args: string[]): Promise { diff --git a/tests/unit/docker/docker-executor.test.ts b/tests/unit/docker/docker-executor.test.ts index 9a82e0e2..bb0025f9 100644 --- a/tests/unit/docker/docker-executor.test.ts +++ b/tests/unit/docker/docker-executor.test.ts @@ -17,6 +17,7 @@ type SyncCall = { cwd?: string; env?: NodeJS.ProcessEnv; remote?: boolean; + timeoutMs?: number; }; }; @@ -47,18 +48,12 @@ describe('docker executor', () => { expect(calls).toHaveLength(1); expect(calls[0].command).toBe('docker'); - expect(calls[0].args).toEqual([ - 'compose', - '-f', - fakeAssets.composeFile, - 'up', - '-d', - '--build', - ]); + expect(calls[0].args).toEqual(['compose', '-f', fakeAssets.composeFile, 'up', '-d', '--build']); expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir); expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0'); expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000'); expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317'); + expect(calls[0].options?.timeoutMs).toBe(10_000); }); it('stages bundled assets before remote compose startup', async () => { @@ -78,7 +73,7 @@ describe('docker executor', () => { expect(calls[0]).toEqual({ command: 'ssh', args: ['docker', 'mkdir -p ~/.ccs/docker'], - options: { remote: true }, + options: { remote: true, timeoutMs: 30_000 }, }); expect(calls[1].command).toBe('scp'); expect(calls[1].args).toEqual([ @@ -88,13 +83,14 @@ describe('docker executor', () => { fakeAssets.entrypoint, 'docker:~/.ccs/docker/', ]); - expect(calls[1].options).toEqual({ remote: true }); + expect(calls[1].options).toEqual({ remote: true, timeoutMs: 30_000 }); expect(calls[2].command).toBe('ssh'); expect(calls[2].args[0]).toBe('docker'); expect(calls[2].args[1]).toContain("CCS_NPM_VERSION='7.59.0'"); expect(calls[2].args[1]).toContain("CCS_DASHBOARD_PORT='3000'"); expect(calls[2].args[1]).toContain("CCS_CLIPROXY_PORT='8317'"); expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1'); + expect(calls[2].options?.timeoutMs).toBe(30_000); }); it('uses npm install latest rather than npm update during in-container updates', async () => { @@ -162,4 +158,30 @@ describe('docker executor', () => { expect(status.supervisor?.exitCode).toBe(7); expect(status.supervisor?.stderr).toContain('supervisor.sock'); }); + + it('surfaces a clear timeout message for blocked sync commands', () => { + const executor = new DockerExecutor({ + assets: fakeAssets, + }); + + const result = ( + executor as unknown as { + runSync: ( + command: string, + args: string[], + options?: { remote?: boolean; timeoutMs?: number } + ) => DockerCommandResult; + } + ).runSync(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { + remote: true, + timeoutMs: 25, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Command timed out after 25ms while running a remote Docker command.' + ); + expect(result.stderr).toContain(`Command: ${process.execPath}`); + expect(result.stderr).toContain('Check SSH reachability and the remote Docker host'); + }); });