From 23f767b0c557affd2cdaaf20226a530db252b558 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 13:57:35 -0400 Subject: [PATCH] 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 --- docker/Dockerfile.integrated | 23 ++ docker/docker-compose.integrated.yml | 32 ++ docker/entrypoint-integrated.sh | 9 + docker/supervisord.conf | 43 +++ package.json | 1 + src/commands/docker-command.ts | 1 + src/commands/docker/config-subcommand.ts | 59 ++++ src/commands/docker/down-subcommand.ts | 37 +++ src/commands/docker/help-subcommand.ts | 66 ++++ src/commands/docker/index.ts | 63 ++++ src/commands/docker/logs-subcommand.ts | 51 +++ src/commands/docker/options.ts | 135 ++++++++ src/commands/docker/status-subcommand.ts | 56 ++++ src/commands/docker/up-subcommand.ts | 49 +++ src/commands/docker/update-subcommand.ts | 37 +++ src/commands/help-command.ts | 14 + src/commands/index.ts | 1 + src/commands/root-command-router.ts | 7 + src/docker/docker-assets.ts | 84 +++++ src/docker/docker-bootstrap.ts | 60 ++++ src/docker/docker-executor.ts | 294 ++++++++++++++++++ src/docker/docker-types.ts | 44 +++ src/docker/index.ts | 16 + tests/unit/commands/docker-command.test.ts | 107 +++++++ tests/unit/commands/docker-options.test.ts | 52 ++++ .../commands/docker-status-subcommand.test.ts | 75 +++++ .../unit/commands/help-command-parity.test.ts | 10 + .../unit/commands/root-command-router.test.ts | 16 + tests/unit/docker/docker-executor.test.ts | 165 ++++++++++ 29 files changed, 1607 insertions(+) create mode 100644 docker/Dockerfile.integrated create mode 100644 docker/docker-compose.integrated.yml create mode 100644 docker/entrypoint-integrated.sh create mode 100644 docker/supervisord.conf create mode 100644 src/commands/docker-command.ts create mode 100644 src/commands/docker/config-subcommand.ts create mode 100644 src/commands/docker/down-subcommand.ts create mode 100644 src/commands/docker/help-subcommand.ts create mode 100644 src/commands/docker/index.ts create mode 100644 src/commands/docker/logs-subcommand.ts create mode 100644 src/commands/docker/options.ts create mode 100644 src/commands/docker/status-subcommand.ts create mode 100644 src/commands/docker/up-subcommand.ts create mode 100644 src/commands/docker/update-subcommand.ts create mode 100644 src/docker/docker-assets.ts create mode 100644 src/docker/docker-bootstrap.ts create mode 100644 src/docker/docker-executor.ts create mode 100644 src/docker/docker-types.ts create mode 100644 src/docker/index.ts create mode 100644 tests/unit/commands/docker-command.test.ts create mode 100644 tests/unit/commands/docker-options.test.ts create mode 100644 tests/unit/commands/docker-status-subcommand.test.ts create mode 100644 tests/unit/docker/docker-executor.test.ts diff --git a/docker/Dockerfile.integrated b/docker/Dockerfile.integrated new file mode 100644 index 00000000..ff337726 --- /dev/null +++ b/docker/Dockerfile.integrated @@ -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"] diff --git a/docker/docker-compose.integrated.yml b/docker/docker-compose.integrated.yml new file mode 100644 index 00000000..1984d14a --- /dev/null +++ b/docker/docker-compose.integrated.yml @@ -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: diff --git a/docker/entrypoint-integrated.sh b/docker/entrypoint-integrated.sh new file mode 100644 index 00000000..214674e0 --- /dev/null +++ b/docker/entrypoint-integrated.sh @@ -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 diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 00000000..c5ecfb18 --- /dev/null +++ b/docker/supervisord.conf @@ -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" diff --git a/package.json b/package.json index 65fb00b2..a286dd2f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "files": [ "dist/", + "docker/", "lib/", "scripts/", "config/", diff --git a/src/commands/docker-command.ts b/src/commands/docker-command.ts new file mode 100644 index 00000000..9de76f4d --- /dev/null +++ b/src/commands/docker-command.ts @@ -0,0 +1 @@ +export { handleDockerCommand } from './docker/index'; diff --git a/src/commands/docker/config-subcommand.ts b/src/commands/docker/config-subcommand.ts new file mode 100644 index 00000000..fcc8c6a2 --- /dev/null +++ b/src/commands/docker/config-subcommand.ts @@ -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 { + 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; + } +} diff --git a/src/commands/docker/down-subcommand.ts b/src/commands/docker/down-subcommand.ts new file mode 100644 index 00000000..09369397 --- /dev/null +++ b/src/commands/docker/down-subcommand.ts @@ -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 { + 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; + } +} diff --git a/src/commands/docker/help-subcommand.ts b/src/commands/docker/help-subcommand.ts new file mode 100644 index 00000000..c7c3bdc3 --- /dev/null +++ b/src/commands/docker/help-subcommand.ts @@ -0,0 +1,66 @@ +import { color, dim, header, initUI, subheader } from '../../utils/ui'; + +export async function showHelp(): Promise { + await initUI(); + console.log(''); + console.log(header('Docker Deployment')); + console.log(''); + console.log(subheader('Usage:')); + console.log(` ${color('ccs docker', '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 ', 'Run command on a remote host over SSH (single target or SSH alias)'], + ['--help, -h', 'Show this help message'], + ], + ], + [ + 'Per-Command Options:', + [ + ['up --port ', 'Publish dashboard on a custom host port'], + ['up --proxy-port ', 'Publish CLIProxy on a custom host port'], + ['logs --follow', 'Stream logs continuously'], + ['logs --service ', '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(''); +} diff --git a/src/commands/docker/index.ts b/src/commands/docker/index.ts new file mode 100644 index 00000000..7ba3b882 --- /dev/null +++ b/src/commands/docker/index.ts @@ -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 { + const normalizedArgs = normalizeLeadingHostArg(args); + + if (hasAnyFlag(normalizedArgs, ['--help', '-h'])) { + await showHelp(); + return; + } + + const command = normalizedArgs[0]; + const commandHandlers: Record Promise> = { + 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)); +} diff --git a/src/commands/docker/logs-subcommand.ts b/src/commands/docker/logs-subcommand.ts new file mode 100644 index 00000000..3d178b10 --- /dev/null +++ b/src/commands/docker/logs-subcommand.ts @@ -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 { + 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; + } +} diff --git a/src/commands/docker/options.ts b/src/commands/docker/options.ts new file mode 100644 index 00000000..0ec9566d --- /dev/null +++ b/src/commands/docker/options.ts @@ -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; +} diff --git a/src/commands/docker/status-subcommand.ts b/src/commands/docker/status-subcommand.ts new file mode 100644 index 00000000..b2630402 --- /dev/null +++ b/src/commands/docker/status-subcommand.ts @@ -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 { + 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; + } +} diff --git a/src/commands/docker/up-subcommand.ts b/src/commands/docker/up-subcommand.ts new file mode 100644 index 00000000..0ee8bc60 --- /dev/null +++ b/src/commands/docker/up-subcommand.ts @@ -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 { + 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; + } +} diff --git a/src/commands/docker/update-subcommand.ts b/src/commands/docker/update-subcommand.ts new file mode 100644 index 00000000..fb186b3f --- /dev/null +++ b/src/commands/docker/update-subcommand.ts @@ -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 { + 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; + } +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index e3bef4e6..6eca9227 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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 ', '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', diff --git a/src/commands/index.ts b/src/commands/index.ts index d6d6f2c2..22d62035 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -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'; diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index 202878d5..98f4a178 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -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) => { diff --git a/src/docker/docker-assets.ts b/src/docker/docker-assets.ts new file mode 100644 index 00000000..85edbf26 --- /dev/null +++ b/src/docker/docker-assets.ts @@ -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, + }; +} diff --git a/src/docker/docker-bootstrap.ts b/src/docker/docker-bootstrap.ts new file mode 100644 index 00000000..108d3f25 --- /dev/null +++ b/src/docker/docker-bootstrap.ts @@ -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 { + const { binaryPath, configPath } = await prepareIntegratedRuntime(); + await new Promise((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 { + 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); + }); +} diff --git a/src/docker/docker-executor.ts b/src/docker/docker-executor.ts new file mode 100644 index 00000000..72168fa0 --- /dev/null +++ b/src/docker/docker-executor.ts @@ -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 { + 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; +} + +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 { + 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 { + this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options); + } + + async status(options: DockerCommandTarget): Promise { + 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 { + 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 { + 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 = {} + ): 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 { + 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 { + 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}`); + } +} diff --git a/src/docker/docker-types.ts b/src/docker/docker-types.ts new file mode 100644 index 00000000..47d47068 --- /dev/null +++ b/src/docker/docker-types.ts @@ -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; +} diff --git a/src/docker/index.ts b/src/docker/index.ts new file mode 100644 index 00000000..7f2da074 --- /dev/null +++ b/src/docker/index.ts @@ -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'; diff --git a/tests/unit/commands/docker-command.test.ts b/tests/unit/commands/docker-command.test.ts new file mode 100644 index 00000000..0cc55f38 --- /dev/null +++ b/tests/unit/commands/docker-command.test.ts @@ -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); + }); +}); diff --git a/tests/unit/commands/docker-options.test.ts b/tests/unit/commands/docker-options.test.ts new file mode 100644 index 00000000..c95f5564 --- /dev/null +++ b/tests/unit/commands/docker-options.test.ts @@ -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']); + }); +}); diff --git a/tests/unit/commands/docker-status-subcommand.test.ts b/tests/unit/commands/docker-status-subcommand.test.ts new file mode 100644 index 00000000..a7e65ba1 --- /dev/null +++ b/tests/unit/commands/docker-status-subcommand.test.ts @@ -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); + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 512bb5f8..04efd788 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -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 ')).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)); diff --git a/tests/unit/commands/root-command-router.test.ts b/tests/unit/commands/root-command-router.test.ts index ef09cf7c..50314f95 100644 --- a/tests/unit/commands/root-command-router.test.ts +++ b/tests/unit/commands/root-command-router.test.ts @@ -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})`); diff --git a/tests/unit/docker/docker-executor.test.ts b/tests/unit/docker/docker-executor.test.ts new file mode 100644 index 00000000..9a82e0e2 --- /dev/null +++ b/tests/unit/docker/docker-executor.test.ts @@ -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'); + }); +});