mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
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:
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user