feat(docker): add integrated deployment commands

- add ccs docker subcommands for up, down, status, update, logs, and config
- bundle integrated docker assets and remote ssh deployment support
- add executor regression tests and root help/router coverage

Refs #812
This commit is contained in:
Tam Nhu Tran
2026-03-27 15:05:17 -04:00
parent 48da0f3538
commit 23f767b0c5
29 changed files with 1607 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
FROM eceasy/cli-proxy-api:latest
ARG CCS_NPM_VERSION=latest
RUN apk add --no-cache \
curl \
jq \
nodejs \
npm \
supervisor
RUN npm install -g @kaitranntt/ccs@${CCS_NPM_VERSION} \
&& ln -sf /usr/local/lib/node_modules/@kaitranntt/ccs/dist/docker/docker-bootstrap.js /usr/local/bin/ccs-docker-bootstrap
COPY supervisord.conf /etc/supervisord.conf
COPY entrypoint-integrated.sh /entrypoint-integrated.sh
RUN chmod +x /entrypoint-integrated.sh \
&& mkdir -p /var/log/ccs
EXPOSE 3000 8085 8317
ENTRYPOINT ["/entrypoint-integrated.sh"]
+32
View File
@@ -0,0 +1,32 @@
services:
ccs-cliproxy:
build:
context: .
dockerfile: Dockerfile.integrated
args:
CCS_NPM_VERSION: "${CCS_NPM_VERSION:-latest}"
image: ccs-cliproxy:latest
container_name: ccs-cliproxy
restart: unless-stopped
init: true
ports:
- "${CCS_DASHBOARD_PORT:-3000}:3000"
- "${CCS_CLIPROXY_PORT:-8317}:8317"
environment:
CCS_PORT: 3000
NODE_ENV: production
NO_COLOR: "${NO_COLOR:-}"
CCS_DEBUG: "${CCS_DEBUG:-}"
volumes:
- ccs_home:/root/.ccs
- ccs_logs:/var/log/ccs
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 2 http://localhost:3000/ >/dev/null && curl -fsS --max-time 2 http://127.0.0.1:8317/ >/dev/null"]
interval: 10s
timeout: 3s
retries: 12
start_period: 30s
volumes:
ccs_home:
ccs_logs:
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
LOG_DIR="/var/log/ccs"
mkdir -p /root/.ccs /root/.ccs/cliproxy "$LOG_DIR"
touch "$LOG_DIR/ccs-dashboard.log" "$LOG_DIR/cliproxy.log"
exec /usr/bin/supervisord -c /etc/supervisord.conf
+43
View File
@@ -0,0 +1,43 @@
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisord.log
pidfile=/var/run/supervisord.pid
loglevel=info
[rpcinterface:supervisor]
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[program:cliproxy]
command=node /usr/local/bin/ccs-docker-bootstrap run-cliproxy
directory=/root
autostart=true
autorestart=true
startsecs=5
startretries=3
stderr_logfile=/var/log/ccs/cliproxy.log
stderr_logfile_maxbytes=0
stdout_logfile=/var/log/ccs/cliproxy.log
stdout_logfile_maxbytes=0
priority=10
[program:ccs-dashboard]
command=ccs config --host 0.0.0.0 --port 3000
directory=/root
autostart=true
autorestart=true
startsecs=10
startretries=3
stderr_logfile=/var/log/ccs/ccs-dashboard.log
stderr_logfile_maxbytes=0
stdout_logfile=/var/log/ccs/ccs-dashboard.log
stdout_logfile_maxbytes=0
priority=20
environment=HOME="/root",NODE_ENV="production"
+1
View File
@@ -32,6 +32,7 @@
},
"files": [
"dist/",
"docker/",
"lib/",
"scripts/",
"config/",
+1
View File
@@ -0,0 +1 @@
export { handleDockerCommand } from './docker/index';
+59
View File
@@ -0,0 +1,59 @@
import { DockerExecutor } from '../../docker';
import { box, color, fail, initUI, table } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host'] as const;
export async function handleConfig(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
try {
const config = new DockerExecutor().getConfig({ host: parsed.host });
const rows = [
['Mode', config.remote ? `remote (${config.host})` : 'local'],
['Local CCS Dir', config.ccsDir],
['Bundled Docker Dir', config.dockerDir],
['Compose File', config.composeFile],
['Dockerfile', config.dockerfile],
['Supervisor Config', config.supervisordConfig],
['Entrypoint', config.entrypoint],
['Remote Deploy Dir', config.remoteDeployDir],
['Compose Service', config.composeService],
['Container Name', config.containerName],
['Dashboard Port', String(config.dashboardPort)],
['CLIProxy Port', String(config.proxyPort)],
];
console.log(
table(
rows.map(([key, value]) => [color(key, 'primary'), value]),
{
head: ['Setting', 'Value'],
style: 'ascii',
}
)
);
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { DockerExecutor } from '../../docker';
import { box, fail, info, initUI, ok } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host'] as const;
export async function handleDown(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
console.log(info(`Stopping Docker stack${parsed.host ? ` on ${parsed.host}` : ''}...`));
try {
await new DockerExecutor().down({ host: parsed.host });
console.log(ok(`Docker stack stopped${parsed.host ? ` on ${parsed.host}` : ''}.`));
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { color, dim, header, initUI, subheader } from '../../utils/ui';
export async function showHelp(): Promise<void> {
await initUI();
console.log('');
console.log(header('Docker Deployment'));
console.log('');
console.log(subheader('Usage:'));
console.log(` ${color('ccs docker', 'command')} <command> [options]`);
console.log('');
const sections: [string, [string, string][]][] = [
[
'Commands:',
[
['up', 'Build and start the integrated CCS + CLIProxy stack'],
['down', 'Stop and remove the integrated stack'],
['status', 'Show docker compose and supervisor status'],
['update', 'Update CCS and CLIProxy inside the running container'],
['logs', 'Show or follow container log output'],
['config', 'Show bundled asset paths and deployment defaults'],
],
],
[
'Common Options:',
[
['--host <target>', 'Run command on a remote host over SSH (single target or SSH alias)'],
['--help, -h', 'Show this help message'],
],
],
[
'Per-Command Options:',
[
['up --port <port>', 'Publish dashboard on a custom host port'],
['up --proxy-port <port>', 'Publish CLIProxy on a custom host port'],
['logs --follow', 'Stream logs continuously'],
['logs --service <name>', 'Filter logs to ccs or cliproxy'],
],
],
[
'Examples:',
[
['ccs docker up', 'Start the stack locally on ports 3000 and 8317'],
['ccs docker up --port 4000 --proxy-port 9317', 'Start locally with custom ports'],
['ccs docker --host my-box status', 'Use the documented common-option ordering'],
['ccs docker up --host my-box', 'Stage assets to ~/.ccs/docker and deploy remotely'],
['ccs docker logs --follow --service ccs', 'Tail dashboard logs only'],
['ccs docker update --host my-box', 'Update the running remote stack in place'],
],
],
];
for (const [title, rows] of sections) {
console.log(subheader(title));
const width = Math.max(...rows.map(([command]) => command.length));
for (const [command, description] of rows) {
console.log(` ${color(command.padEnd(width + 2), 'command')} ${description}`);
}
console.log('');
}
console.log(
dim(' Remote deployments use ~/.ccs/docker on the target host to avoid root-only paths.')
);
console.log('');
}
+63
View File
@@ -0,0 +1,63 @@
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { handleConfig } from './config-subcommand';
import { handleDown } from './down-subcommand';
import { showHelp } from './help-subcommand';
import { handleLogs } from './logs-subcommand';
import { handleStatus } from './status-subcommand';
import { handleUp } from './up-subcommand';
import { handleUpdate } from './update-subcommand';
function normalizeLeadingHostArg(args: string[]): string[] {
const firstToken = args[0];
if (!firstToken || (firstToken !== '--host' && !firstToken.startsWith('--host='))) {
return args;
}
const extracted = extractOption(args, ['--host'], {
knownFlags: ['--host', '--help', '-h'],
});
if (extracted.missingValue || !extracted.value?.trim()) {
return args;
}
const command = extracted.remainingArgs[0];
if (!command || command.startsWith('-')) {
return args;
}
return [command, '--host', extracted.value.trim(), ...extracted.remainingArgs.slice(1)];
}
export async function handleDockerCommand(args: string[]): Promise<void> {
const normalizedArgs = normalizeLeadingHostArg(args);
if (hasAnyFlag(normalizedArgs, ['--help', '-h'])) {
await showHelp();
return;
}
const command = normalizedArgs[0];
const commandHandlers: Record<string, (subArgs: string[]) => Promise<void>> = {
up: handleUp,
down: handleDown,
status: handleStatus,
update: handleUpdate,
logs: handleLogs,
config: handleConfig,
help: async () => showHelp(),
};
if (!command) {
await showHelp();
return;
}
const handler = commandHandlers[command];
if (!handler) {
await showHelp();
process.exitCode = 1;
return;
}
await handler(normalizedArgs.slice(1));
}
+51
View File
@@ -0,0 +1,51 @@
import { DockerExecutor } from '../../docker';
import { box, fail, info, initUI } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerLogsOptions } from './options';
const KNOWN_FLAGS = ['--host', '--follow', '--service'] as const;
export async function handleLogs(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerLogsOptions(args, KNOWN_FLAGS);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
knownFlags: ['--follow'],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
const executor = new DockerExecutor();
try {
if (parsed.follow) {
console.log(info(`Following Docker logs${parsed.host ? ` on ${parsed.host}` : ''}...`));
await executor.logs({
host: parsed.host,
follow: true,
service: parsed.service,
});
return;
}
const output = await executor.logs({
host: parsed.host,
follow: false,
service: parsed.service,
});
console.log(output ?? '');
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
}
+135
View File
@@ -0,0 +1,135 @@
import { extractOption, hasAnyFlag, scanCommandArgs } from '../arg-extractor';
import type { DockerLogService } from '../../docker';
export interface ParsedDockerTarget {
errors: string[];
remainingArgs: string[];
host?: string;
}
export interface ParsedDockerUpOptions extends ParsedDockerTarget {
port?: number;
proxyPort?: number;
}
export interface ParsedDockerLogsOptions extends ParsedDockerTarget {
follow: boolean;
service?: DockerLogService;
}
function parseNumberOption(
args: string[],
flag: string,
knownFlags: readonly string[]
): { value?: number; remainingArgs: string[]; error?: string } {
const extracted = extractOption(args, [flag], { knownFlags });
if (!extracted.found) {
return { remainingArgs: args };
}
if (extracted.missingValue || !extracted.value) {
return { remainingArgs: extracted.remainingArgs, error: `Missing value for ${flag}` };
}
const value = Number.parseInt(extracted.value, 10);
if (Number.isNaN(value) || value <= 0 || value >= 65536) {
return { remainingArgs: extracted.remainingArgs, error: `Invalid value for ${flag}` };
}
return { value, remainingArgs: extracted.remainingArgs };
}
export function parseDockerTarget(
args: string[],
knownFlags: readonly string[]
): ParsedDockerTarget {
const extracted = extractOption(args, ['--host'], { knownFlags });
if (!extracted.found) {
return { errors: [], remainingArgs: args };
}
if (extracted.missingValue || !extracted.value?.trim()) {
return { errors: ['Missing value for --host'], remainingArgs: extracted.remainingArgs };
}
const host = extracted.value.trim();
if (host.startsWith('-') || /\s/.test(host)) {
return {
errors: [
'Invalid value for --host. Use a single SSH target or SSH config alias such as my-box or user@host.',
],
remainingArgs: extracted.remainingArgs,
};
}
return {
errors: [],
remainingArgs: extracted.remainingArgs,
host,
};
}
export function parseDockerUpOptions(
args: string[],
knownFlags: readonly string[]
): ParsedDockerUpOptions {
const target = parseDockerTarget(args, knownFlags);
const port = parseNumberOption(target.remainingArgs, '--port', knownFlags);
const proxyPort = parseNumberOption(port.remainingArgs, '--proxy-port', knownFlags);
const errors = [...target.errors];
if (port.error) {
errors.push(port.error);
}
if (proxyPort.error) {
errors.push(proxyPort.error);
}
return {
...target,
remainingArgs: proxyPort.remainingArgs,
port: port.value,
proxyPort: proxyPort.value,
errors,
};
}
export function parseDockerLogsOptions(
args: string[],
knownFlags: readonly string[]
): ParsedDockerLogsOptions {
const target = parseDockerTarget(args, knownFlags);
const service = extractOption(target.remainingArgs, ['--service'], { knownFlags });
let parsedService: DockerLogService | undefined;
const errors = [...target.errors];
if (service.found) {
if (service.missingValue || !service.value) {
errors.push('Missing value for --service');
} else if (service.value !== 'ccs' && service.value !== 'cliproxy') {
errors.push('Invalid value for --service. Use: ccs or cliproxy');
} else {
parsedService = service.value;
}
}
return {
...target,
remainingArgs: service.remainingArgs,
follow: hasAnyFlag(service.remainingArgs, ['--follow']),
service: parsedService,
errors,
};
}
export function collectUnexpectedDockerArgs(
args: string[],
options: {
knownFlags: readonly string[];
valueFlags?: readonly string[];
maxPositionals?: number;
}
): string[] {
const scanned = scanCommandArgs(args, {
knownFlags: options.knownFlags,
valueFlags: options.valueFlags,
});
const errors = scanned.unknownFlags.map((flag) => `Unknown option: ${flag}`);
const maxPositionals = options.maxPositionals ?? 0;
if (scanned.positionals.length > maxPositionals) {
errors.push(`Unexpected arguments: ${scanned.positionals.slice(maxPositionals).join(' ')}`);
}
return errors;
}
+56
View File
@@ -0,0 +1,56 @@
import { DockerExecutor } from '../../docker';
import { box, color, fail, info, initUI, ok, subheader } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host'] as const;
export async function handleStatus(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
try {
const status = await new DockerExecutor().status({ host: parsed.host });
console.log(ok(`Docker status${parsed.host ? ` for ${parsed.host}` : ''}`));
console.log('');
console.log(subheader('Compose:'));
console.log(status.compose.stdout.trim() || info('No docker compose output.'));
if (status.supervisor?.exitCode === 0 && status.supervisor.stdout.trim()) {
console.log('');
console.log(subheader('Supervisor:'));
console.log(status.supervisor.stdout.trim());
} else if (status.supervisor) {
console.log('');
console.log(subheader('Supervisor:'));
const detail = (status.supervisor.stderr || status.supervisor.stdout).trim();
console.log(
info(
`Supervisor status check failed for ${color('ccs-cliproxy', 'command')}.\n${detail || 'No additional detail provided.'}`
)
);
} else {
console.log('');
console.log(info(`Supervisor status unavailable for ${color('ccs-cliproxy', 'command')}.`));
}
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
}
+49
View File
@@ -0,0 +1,49 @@
import {
DOCKER_DEFAULT_DASHBOARD_PORT,
DOCKER_DEFAULT_PROXY_PORT,
DockerExecutor,
} from '../../docker';
import { box, fail, info, initUI, ok } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerUpOptions } from './options';
const KNOWN_FLAGS = ['--host', '--port', '--proxy-port'] as const;
export async function handleUp(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerUpOptions(args, KNOWN_FLAGS);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
const executor = new DockerExecutor();
const port = parsed.port ?? DOCKER_DEFAULT_DASHBOARD_PORT;
const proxyPort = parsed.proxyPort ?? DOCKER_DEFAULT_PROXY_PORT;
console.log(
info(`Starting integrated Docker stack${parsed.host ? ` on ${parsed.host}` : ''}...`)
);
try {
await executor.up({ host: parsed.host, port, proxyPort });
console.log(ok(`Docker stack is running${parsed.host ? ` on ${parsed.host}` : ' locally'}.`));
console.log(info(`Dashboard port: ${port}`));
console.log(info(`CLIProxy port: ${proxyPort}`));
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { DockerExecutor } from '../../docker';
import { box, fail, info, initUI, ok } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host'] as const;
export async function handleUpdate(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
console.log(info(`Updating running Docker stack${parsed.host ? ` on ${parsed.host}` : ''}...`));
try {
await new DockerExecutor().update({ host: parsed.host });
console.log(ok(`Docker stack updated${parsed.host ? ` on ${parsed.host}` : ''}.`));
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
}
+14
View File
@@ -470,6 +470,20 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
writeLine
);
printSubSection(
'Docker Deployment',
[
['ccs docker --help', 'Manage the integrated CCS + CLIProxy Docker stack'],
['ccs docker up', 'Build and start the stack locally'],
['ccs docker up --host <ssh>', 'Stage assets to ~/.ccs/docker and deploy remotely'],
['ccs docker status', 'Show docker compose and supervisor status'],
['ccs docker logs --follow', 'Tail combined CCS + CLIProxy logs'],
['ccs docker update', 'Update CCS and CLIProxy inside the running container'],
['ccs docker config', 'Show bundled asset paths and deployment defaults'],
],
writeLine
);
// CLI Proxy configuration flags (new)
printSubSection(
'CLI Proxy Configuration',
+1
View File
@@ -9,6 +9,7 @@ export { handleConfigCommand } from './config-command';
export { handleConfigImageAnalysisCommand } from './config-image-analysis-command';
export { handleCopilotCommand } from './copilot-command';
export { handleDoctorCommand } from './doctor-command';
export { handleDockerCommand } from './docker-command';
export { handleHelpCommand } from './help-command';
export { handleInstallCommand } from './install-command';
export { handleMigrateCommand } from './migrate-command';
+7
View File
@@ -129,6 +129,13 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleCliproxyCommand(args);
},
},
{
name: 'docker',
handle: async (args) => {
const { handleDockerCommand } = await import('./docker-command');
await handleDockerCommand(args);
},
},
{
name: 'config',
handle: async (args) => {
+84
View File
@@ -0,0 +1,84 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
import type { DockerConfigSummary } from './docker-types';
export const DOCKER_REMOTE_DIR = '~/.ccs/docker';
export const DOCKER_COMPOSE_SERVICE = 'ccs-cliproxy';
export const DOCKER_CONTAINER_NAME = 'ccs-cliproxy';
export const DOCKER_DEFAULT_DASHBOARD_PORT = 3000;
export const DOCKER_DEFAULT_PROXY_PORT = 8317;
export const DOCKER_LOG_FILES = {
ccs: '/var/log/ccs/ccs-dashboard.log',
cliproxy: '/var/log/ccs/cliproxy.log',
} as const;
export interface DockerAssetPaths {
dockerDir: string;
composeFile: string;
dockerfile: string;
supervisordConfig: string;
entrypoint: string;
}
function getPackageRoot(): string {
return path.resolve(__dirname, '..', '..');
}
export function getDockerAssetPaths(): DockerAssetPaths {
const packageRoot = getPackageRoot();
const dockerDir = path.join(packageRoot, 'docker');
const assets: DockerAssetPaths = {
dockerDir,
composeFile: path.join(dockerDir, 'docker-compose.integrated.yml'),
dockerfile: path.join(dockerDir, 'Dockerfile.integrated'),
supervisordConfig: path.join(dockerDir, 'supervisord.conf'),
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.`
);
}
}
return assets;
}
export function getInstalledCcsVersion(): string {
const packageJsonPath = path.join(getPackageRoot(), 'package.json');
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
version?: string;
};
return packageJson.version?.trim() || 'latest';
} catch {
return 'latest';
}
}
export function createDockerConfigSummary(options: {
host?: string;
port?: number;
proxyPort?: number;
}): DockerConfigSummary {
const assets = getDockerAssetPaths();
return {
host: options.host,
remote: Boolean(options.host),
ccsDir: getCcsDir(),
dockerDir: assets.dockerDir,
composeFile: assets.composeFile,
dockerfile: assets.dockerfile,
supervisordConfig: assets.supervisordConfig,
entrypoint: assets.entrypoint,
remoteDeployDir: DOCKER_REMOTE_DIR,
composeService: DOCKER_COMPOSE_SERVICE,
containerName: DOCKER_CONTAINER_NAME,
dashboardPort: options.port ?? DOCKER_DEFAULT_DASHBOARD_PORT,
proxyPort: options.proxyPort ?? DOCKER_DEFAULT_PROXY_PORT,
};
}
+60
View File
@@ -0,0 +1,60 @@
import { spawn } from 'child_process';
import { ensureCLIProxyBinary } from '../cliproxy/binary-manager';
import {
configExists,
configNeedsRegeneration,
generateConfig,
getCliproxyWritablePath,
regenerateConfig,
} from '../cliproxy/config-generator';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getCliproxyConfigPath } from '../cliproxy/config/path-resolver';
async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configPath: string }> {
const binaryPath = await ensureCLIProxyBinary(false);
const configPath = !configExists(CLIPROXY_DEFAULT_PORT)
? generateConfig('gemini', CLIPROXY_DEFAULT_PORT)
: configNeedsRegeneration()
? regenerateConfig(CLIPROXY_DEFAULT_PORT)
: getCliproxyConfigPath();
return { binaryPath, configPath };
}
async function runCliproxy(): Promise<void> {
const { binaryPath, configPath } = await prepareIntegratedRuntime();
await new Promise<void>((resolve, reject) => {
const child = spawn(binaryPath, ['--config', configPath], {
stdio: 'inherit',
env: {
...process.env,
WRITABLE_PATH: getCliproxyWritablePath(),
},
});
child.on('error', reject);
child.on('close', (code) => {
process.exit(code ?? 1);
});
child.on('spawn', () => resolve());
});
}
async function main(): Promise<void> {
const command = process.argv[2];
if (command !== 'run-cliproxy') {
console.error('[X] Usage: node dist/docker/docker-bootstrap.js run-cliproxy');
process.exit(1);
}
await 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);
});
}
+294
View File
@@ -0,0 +1,294 @@
import { spawn, spawnSync } from 'child_process';
import * as path from 'path';
import {
DOCKER_CONTAINER_NAME,
DOCKER_LOG_FILES,
DOCKER_REMOTE_DIR,
type DockerAssetPaths,
createDockerConfigSummary,
getInstalledCcsVersion,
getDockerAssetPaths,
} from './docker-assets';
import type {
DockerCommandResult,
DockerCommandTarget,
DockerConfigSummary,
DockerLogsOptions,
DockerStatusResult,
DockerUpOptions,
} from './docker-types';
function quotePosix(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
function renderCommand(command: string, args: string[]): string {
return [command, ...args.map((arg) => quotePosix(arg))].join(' ');
}
function normalizeOutput(value: string | Buffer | null | undefined): string {
if (typeof value === 'string') return value;
return value ? value.toString('utf8') : '';
}
interface DockerSyncRunOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
remote?: boolean;
}
function runSync(
command: string,
args: string[],
options: DockerSyncRunOptions = {}
): DockerCommandResult {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: 'utf8',
stdio: 'pipe',
windowsHide: true,
});
const stderr = [normalizeOutput(result.stderr), result.error?.message].filter(Boolean).join('\n');
return {
command: renderCommand(command, args),
exitCode: result.status ?? 1,
stdout: normalizeOutput(result.stdout),
stderr,
remote: options.remote ?? false,
};
}
function runStreaming(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
windowsHide: true,
});
child.on('error', reject);
child.on('close', (code) => {
if ((code ?? 1) === 0) {
resolve();
return;
}
reject(new Error(`Command failed (${code ?? 1}): ${renderCommand(command, args)}`));
});
});
}
function resolveLocalComposePrefix(): string[] {
if (runSync('docker', ['compose', 'version']).exitCode === 0) {
return ['docker', 'compose'];
}
if (runSync('docker-compose', ['version']).exitCode === 0) {
return ['docker-compose'];
}
throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.');
}
function buildRemoteComposeCommand(composeArgs: string[]): string {
const suffix = composeArgs.map((arg) => quotePosix(arg)).join(' ');
return [
'if docker compose version >/dev/null 2>&1; then',
`docker compose ${suffix};`,
'elif docker-compose version >/dev/null 2>&1; then',
`docker-compose ${suffix};`,
'else',
"echo 'Docker Compose is not available on the remote host.' >&2;",
'exit 127;',
'fi',
].join(' ');
}
function buildRemoteDockerCommand(args: string[]): string {
return ['docker', ...args.map((arg) => quotePosix(arg))].join(' ');
}
interface DockerExecutorDeps {
assets?: DockerAssetPaths;
getInstalledCcsVersion?: () => string;
resolveLocalComposePrefix?: () => string[];
runSync?: (
command: string,
args: string[],
options?: DockerSyncRunOptions
) => DockerCommandResult;
runStreaming?: (command: string, args: string[]) => Promise<void>;
}
export class DockerExecutor {
private readonly assets: DockerAssetPaths;
constructor(private readonly deps: DockerExecutorDeps = {}) {
this.assets = deps.assets ?? getDockerAssetPaths();
}
getConfig(options: { host?: string; port?: number; proxyPort?: number }): DockerConfigSummary {
return createDockerConfigSummary(options);
}
async up(options: DockerUpOptions): Promise<void> {
if (options.host) {
this.stageRemoteAssets(options.host);
}
this.ensureSuccess(
this.runCompose(['up', '-d', '--build'], options, {
CCS_NPM_VERSION: this.getInstalledCcsVersion(),
CCS_DASHBOARD_PORT: String(options.port),
CCS_CLIPROXY_PORT: String(options.proxyPort),
}),
'Docker stack startup',
options
);
}
async down(options: DockerCommandTarget): Promise<void> {
this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options);
}
async status(options: DockerCommandTarget): Promise<DockerStatusResult> {
const compose = this.runCompose(['ps'], options);
this.ensureSuccess(compose, 'Docker status', options);
let supervisor: DockerCommandResult | undefined;
if (compose.exitCode === 0) {
supervisor = this.runDocker(
['exec', DOCKER_CONTAINER_NAME, 'supervisorctl', '-c', '/etc/supervisord.conf', 'status'],
options
);
}
return { compose, supervisor };
}
async update(options: DockerCommandTarget): Promise<void> {
const script =
'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy';
this.ensureSuccess(
this.runDocker(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', script], options),
'Docker stack update',
options
);
}
async logs(options: DockerLogsOptions): Promise<string | void> {
const files = options.service
? [DOCKER_LOG_FILES[options.service]]
: [DOCKER_LOG_FILES.ccs, DOCKER_LOG_FILES.cliproxy];
const touch = `mkdir -p /var/log/ccs && touch ${files.map((file) => quotePosix(file)).join(' ')}`;
const command = options.follow
? `${touch} && tail -n 100 -F ${files.map((file) => quotePosix(file)).join(' ')}`
: options.service
? `${touch} && tail -n 100 ${quotePosix(files[0])}`
: `${touch} && printf '== ccs ==\\n' && tail -n 100 ${quotePosix(
DOCKER_LOG_FILES.ccs
)} && printf '\\n== cliproxy ==\\n' && tail -n 100 ${quotePosix(DOCKER_LOG_FILES.cliproxy)}`;
if (options.follow) {
await this.runDockerStreaming(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', command], options);
return;
}
const result = this.runDocker(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', command], options);
this.ensureSuccess(result, 'Docker log retrieval', options);
return result.stdout;
}
private stageRemoteAssets(host: string): void {
this.ensureSuccess(
this.runSync('ssh', [host, `mkdir -p ${DOCKER_REMOTE_DIR}`], { remote: true }),
'Remote Docker asset staging',
{ host }
);
const files = [
this.assets.composeFile,
this.assets.dockerfile,
this.assets.supervisordConfig,
this.assets.entrypoint,
];
const target = `${host}:${DOCKER_REMOTE_DIR}/`;
this.ensureSuccess(
this.runSync('scp', [...files, target], { remote: true }),
'Remote Docker asset copy',
{
host,
}
);
}
private runCompose(
args: string[],
options: DockerCommandTarget,
env: Record<string, string> = {}
): DockerCommandResult {
if (!options.host) {
const prefix = this.resolveLocalComposePrefix();
const command = prefix[0];
const composeArgs = [...prefix.slice(1), '-f', this.assets.composeFile, ...args];
return this.runSync(command, composeArgs, {
cwd: path.dirname(this.assets.composeFile),
env: { ...process.env, ...env },
remote: false,
});
}
const envPrefix = Object.entries(env)
.map(([key, value]) => `${key}=${quotePosix(value)}`)
.join(' ');
const composeArgs = ['-f', path.basename(this.assets.composeFile), ...args];
const remoteCommand = `cd ${DOCKER_REMOTE_DIR} && ${envPrefix ? `${envPrefix} ` : ''}${buildRemoteComposeCommand(composeArgs)}`;
return this.runSync('ssh', [options.host, remoteCommand], { remote: true });
}
private runDocker(args: string[], options: DockerCommandTarget): DockerCommandResult {
if (!options.host) {
return this.runSync('docker', args);
}
return this.runSync('ssh', [options.host, buildRemoteDockerCommand(args)], { remote: true });
}
private async runDockerStreaming(args: string[], options: DockerCommandTarget): Promise<void> {
if (!options.host) {
await this.runStreaming('docker', args);
return;
}
await this.runStreaming('ssh', [options.host, buildRemoteDockerCommand(args)]);
}
private getInstalledCcsVersion(): string {
return this.deps.getInstalledCcsVersion?.() ?? getInstalledCcsVersion();
}
private resolveLocalComposePrefix(): string[] {
return this.deps.resolveLocalComposePrefix?.() ?? resolveLocalComposePrefix();
}
private runSync(
command: string,
args: string[],
options: DockerSyncRunOptions = {}
): DockerCommandResult {
return this.deps.runSync?.(command, args, options) ?? runSync(command, args, options);
}
private async runStreaming(command: string, args: string[]): Promise<void> {
await (this.deps.runStreaming?.(command, args) ?? runStreaming(command, args));
}
private ensureSuccess(
result: DockerCommandResult,
label: string,
options: DockerCommandTarget
): void {
if (result.exitCode === 0) {
return;
}
const detail = (result.stderr || result.stdout).trim();
const hint =
options.host && /No such file|no configuration file|can't cd|not found/i.test(detail)
? `\nRun \`ccs docker up --host ${options.host}\` first.`
: '';
throw new Error(`${label} failed.${detail ? `\n${detail}` : ''}${hint}`);
}
}
+44
View File
@@ -0,0 +1,44 @@
export type DockerLogService = 'ccs' | 'cliproxy';
export interface DockerCommandTarget {
host?: string;
}
export interface DockerUpOptions extends DockerCommandTarget {
port: number;
proxyPort: number;
}
export interface DockerLogsOptions extends DockerCommandTarget {
follow: boolean;
service?: DockerLogService;
}
export interface DockerCommandResult {
command: string;
exitCode: number;
stdout: string;
stderr: string;
remote: boolean;
}
export interface DockerStatusResult {
compose: DockerCommandResult;
supervisor?: DockerCommandResult;
}
export interface DockerConfigSummary {
host?: string;
remote: boolean;
ccsDir: string;
dockerDir: string;
composeFile: string;
dockerfile: string;
supervisordConfig: string;
entrypoint: string;
remoteDeployDir: string;
composeService: string;
containerName: string;
dashboardPort: number;
proxyPort: number;
}
+16
View File
@@ -0,0 +1,16 @@
export { DockerExecutor } from './docker-executor';
export {
DOCKER_CONTAINER_NAME,
DOCKER_COMPOSE_SERVICE,
DOCKER_DEFAULT_DASHBOARD_PORT,
DOCKER_DEFAULT_PROXY_PORT,
DOCKER_REMOTE_DIR,
} from './docker-assets';
export type {
DockerCommandTarget,
DockerConfigSummary,
DockerLogsOptions,
DockerLogService,
DockerStatusResult,
DockerUpOptions,
} from './docker-types';
+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,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,75 @@
import { afterEach, beforeEach, describe, expect, it, mock } 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;
mock.restore();
});
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 () => {
mock.module('../../../src/docker', () => ({
DockerExecutor: class {
async status() {
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,
},
};
}
},
}));
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);
});
});
@@ -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})`);
+165
View File
@@ -0,0 +1,165 @@
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;
};
};
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');
});
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 },
});
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 });
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');
});
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');
});
});