fix(docker): bound blocking command execution

- add default local and remote sync-command timeouts with clearer timeout diagnostics

- remove extra synchronous fs and process.exit patterns from docker assets/bootstrap helpers

- register the docker help handler in CLAUDE.md for the repo help-location reference
This commit is contained in:
Tam Nhu Tran
2026-03-27 15:05:17 -04:00
parent 6ac8f59a78
commit 5ad4303345
5 changed files with 156 additions and 48 deletions
+1
View File
@@ -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()` |
+30 -12
View File
@@ -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';
}
}
+16 -13
View File
@@ -21,9 +21,9 @@ async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configP
return { binaryPath, configPath };
}
async function runCliproxy(): Promise<void> {
async function runCliproxy(): Promise<number> {
const { binaryPath, configPath } = await prepareIntegratedRuntime();
await new Promise<void>((resolve, reject) => {
return new Promise<number>((resolve, reject) => {
const child = spawn(binaryPath, ['--config', configPath], {
stdio: 'inherit',
env: {
@@ -34,27 +34,30 @@ async function runCliproxy(): Promise<void> {
child.on('error', reject);
child.on('close', (code) => {
process.exit(code ?? 1);
resolve(code ?? 1);
});
child.on('spawn', () => resolve());
});
}
async function main(): Promise<void> {
async function main(): Promise<number> {
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;
});
}
+77 -13
View File
@@ -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<void> {
});
}
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<void> {
up(options: DockerUpOptions): void {
if (options.host) {
this.stageRemoteAssets(options.host);
}
@@ -144,11 +204,11 @@ export class DockerExecutor {
);
}
async down(options: DockerCommandTarget): Promise<void> {
down(options: DockerCommandTarget): void {
this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options);
}
async status(options: DockerCommandTarget): Promise<DockerStatusResult> {
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<void> {
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<void> {
+32 -10
View File
@@ -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');
});
});