diff --git a/CLAUDE.md b/CLAUDE.md index 41b8334e..6cdab761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | | `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | +| `ccs docker --help` | `src/commands/docker/help-subcommand.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | | `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | | `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` | 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/README.md b/docker/README.md index d6fd0d0f..eecd14ea 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,10 +1,10 @@
-# CCS Dashboard - Docker +# CCS Docker Deployment ![CCS Logo](../assets/ccs-logo-medium.png) -### Run the CCS Config Dashboard in Docker. +### Run CCS in Docker, locally or over SSH. Persistent config, restart on reboot. **[Back to README](../README.md)** @@ -13,7 +13,43 @@ Persistent config, restart on reboot.
-## Quick Start (Prebuilt Image) +## Preferred: `ccs docker` + +The CLI now ships a first-class Docker command suite for the integrated CCS + CLIProxy stack: + +```bash +ccs docker up +ccs docker status +ccs docker logs --follow +ccs docker config +ccs docker update +ccs docker down +``` + +Remote deployment stages the bundled Docker assets to `~/.ccs/docker` on the target host: + +```bash +ccs docker up --host my-server +ccs docker --host my-server status +ccs docker status --host my-server +ccs docker logs --host my-server --service ccs --follow +ccs docker config --host my-server +``` + +Use a single SSH target or SSH config alias for `--host`. If you need custom SSH flags such as a port override, configure them in `~/.ssh/config` and reference the alias from `ccs docker`. + +The `ccs docker` flow uses the integrated assets in this directory: + +- `docker/Dockerfile.integrated` +- `docker/docker-compose.integrated.yml` +- `docker/supervisord.conf` +- `docker/entrypoint-integrated.sh` + +## Prebuilt Image Quick Start + +This existing image still runs the CCS dashboard and its locally managed CLIProxy inside one +container. It does not provide the remote staging and in-container self-update flow exposed by +`ccs docker`. Pull the latest stable release image from GitHub Container Registry: @@ -30,7 +66,7 @@ docker run -d \ Release-tag images are also published as `ghcr.io/kaitranntt/ccs-dashboard:`. -## Build Locally +## Prebuilt Image Build Locally ```bash docker build -f docker/Dockerfile -t ccs-dashboard:latest . @@ -91,7 +127,7 @@ docker start ccs-dashboard docker rm -f ccs-dashboard ``` -## Docker Compose (Optional) +## Prebuilt Image Docker Compose (Optional) Using the included `docker/docker-compose.yml`: @@ -106,6 +142,8 @@ Stop: docker-compose -f docker/docker-compose.yml down ``` +For the integrated CCS + CLIProxy stack managed by the CLI, use `ccs docker up` instead. + ## Persistence - CCS stores data in `/home/node/.ccs` inside the container. 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/docs/project-roadmap.md b/docs/project-roadmap.md index 80526d0c..7090a9a5 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -42,6 +42,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes - **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. +- **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts. - **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected. - **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes. - **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests. diff --git a/package.json b/package.json index 60c01626..4f77ce9f 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..1ebf58aa --- /dev/null +++ b/src/docker/docker-assets.ts @@ -0,0 +1,102 @@ +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; +} + +let hasWarnedAboutVersionFallback = false; + +function getPackageRoot(): string { + return path.resolve(__dirname, '..', '..'); +} + +function ensureBundledAsset(assetPath: string): void { + try { + require.resolve(assetPath); + } catch { + throw new Error( + `Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.` + ); + } +} + +function warnAboutVersionFallback(error: unknown): void { + if (hasWarnedAboutVersionFallback) { + return; + } + hasWarnedAboutVersionFallback = true; + const detail = error instanceof Error ? error.message : String(error); + console.error( + `[!] Failed to determine the installed CCS version from package metadata; falling back to latest.\n${detail}` + ); +} + +export function getDockerAssetPaths(): DockerAssetPaths { + const packageRoot = getPackageRoot(); + const dockerDir = path.join(packageRoot, 'docker'); + 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'), + }; + + ensureBundledAsset(assets.composeFile); + ensureBundledAsset(assets.dockerfile); + ensureBundledAsset(assets.supervisordConfig); + ensureBundledAsset(assets.entrypoint); + + return assets; +} + +export function getInstalledCcsVersion(): string { + const packageJsonPath = path.join(getPackageRoot(), 'package.json'); + try { + const packageJson = require(packageJsonPath) as { version?: string }; + return packageJson.version?.trim() || 'latest'; + } catch (error) { + warnAboutVersionFallback(error); + 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..5b381239 --- /dev/null +++ b/src/docker/docker-bootstrap.ts @@ -0,0 +1,63 @@ +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(); + return 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) => { + resolve(code ?? 1); + }); + }); +} + +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'); + return 1; + } + + return runCliproxy(); +} + +if (require.main === module) { + void main() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((error) => { + console.error( + `[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}` + ); + process.exitCode = 1; + }); +} diff --git a/src/docker/docker-executor.ts b/src/docker/docker-executor.ts new file mode 100644 index 00000000..e04a0e01 --- /dev/null +++ b/src/docker/docker-executor.ts @@ -0,0 +1,358 @@ +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'; + +const LOCAL_DOCKER_SYNC_TIMEOUT_MS = 10_000; +const REMOTE_DOCKER_SYNC_TIMEOUT_MS = 30_000; + +function quotePosix(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +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; + timeoutMs?: number; +} + +function formatTimeout(timeoutMs: number): string { + return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`; +} + +function buildTimeoutMessage( + command: string, + args: string[], + timeoutMs: number, + remote: boolean +): string { + return [ + `Command timed out after ${formatTimeout(timeoutMs)} while running a ${ + remote ? 'remote' : 'local' + } Docker command.`, + `Command: ${renderCommand(command, args)}`, + remote + ? 'Check SSH reachability and the remote Docker host, then try again.' + : 'Check Docker availability on this machine, then try again.', + ].join('\n'); +} + +function applyDefaultTimeouts(options: DockerSyncRunOptions): DockerSyncRunOptions { + return { + ...options, + timeoutMs: + options.timeoutMs ?? + (options.remote ? REMOTE_DOCKER_SYNC_TIMEOUT_MS : LOCAL_DOCKER_SYNC_TIMEOUT_MS), + }; +} + +function runSync( + command: string, + args: string[], + options: DockerSyncRunOptions = {} +): DockerCommandResult { + const normalizedOptions = applyDefaultTimeouts(options); + const result = spawnSync(command, args, { + cwd: normalizedOptions.cwd, + env: normalizedOptions.env, + encoding: 'utf8', + stdio: 'pipe', + timeout: normalizedOptions.timeoutMs, + windowsHide: true, + }); + const errorMessage = + result.error && 'code' in result.error && result.error.code === 'ETIMEDOUT' + ? buildTimeoutMessage( + command, + args, + normalizedOptions.timeoutMs ?? LOCAL_DOCKER_SYNC_TIMEOUT_MS, + normalizedOptions.remote ?? false + ) + : result.error?.message; + const stderr = [normalizeOutput(result.stderr), errorMessage].filter(Boolean).join('\n'); + + return { + command: renderCommand(command, args), + exitCode: result.status ?? 1, + stdout: normalizeOutput(result.stdout), + stderr, + remote: normalizedOptions.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)}`)); + }); + }); +} + +let cachedLocalComposePrefix: string[] | undefined; + +function resolveLocalComposePrefix(): string[] { + if (cachedLocalComposePrefix) { + return [...cachedLocalComposePrefix]; + } + if ( + runSync('docker', ['compose', 'version'], { + timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS, + }).exitCode === 0 + ) { + cachedLocalComposePrefix = ['docker', 'compose']; + return [...cachedLocalComposePrefix]; + } + if ( + runSync('docker-compose', ['version'], { + timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS, + }).exitCode === 0 + ) { + cachedLocalComposePrefix = ['docker-compose']; + return [...cachedLocalComposePrefix]; + } + throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.'); +} + +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); + } + + up(options: DockerUpOptions): 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 + ); + } + + down(options: DockerCommandTarget): void { + this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options); + } + + status(options: DockerCommandTarget): 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 }; + } + + update(options: DockerCommandTarget): void { + const script = + 'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy'; + this.ensureSuccess( + 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 { + const normalizedOptions = applyDefaultTimeouts(options); + return ( + this.deps.runSync?.(command, args, normalizedOptions) ?? + runSync(command, args, normalizedOptions) + ); + } + + 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-config-subcommand.test.ts b/tests/unit/commands/docker-config-subcommand.test.ts new file mode 100644 index 00000000..e71afd2d --- /dev/null +++ b/tests/unit/commands/docker-config-subcommand.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleConfig() { + const mod = await import( + `../../../src/commands/docker/config-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleConfig; +} + +describe('docker config subcommand', () => { + it('prints the derived Docker configuration table', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig; + dockerModule.DockerExecutor.prototype.getConfig = function () { + return { + host: 'docker-box', + remote: true, + ccsDir: '/tmp/.ccs', + dockerDir: '/tmp/docker', + composeFile: '/tmp/docker/docker-compose.integrated.yml', + dockerfile: '/tmp/docker/Dockerfile.integrated', + supervisordConfig: '/tmp/docker/supervisord.conf', + entrypoint: '/tmp/docker/entrypoint-integrated.sh', + remoteDeployDir: '~/.ccs/docker', + composeService: 'ccs-cliproxy', + containerName: 'ccs-cliproxy', + dashboardPort: 3000, + proxyPort: 8317, + }; + }; + + try { + const handleConfig = await loadHandleConfig(); + await handleConfig(['--host', 'docker-box']); + + const rendered = renderCapturedLines(capture.logLines); + expect(rendered).toContain('remote (docker-box)'); + expect(rendered).toContain('/tmp/docker/docker-compose.integrated.yml'); + expect(rendered).toContain('ccs-cliproxy'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig; + } + }); + + it('renders thrown config errors as boxed failures', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalGetConfig = dockerModule.DockerExecutor.prototype.getConfig; + dockerModule.DockerExecutor.prototype.getConfig = function () { + throw new Error('Missing bundled Docker asset: /tmp/docker/Dockerfile.integrated'); + }; + + try { + const handleConfig = await loadHandleConfig(); + await handleConfig([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Missing bundled Docker asset'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.getConfig = originalGetConfig; + } + }); +}); diff --git a/tests/unit/commands/docker-down-subcommand.test.ts b/tests/unit/commands/docker-down-subcommand.test.ts new file mode 100644 index 00000000..d511c5bb --- /dev/null +++ b/tests/unit/commands/docker-down-subcommand.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleDown() { + const mod = await import( + `../../../src/commands/docker/down-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleDown; +} + +describe('docker down subcommand', () => { + it('prints progress and success output for remote shutdowns', async () => { + const calls: Array<{ host?: string }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalDown = dockerModule.DockerExecutor.prototype.down; + dockerModule.DockerExecutor.prototype.down = function (options: { host?: string }) { + calls.push(options); + }; + + try { + const handleDown = await loadHandleDown(); + await handleDown(['--host', 'docker-box']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box' }]); + expect(rendered).toContain('Stopping Docker stack on docker-box...'); + expect(rendered).toContain('Docker stack stopped on docker-box.'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.down = originalDown; + } + }); + + it('renders executor failures as boxed errors', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalDown = dockerModule.DockerExecutor.prototype.down; + dockerModule.DockerExecutor.prototype.down = function () { + throw new Error('Docker stack shutdown failed.\nCommand timed out after 30s.'); + }; + + try { + const handleDown = await loadHandleDown(); + await handleDown([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Docker stack shutdown failed.'); + expect(rendered).toContain('Command timed out after 30s.'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.down = originalDown; + } + }); +}); diff --git a/tests/unit/commands/docker-logs-subcommand.test.ts b/tests/unit/commands/docker-logs-subcommand.test.ts new file mode 100644 index 00000000..ae2bbd7a --- /dev/null +++ b/tests/unit/commands/docker-logs-subcommand.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleLogs() { + const mod = await import( + `../../../src/commands/docker/logs-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleLogs; +} + +describe('docker logs subcommand', () => { + it('prints log snapshots returned by the executor', async () => { + const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalLogs = dockerModule.DockerExecutor.prototype.logs; + dockerModule.DockerExecutor.prototype.logs = async function (options: { + host?: string; + follow: boolean; + service?: 'ccs' | 'cliproxy'; + }) { + calls.push(options); + return '== ccs ==\nready'; + }; + + try { + const handleLogs = await loadHandleLogs(); + await handleLogs(['--service', 'ccs']); + + expect(calls).toEqual([{ follow: false, service: 'ccs' }]); + expect(renderCapturedLines(capture.logLines)).toContain('== ccs ==\nready'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.logs = originalLogs; + } + }); + + it('announces follow mode before streaming logs', async () => { + const calls: Array<{ host?: string; follow: boolean; service?: 'ccs' | 'cliproxy' }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalLogs = dockerModule.DockerExecutor.prototype.logs; + dockerModule.DockerExecutor.prototype.logs = async function (options: { + host?: string; + follow: boolean; + service?: 'ccs' | 'cliproxy'; + }) { + calls.push(options); + }; + + try { + const handleLogs = await loadHandleLogs(); + await handleLogs(['--host', 'docker-box', '--follow', '--service', 'cliproxy']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box', follow: true, service: 'cliproxy' }]); + expect(rendered).toContain('Following Docker logs on docker-box...'); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.logs = originalLogs; + } + }); + + it('renders executor failures as boxed errors', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalLogs = dockerModule.DockerExecutor.prototype.logs; + dockerModule.DockerExecutor.prototype.logs = async function () { + throw new Error('Docker log retrieval failed.\nContainer is not running.'); + }; + + try { + const handleLogs = await loadHandleLogs(); + await handleLogs([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Docker log retrieval failed.'); + expect(rendered).toContain('Container is not running.'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.logs = originalLogs; + } + }); +}); 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..0fca60f7 --- /dev/null +++ b/tests/unit/commands/docker-status-subcommand.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +let logLines: string[] = []; +let errorLines: string[] = []; +let originalConsoleLog: typeof console.log; +let originalConsoleError: typeof console.error; +let originalExitCode: number | undefined; + +beforeEach(() => { + logLines = []; + errorLines = []; + originalConsoleLog = console.log; + originalConsoleError = console.error; + originalExitCode = process.exitCode; + process.exitCode = 0; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + errorLines.push(args.map(String).join(' ')); + }; +}); + +afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + process.exitCode = originalExitCode ?? 0; +}); + +async function loadHandleStatus() { + const mod = await import( + `../../../src/commands/docker/status-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleStatus; +} + +describe('docker status subcommand', () => { + it('prints supervisor failure details instead of masking them as a missing container', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalStatus = dockerModule.DockerExecutor.prototype.status; + dockerModule.DockerExecutor.prototype.status = function () { + return { + compose: { + command: '', + exitCode: 0, + stdout: 'NAME STATUS', + stderr: '', + remote: false, + }, + supervisor: { + command: '', + exitCode: 7, + stdout: '', + stderr: 'unix:///var/run/supervisor.sock no such file', + remote: false, + }, + }; + }; + + try { + const handleStatus = await loadHandleStatus(); + await handleStatus([]); + + const rendered = logLines.join('\n'); + expect(rendered).toContain('Docker status'); + expect(rendered).toContain('Supervisor status check failed'); + expect(rendered).toContain('supervisor.sock no such file'); + expect(rendered).not.toContain('may not be running'); + expect(errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.status = originalStatus; + } + }); +}); diff --git a/tests/unit/commands/docker-subcommand-test-helpers.ts b/tests/unit/commands/docker-subcommand-test-helpers.ts new file mode 100644 index 00000000..6029bf00 --- /dev/null +++ b/tests/unit/commands/docker-subcommand-test-helpers.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, mock } from 'bun:test'; + +export function useDockerSubcommandConsoleCapture(): { + logLines: string[]; + errorLines: string[]; +} { + const state = { + logLines: [] as string[], + errorLines: [] as string[], + }; + + let originalConsoleLog: typeof console.log; + let originalConsoleError: typeof console.error; + let originalExitCode: number | undefined; + + beforeEach(() => { + state.logLines = []; + state.errorLines = []; + originalConsoleLog = console.log; + originalConsoleError = console.error; + originalExitCode = process.exitCode; + process.exitCode = 0; + + console.log = (...args: unknown[]) => { + state.logLines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + state.errorLines.push(args.map(String).join(' ')); + }; + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + process.exitCode = originalExitCode ?? 0; + mock.restore(); + }); + + return state; +} + +export function renderCapturedLines(lines: string[]): string { + return lines.join('\n'); +} diff --git a/tests/unit/commands/docker-up-subcommand.test.ts b/tests/unit/commands/docker-up-subcommand.test.ts new file mode 100644 index 00000000..7816cbc3 --- /dev/null +++ b/tests/unit/commands/docker-up-subcommand.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleUp() { + const mod = await import( + `../../../src/commands/docker/up-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleUp; +} + +describe('docker up subcommand', () => { + it('prints the success summary with the requested host and port mappings', async () => { + const calls: Array<{ host?: string; port: number; proxyPort: number }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalUp = dockerModule.DockerExecutor.prototype.up; + dockerModule.DockerExecutor.prototype.up = function (options: { + host?: string; + port: number; + proxyPort: number; + }) { + calls.push(options); + }; + + try { + const handleUp = await loadHandleUp(); + await handleUp(['--host', 'docker-box', '--port', '4000', '--proxy-port', '9317']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box', port: 4000, proxyPort: 9317 }]); + expect(rendered).toContain('Starting integrated Docker stack on docker-box'); + expect(rendered).toContain('Docker stack is running on docker-box.'); + expect(rendered).toContain('Dashboard port: 4000'); + expect(rendered).toContain('CLIProxy port: 9317'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.up = originalUp; + } + }); + + it('renders boxed validation errors without invoking the executor', async () => { + const handleUp = await loadHandleUp(); + await handleUp(['--port', '70000']); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Invalid value for --port'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/unit/commands/docker-update-subcommand.test.ts b/tests/unit/commands/docker-update-subcommand.test.ts new file mode 100644 index 00000000..240e3a37 --- /dev/null +++ b/tests/unit/commands/docker-update-subcommand.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; + +const capture = useDockerSubcommandConsoleCapture(); + +async function loadHandleUpdate() { + const mod = await import( + `../../../src/commands/docker/update-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleUpdate; +} + +describe('docker update subcommand', () => { + it('prints progress and success output for remote updates', async () => { + const calls: Array<{ host?: string }> = []; + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalUpdate = dockerModule.DockerExecutor.prototype.update; + dockerModule.DockerExecutor.prototype.update = function (options: { host?: string }) { + calls.push(options); + }; + + try { + const handleUpdate = await loadHandleUpdate(); + await handleUpdate(['--host', 'docker-box']); + + const rendered = renderCapturedLines(capture.logLines); + expect(calls).toEqual([{ host: 'docker-box' }]); + expect(rendered).toContain('Updating running Docker stack on docker-box...'); + expect(rendered).toContain('Docker stack updated on docker-box.'); + expect(capture.errorLines).toEqual([]); + expect(process.exitCode).toBe(0); + } finally { + dockerModule.DockerExecutor.prototype.update = originalUpdate; + } + }); + + it('renders executor failures as boxed errors', async () => { + const dockerModule = (await import( + '../../../src/docker' + )) as typeof import('../../../src/docker'); + const originalUpdate = dockerModule.DockerExecutor.prototype.update; + dockerModule.DockerExecutor.prototype.update = function () { + throw new Error('Docker stack update failed.\nSupervisor restart did not complete.'); + }; + + try { + const handleUpdate = await loadHandleUpdate(); + await handleUpdate([]); + + const rendered = renderCapturedLines(capture.errorLines); + expect(rendered).toContain('Docker stack update failed.'); + expect(rendered).toContain('Supervisor restart did not complete.'); + expect(process.exitCode).toBe(1); + } finally { + dockerModule.DockerExecutor.prototype.update = originalUpdate; + } + }); +}); 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..bb0025f9 --- /dev/null +++ b/tests/unit/docker/docker-executor.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'bun:test'; +import { DockerExecutor } from '../../../src/docker/docker-executor'; +import type { DockerCommandResult } from '../../../src/docker/docker-types'; + +const fakeAssets = { + dockerDir: '/tmp/ccs-docker', + composeFile: '/tmp/ccs-docker/docker-compose.integrated.yml', + dockerfile: '/tmp/ccs-docker/Dockerfile.integrated', + supervisordConfig: '/tmp/ccs-docker/supervisord.conf', + entrypoint: '/tmp/ccs-docker/entrypoint-integrated.sh', +}; + +type SyncCall = { + command: string; + args: string[]; + options?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + remote?: boolean; + timeoutMs?: number; + }; +}; + +function okResult(remote = false): DockerCommandResult { + return { + command: '', + exitCode: 0, + stdout: '', + stderr: '', + remote, + }; +} + +describe('docker executor', () => { + it('passes compose env and bundled compose file when bringing the stack up locally', async () => { + const calls: SyncCall[] = []; + const executor = new DockerExecutor({ + assets: fakeAssets, + getInstalledCcsVersion: () => '7.59.0', + resolveLocalComposePrefix: () => ['docker', 'compose'], + runSync: (command, args, options) => { + calls.push({ command, args, options }); + return okResult(options?.remote ?? false); + }, + }); + + await executor.up({ port: 4000, proxyPort: 9317 }); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toBe('docker'); + expect(calls[0].args).toEqual(['compose', '-f', fakeAssets.composeFile, 'up', '-d', '--build']); + expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir); + expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0'); + expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000'); + expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317'); + expect(calls[0].options?.timeoutMs).toBe(10_000); + }); + + it('stages bundled assets before remote compose startup', async () => { + const calls: SyncCall[] = []; + const executor = new DockerExecutor({ + assets: fakeAssets, + getInstalledCcsVersion: () => '7.59.0', + runSync: (command, args, options) => { + calls.push({ command, args, options }); + return okResult(options?.remote ?? false); + }, + }); + + await executor.up({ host: 'docker', port: 3000, proxyPort: 8317 }); + + expect(calls).toHaveLength(3); + expect(calls[0]).toEqual({ + command: 'ssh', + args: ['docker', 'mkdir -p ~/.ccs/docker'], + options: { remote: true, timeoutMs: 30_000 }, + }); + expect(calls[1].command).toBe('scp'); + expect(calls[1].args).toEqual([ + fakeAssets.composeFile, + fakeAssets.dockerfile, + fakeAssets.supervisordConfig, + fakeAssets.entrypoint, + 'docker:~/.ccs/docker/', + ]); + expect(calls[1].options).toEqual({ remote: true, timeoutMs: 30_000 }); + expect(calls[2].command).toBe('ssh'); + expect(calls[2].args[0]).toBe('docker'); + expect(calls[2].args[1]).toContain("CCS_NPM_VERSION='7.59.0'"); + expect(calls[2].args[1]).toContain("CCS_DASHBOARD_PORT='3000'"); + expect(calls[2].args[1]).toContain("CCS_CLIPROXY_PORT='8317'"); + expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1'); + expect(calls[2].options?.timeoutMs).toBe(30_000); + }); + + it('uses npm install latest rather than npm update during in-container updates', async () => { + const calls: SyncCall[] = []; + const executor = new DockerExecutor({ + assets: fakeAssets, + runSync: (command, args, options) => { + calls.push({ command, args, options }); + return okResult(options?.remote ?? false); + }, + }); + + await executor.update({}); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toBe('docker'); + expect(calls[0].args[0]).toBe('exec'); + expect(calls[0].args[1]).toBe('ccs-cliproxy'); + expect(calls[0].args[4]).toContain('npm install -g @kaitranntt/ccs@latest --force'); + expect(calls[0].args[4]).toContain('ccs cliproxy --latest'); + expect(calls[0].args[4]).toContain( + 'supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy' + ); + }); + + it('preserves supervisorctl failures in status results for CLI rendering', async () => { + let callCount = 0; + const executor = new DockerExecutor({ + assets: fakeAssets, + resolveLocalComposePrefix: () => ['docker', 'compose'], + runSync: (_command, args, options) => { + callCount++; + if (callCount === 1) { + expect(args).toEqual(['compose', '-f', fakeAssets.composeFile, 'ps']); + return { + command: '', + exitCode: 0, + stdout: 'NAME STATUS', + stderr: '', + remote: options?.remote ?? false, + }; + } + + expect(args).toEqual([ + 'exec', + 'ccs-cliproxy', + 'supervisorctl', + '-c', + '/etc/supervisord.conf', + 'status', + ]); + return { + command: '', + exitCode: 7, + stdout: '', + stderr: 'unix:///var/run/supervisor.sock no such file', + remote: options?.remote ?? false, + }; + }, + }); + + const status = await executor.status({}); + + expect(status.compose.exitCode).toBe(0); + expect(status.supervisor?.exitCode).toBe(7); + expect(status.supervisor?.stderr).toContain('supervisor.sock'); + }); + + it('surfaces a clear timeout message for blocked sync commands', () => { + const executor = new DockerExecutor({ + assets: fakeAssets, + }); + + const result = ( + executor as unknown as { + runSync: ( + command: string, + args: string[], + options?: { remote?: boolean; timeoutMs?: number } + ) => DockerCommandResult; + } + ).runSync(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { + remote: true, + timeoutMs: 25, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Command timed out after 25ms while running a remote Docker command.' + ); + expect(result.stderr).toContain(`Command: ${process.execPath}`); + expect(result.stderr).toContain('Check SSH reachability and the remote Docker host'); + }); +});