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

-### 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');
+ });
+});