Merge pull request #820 from kaitranntt/kai/feat/812-docker-command

feat: add ccs docker deployment commands
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-27 15:23:12 -04:00
commit 508c96e289
38 files changed
+2156 -5

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
let calls: string[] = [];
let originalExitCode: number | undefined;
beforeEach(() => {
calls = [];
originalExitCode = process.exitCode;
process.exitCode = 0;
mock.module('../../../src/commands/docker/help-subcommand', () => ({
showHelp: async () => {
calls.push('help');
},
}));
mock.module('../../../src/commands/docker/up-subcommand', () => ({
handleUp: async (args: string[]) => {
calls.push(`up:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/docker/down-subcommand', () => ({
handleDown: async (args: string[]) => {
calls.push(`down:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/docker/status-subcommand', () => ({
handleStatus: async (args: string[]) => {
calls.push(`status:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/docker/update-subcommand', () => ({
handleUpdate: async (args: string[]) => {
calls.push(`update:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/docker/logs-subcommand', () => ({
handleLogs: async (args: string[]) => {
calls.push(`logs:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/docker/config-subcommand', () => ({
handleConfig: async (args: string[]) => {
calls.push(`config:${args.join(' ')}`);
},
}));
});
afterEach(() => {
mock.restore();
process.exitCode = originalExitCode ?? 0;
});
async function loadHandleDockerCommand() {
const mod = await import(`../../../src/commands/docker/index?test=${Date.now()}-${Math.random()}`);
return mod.handleDockerCommand;
}
describe('docker command', () => {
it('shows help when invoked without a subcommand', async () => {
const handleDockerCommand = await loadHandleDockerCommand();
await handleDockerCommand([]);
expect(calls).toEqual(['help']);
expect(process.exitCode).toBe(0);
});
it('routes nested subcommands with remaining args intact', async () => {
const handleDockerCommand = await loadHandleDockerCommand();
await handleDockerCommand(['up', '--host', 'my-box', '--port', '4000']);
expect(calls).toEqual(['up:--host my-box --port 4000']);
});
it('supports --host before the subcommand', async () => {
const handleDockerCommand = await loadHandleDockerCommand();
await handleDockerCommand(['--host', 'my-box', 'status']);
expect(calls).toEqual(['status:--host my-box']);
});
it('treats help as a nested subcommand alias', async () => {
const handleDockerCommand = await loadHandleDockerCommand();
await handleDockerCommand(['help']);
expect(calls).toEqual(['help']);
expect(process.exitCode).toBe(0);
});
it('marks unknown subcommands as failures after printing help', async () => {
const handleDockerCommand = await loadHandleDockerCommand();
await handleDockerCommand(['unknown']);
expect(calls).toEqual(['help']);
expect(process.exitCode).toBe(1);
});
});
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'bun:test';
import {
renderCapturedLines,
useDockerSubcommandConsoleCapture,
} from './docker-subcommand-test-helpers';
const capture = useDockerSubcommandConsoleCapture();
async function loadHandleConfig() {
const mod = await import(
`../../../src/commands/docker/config-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleConfig;
}
describe('docker config subcommand', () => {
it('prints the derived Docker configuration table', async () => {
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig;
dockerModule.DockerExecutor.prototype.getConfig = function () {
return {
host: 'docker-box',
remote: true,
ccsDir: '/tmp/.ccs',
dockerDir: '/tmp/docker',
composeFile: '/tmp/docker/docker-compose.integrated.yml',
dockerfile: '/tmp/docker/Dockerfile.integrated',
supervisordConfig: '/tmp/docker/supervisord.conf',
entrypoint: '/tmp/docker/entrypoint-integrated.sh',
remoteDeployDir: '~/.ccs/docker',
composeService: 'ccs-cliproxy',
containerName: 'ccs-cliproxy',
dashboardPort: 3000,
proxyPort: 8317,
};
};
try {
const handleConfig = await loadHandleConfig();
await handleConfig(['--host', 'docker-box']);
const rendered = renderCapturedLines(capture.logLines);
expect(rendered).toContain('remote (docker-box)');
expect(rendered).toContain('/tmp/docker/docker-compose.integrated.yml');
expect(rendered).toContain('ccs-cliproxy');
expect(capture.errorLines).toEqual([]);
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig;
}
});
it('renders thrown config errors as boxed failures', async () => {
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig;
dockerModule.DockerExecutor.prototype.getConfig = function () {
throw new Error('Missing bundled Docker asset: /tmp/docker/Dockerfile.integrated');
};
try {
const handleConfig = await loadHandleConfig();
await handleConfig([]);
const rendered = renderCapturedLines(capture.errorLines);
expect(rendered).toContain('Missing bundled Docker asset');
expect(process.exitCode).toBe(1);
} finally {
dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig;
}
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'bun:test';
import {
renderCapturedLines,
useDockerSubcommandConsoleCapture,
} from './docker-subcommand-test-helpers';
const capture = useDockerSubcommandConsoleCapture();
async function loadHandleDown() {
const mod = await import(
`../../../src/commands/docker/down-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleDown;
}
describe('docker down subcommand', () => {
it('prints progress and success output for remote shutdowns', async () => {
const calls: Array<{ host?: string }> = [];
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalDown = dockerModule.DockerExecutor.prototype.down;
dockerModule.DockerExecutor.prototype.down = function (options: { host?: string }) {
calls.push(options);
};
try {
const handleDown = await loadHandleDown();
await handleDown(['--host', 'docker-box']);
const rendered = renderCapturedLines(capture.logLines);
expect(calls).toEqual([{ host: 'docker-box' }]);
expect(rendered).toContain('Stopping Docker stack on docker-box...');
expect(rendered).toContain('Docker stack stopped on docker-box.');
expect(capture.errorLines).toEqual([]);
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.down = originalDown;
}
});
it('renders executor failures as boxed errors', async () => {
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalDown = dockerModule.DockerExecutor.prototype.down;
dockerModule.DockerExecutor.prototype.down = function () {
throw new Error('Docker stack shutdown failed.\nCommand timed out after 30s.');
};
try {
const handleDown = await loadHandleDown();
await handleDown([]);
const rendered = renderCapturedLines(capture.errorLines);
expect(rendered).toContain('Docker stack shutdown failed.');
expect(rendered).toContain('Command timed out after 30s.');
expect(process.exitCode).toBe(1);
} finally {
dockerModule.DockerExecutor.prototype.down = originalDown;
}
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'bun:test';
import {
renderCapturedLines,
useDockerSubcommandConsoleCapture,
} from './docker-subcommand-test-helpers';
const capture = useDockerSubcommandConsoleCapture();
async function loadHandleLogs() {
const mod = await import(
`../../../src/commands/docker/logs-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleLogs;
}
describe('docker logs subcommand', () => {
it('prints log snapshots returned by the executor', async () => {
const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = [];
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalLogs = dockerModule.DockerExecutor.prototype.logs;
dockerModule.DockerExecutor.prototype.logs = async function (options: {
host?: string;
follow: boolean;
service?: 'ccs' | 'cliproxy';
}) {
calls.push(options);
return '== ccs ==\nready';
};
try {
const handleLogs = await loadHandleLogs();
await handleLogs(['--service', 'ccs']);
expect(calls).toEqual([{ follow: false, service: 'ccs' }]);
expect(renderCapturedLines(capture.logLines)).toContain('== ccs ==\nready');
expect(capture.errorLines).toEqual([]);
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.logs = originalLogs;
}
});
it('announces follow mode before streaming logs', async () => {
const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = [];
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalLogs = dockerModule.DockerExecutor.prototype.logs;
dockerModule.DockerExecutor.prototype.logs = async function (options: {
host?: string;
follow: boolean;
service?: 'ccs' | 'cliproxy';
}) {
calls.push(options);
};
try {
const handleLogs = await loadHandleLogs();
await handleLogs(['--host', 'docker-box', '--follow', '--service', 'cliproxy']);
const rendered = renderCapturedLines(capture.logLines);
expect(calls).toEqual([{ host: 'docker-box', follow: true, service: 'cliproxy' }]);
expect(rendered).toContain('Following Docker logs on docker-box...');
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.logs = originalLogs;
}
});
it('renders executor failures as boxed errors', async () => {
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalLogs = dockerModule.DockerExecutor.prototype.logs;
dockerModule.DockerExecutor.prototype.logs = async function () {
throw new Error('Docker log retrieval failed.\nContainer is not running.');
};
try {
const handleLogs = await loadHandleLogs();
await handleLogs([]);
const rendered = renderCapturedLines(capture.errorLines);
expect(rendered).toContain('Docker log retrieval failed.');
expect(rendered).toContain('Container is not running.');
expect(process.exitCode).toBe(1);
} finally {
dockerModule.DockerExecutor.prototype.logs = originalLogs;
}
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'bun:test';
import {
parseDockerLogsOptions,
parseDockerTarget,
parseDockerUpOptions,
} from '../../../src/commands/docker/options';
describe('docker options', () => {
it('accepts a single SSH target for --host', () => {
const parsed = parseDockerTarget(['--host', 'my-box'], ['--host']);
expect(parsed.errors).toEqual([]);
expect(parsed.host).toBe('my-box');
});
it('rejects whitespace-separated SSH command strings', () => {
const parsed = parseDockerTarget(['--host', 'user@host -p 2222'], ['--host']);
expect(parsed.errors).toEqual([
'Invalid value for --host. Use a single SSH target or SSH config alias such as my-box or user@host.',
]);
});
it('rejects dash-prefixed host tokens', () => {
const parsed = parseDockerTarget(['--host', '-p'], ['--host']);
expect(parsed.errors).toEqual(['Missing value for --host']);
});
it('parses local up port overrides', () => {
const parsed = parseDockerUpOptions(['--port', '4000', '--proxy-port', '9317'], [
'--port',
'--proxy-port',
]);
expect(parsed.errors).toEqual([]);
expect(parsed.port).toBe(4000);
expect(parsed.proxyPort).toBe(9317);
});
it('rejects invalid numeric up port overrides', () => {
const parsed = parseDockerUpOptions(['--port', '99999'], ['--port']);
expect(parsed.errors).toEqual(['Invalid value for --port']);
});
it('rejects invalid log service filters', () => {
const parsed = parseDockerLogsOptions(['--service', 'api'], ['--service']);
expect(parsed.errors).toEqual(['Invalid value for --service. Use: ccs or cliproxy']);
});
});
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
let logLines: string[] = [];
let errorLines: string[] = [];
let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error;
let originalExitCode: number | undefined;
beforeEach(() => {
logLines = [];
errorLines = [];
originalConsoleLog = console.log;
originalConsoleError = console.error;
originalExitCode = process.exitCode;
process.exitCode = 0;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
console.error = (...args: unknown[]) => {
errorLines.push(args.map(String).join(' '));
};
});
afterEach(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
process.exitCode = originalExitCode ?? 0;
});
async function loadHandleStatus() {
const mod = await import(
`../../../src/commands/docker/status-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleStatus;
}
describe('docker status subcommand', () => {
it('prints supervisor failure details instead of masking them as a missing container', async () => {
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalStatus = dockerModule.DockerExecutor.prototype.status;
dockerModule.DockerExecutor.prototype.status = function () {
return {
compose: {
command: '',
exitCode: 0,
stdout: 'NAME STATUS',
stderr: '',
remote: false,
},
supervisor: {
command: '',
exitCode: 7,
stdout: '',
stderr: 'unix:///var/run/supervisor.sock no such file',
remote: false,
},
};
};
try {
const handleStatus = await loadHandleStatus();
await handleStatus([]);
const rendered = logLines.join('\n');
expect(rendered).toContain('Docker status');
expect(rendered).toContain('Supervisor status check failed');
expect(rendered).toContain('supervisor.sock no such file');
expect(rendered).not.toContain('may not be running');
expect(errorLines).toEqual([]);
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.status = originalStatus;
}
});
});
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, mock } from 'bun:test';
export function useDockerSubcommandConsoleCapture(): {
logLines: string[];
errorLines: string[];
} {
const state = {
logLines: [] as string[],
errorLines: [] as string[],
};
let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error;
let originalExitCode: number | undefined;
beforeEach(() => {
state.logLines = [];
state.errorLines = [];
originalConsoleLog = console.log;
originalConsoleError = console.error;
originalExitCode = process.exitCode;
process.exitCode = 0;
console.log = (...args: unknown[]) => {
state.logLines.push(args.map(String).join(' '));
};
console.error = (...args: unknown[]) => {
state.errorLines.push(args.map(String).join(' '));
};
});
afterEach(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
process.exitCode = originalExitCode ?? 0;
mock.restore();
});
return state;
}
export function renderCapturedLines(lines: string[]): string {
return lines.join('\n');
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test';
import {
renderCapturedLines,
useDockerSubcommandConsoleCapture,
} from './docker-subcommand-test-helpers';
const capture = useDockerSubcommandConsoleCapture();
async function loadHandleUp() {
const mod = await import(
`../../../src/commands/docker/up-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleUp;
}
describe('docker up subcommand', () => {
it('prints the success summary with the requested host and port mappings', async () => {
const calls: Array<{ host?: string; port: number; proxyPort: number }> = [];
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalUp = dockerModule.DockerExecutor.prototype.up;
dockerModule.DockerExecutor.prototype.up = function (options: {
host?: string;
port: number;
proxyPort: number;
}) {
calls.push(options);
};
try {
const handleUp = await loadHandleUp();
await handleUp(['--host', 'docker-box', '--port', '4000', '--proxy-port', '9317']);
const rendered = renderCapturedLines(capture.logLines);
expect(calls).toEqual([{ host: 'docker-box', port: 4000, proxyPort: 9317 }]);
expect(rendered).toContain('Starting integrated Docker stack on docker-box');
expect(rendered).toContain('Docker stack is running on docker-box.');
expect(rendered).toContain('Dashboard port: 4000');
expect(rendered).toContain('CLIProxy port: 9317');
expect(capture.errorLines).toEqual([]);
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.up = originalUp;
}
});
it('renders boxed validation errors without invoking the executor', async () => {
const handleUp = await loadHandleUp();
await handleUp(['--port', '70000']);
const rendered = renderCapturedLines(capture.errorLines);
expect(rendered).toContain('Invalid value for --port');
expect(process.exitCode).toBe(1);
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'bun:test';
import {
renderCapturedLines,
useDockerSubcommandConsoleCapture,
} from './docker-subcommand-test-helpers';
const capture = useDockerSubcommandConsoleCapture();
async function loadHandleUpdate() {
const mod = await import(
`../../../src/commands/docker/update-subcommand?test=${Date.now()}-${Math.random()}`
);
return mod.handleUpdate;
}
describe('docker update subcommand', () => {
it('prints progress and success output for remote updates', async () => {
const calls: Array<{ host?: string }> = [];
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalUpdate = dockerModule.DockerExecutor.prototype.update;
dockerModule.DockerExecutor.prototype.update = function (options: { host?: string }) {
calls.push(options);
};
try {
const handleUpdate = await loadHandleUpdate();
await handleUpdate(['--host', 'docker-box']);
const rendered = renderCapturedLines(capture.logLines);
expect(calls).toEqual([{ host: 'docker-box' }]);
expect(rendered).toContain('Updating running Docker stack on docker-box...');
expect(rendered).toContain('Docker stack updated on docker-box.');
expect(capture.errorLines).toEqual([]);
expect(process.exitCode).toBe(0);
} finally {
dockerModule.DockerExecutor.prototype.update = originalUpdate;
}
});
it('renders executor failures as boxed errors', async () => {
const dockerModule = (await import(
'../../../src/docker'
)) as typeof import('../../../src/docker');
const originalUpdate = dockerModule.DockerExecutor.prototype.update;
dockerModule.DockerExecutor.prototype.update = function () {
throw new Error('Docker stack update failed.\nSupervisor restart did not complete.');
};
try {
const handleUpdate = await loadHandleUpdate();
await handleUpdate([]);
const rendered = renderCapturedLines(capture.errorLines);
expect(rendered).toContain('Docker stack update failed.');
expect(rendered).toContain('Supervisor restart did not complete.');
expect(process.exitCode).toBe(1);
} finally {
dockerModule.DockerExecutor.prototype.update = originalUpdate;
}
});
});
@@ -60,6 +60,16 @@ describe('help command parity', () => {
expect(rendered.includes('Force all-interface binding for remote devices')).toBe(true);
});
test('root help documents docker deployment commands', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs docker --help')).toBe(true);
expect(rendered.includes('ccs docker up --host <ssh>')).toBe(true);
expect(rendered.includes('ccs docker update')).toBe(true);
});
test('root help documents official channels native-only scope and process-env tokens', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
@@ -33,6 +33,12 @@ beforeEach(() => {
},
}));
mock.module('../../../src/commands/docker-command', () => ({
handleDockerCommand: async (args: string[]) => {
calls.push(`docker:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/tokens-command', () => ({
handleTokensCommand: async () => 37,
}));
@@ -86,6 +92,16 @@ describe('root-command-router', () => {
expect(calls).toEqual(['api:discover --register']);
});
it('routes docker commands through the root router', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['docker', 'status', '--host', 'my-box'])).resolves.toBe(
true
);
expect(calls).toEqual(['docker:status --host my-box']);
});
it('exits with the nested command exit code when required', async () => {
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from 'bun:test';
import { DockerExecutor } from '../../../src/docker/docker-executor';
import type { DockerCommandResult } from '../../../src/docker/docker-types';
const fakeAssets = {
dockerDir: '/tmp/ccs-docker',
composeFile: '/tmp/ccs-docker/docker-compose.integrated.yml',
dockerfile: '/tmp/ccs-docker/Dockerfile.integrated',
supervisordConfig: '/tmp/ccs-docker/supervisord.conf',
entrypoint: '/tmp/ccs-docker/entrypoint-integrated.sh',
};
type SyncCall = {
command: string;
args: string[];
options?: {
cwd?: string;
env?: NodeJS.ProcessEnv;
remote?: boolean;
timeoutMs?: number;
};
};
function okResult(remote = false): DockerCommandResult {
return {
command: '',
exitCode: 0,
stdout: '',
stderr: '',
remote,
};
}
describe('docker executor', () => {
it('passes compose env and bundled compose file when bringing the stack up locally', async () => {
const calls: SyncCall[] = [];
const executor = new DockerExecutor({
assets: fakeAssets,
getInstalledCcsVersion: () => '7.59.0',
resolveLocalComposePrefix: () => ['docker', 'compose'],
runSync: (command, args, options) => {
calls.push({ command, args, options });
return okResult(options?.remote ?? false);
},
});
await executor.up({ port: 4000, proxyPort: 9317 });
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].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 () => {
const calls: SyncCall[] = [];
const executor = new DockerExecutor({
assets: fakeAssets,
getInstalledCcsVersion: () => '7.59.0',
runSync: (command, args, options) => {
calls.push({ command, args, options });
return okResult(options?.remote ?? false);
},
});
await executor.up({ host: 'docker', port: 3000, proxyPort: 8317 });
expect(calls).toHaveLength(3);
expect(calls[0]).toEqual({
command: 'ssh',
args: ['docker', 'mkdir -p ~/.ccs/docker'],
options: { remote: true, timeoutMs: 30_000 },
});
expect(calls[1].command).toBe('scp');
expect(calls[1].args).toEqual([
fakeAssets.composeFile,
fakeAssets.dockerfile,
fakeAssets.supervisordConfig,
fakeAssets.entrypoint,
'docker:~/.ccs/docker/',
]);
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 () => {
const calls: SyncCall[] = [];
const executor = new DockerExecutor({
assets: fakeAssets,
runSync: (command, args, options) => {
calls.push({ command, args, options });
return okResult(options?.remote ?? false);
},
});
await executor.update({});
expect(calls).toHaveLength(1);
expect(calls[0].command).toBe('docker');
expect(calls[0].args[0]).toBe('exec');
expect(calls[0].args[1]).toBe('ccs-cliproxy');
expect(calls[0].args[4]).toContain('npm install -g @kaitranntt/ccs@latest --force');
expect(calls[0].args[4]).toContain('ccs cliproxy --latest');
expect(calls[0].args[4]).toContain(
'supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy'
);
});
it('preserves supervisorctl failures in status results for CLI rendering', async () => {
let callCount = 0;
const executor = new DockerExecutor({
assets: fakeAssets,
resolveLocalComposePrefix: () => ['docker', 'compose'],
runSync: (_command, args, options) => {
callCount++;
if (callCount === 1) {
expect(args).toEqual(['compose', '-f', fakeAssets.composeFile, 'ps']);
return {
command: '',
exitCode: 0,
stdout: 'NAME STATUS',
stderr: '',
remote: options?.remote ?? false,
};
}
expect(args).toEqual([
'exec',
'ccs-cliproxy',
'supervisorctl',
'-c',
'/etc/supervisord.conf',
'status',
]);
return {
command: '',
exitCode: 7,
stdout: '',
stderr: 'unix:///var/run/supervisor.sock no such file',
remote: options?.remote ?? false,
};
},
});
const status = await executor.status({});
expect(status.compose.exitCode).toBe(0);
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');
});
});