mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
feat(docker): add integrated deployment commands
- add ccs docker subcommands for up, down, status, update, logs, and config - bundle integrated docker assets and remote ssh deployment support - add executor regression tests and root help/router coverage Refs #812
This commit is contained in:
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -32,6 +32,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"docker/",
|
||||
"lib/",
|
||||
"scripts/",
|
||||
"config/",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { handleDockerCommand } from './docker/index';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { DockerExecutor } from '../../docker';
|
||||
import { box, color, fail, initUI, table } from '../../utils/ui';
|
||||
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
|
||||
|
||||
const KNOWN_FLAGS = ['--host'] as const;
|
||||
|
||||
export async function handleConfig(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
|
||||
const errors = [
|
||||
...parsed.errors,
|
||||
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
|
||||
knownFlags: [],
|
||||
maxPositionals: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = new DockerExecutor().getConfig({ host: parsed.host });
|
||||
const rows = [
|
||||
['Mode', config.remote ? `remote (${config.host})` : 'local'],
|
||||
['Local CCS Dir', config.ccsDir],
|
||||
['Bundled Docker Dir', config.dockerDir],
|
||||
['Compose File', config.composeFile],
|
||||
['Dockerfile', config.dockerfile],
|
||||
['Supervisor Config', config.supervisordConfig],
|
||||
['Entrypoint', config.entrypoint],
|
||||
['Remote Deploy Dir', config.remoteDeployDir],
|
||||
['Compose Service', config.composeService],
|
||||
['Container Name', config.containerName],
|
||||
['Dashboard Port', String(config.dashboardPort)],
|
||||
['CLIProxy Port', String(config.proxyPort)],
|
||||
];
|
||||
|
||||
console.log(
|
||||
table(
|
||||
rows.map(([key, value]) => [color(key, 'primary'), value]),
|
||||
{
|
||||
head: ['Setting', 'Value'],
|
||||
style: 'ascii',
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
box(fail(error instanceof Error ? error.message : String(error)), {
|
||||
title: 'Docker',
|
||||
padding: 1,
|
||||
})
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DockerExecutor } from '../../docker';
|
||||
import { box, fail, info, initUI, ok } from '../../utils/ui';
|
||||
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
|
||||
|
||||
const KNOWN_FLAGS = ['--host'] as const;
|
||||
|
||||
export async function handleDown(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
|
||||
const errors = [
|
||||
...parsed.errors,
|
||||
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
|
||||
knownFlags: [],
|
||||
maxPositionals: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(info(`Stopping Docker stack${parsed.host ? ` on ${parsed.host}` : ''}...`));
|
||||
try {
|
||||
await new DockerExecutor().down({ host: parsed.host });
|
||||
console.log(ok(`Docker stack stopped${parsed.host ? ` on ${parsed.host}` : ''}.`));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
box(fail(error instanceof Error ? error.message : String(error)), {
|
||||
title: 'Docker',
|
||||
padding: 1,
|
||||
})
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { color, dim, header, initUI, subheader } from '../../utils/ui';
|
||||
|
||||
export async function showHelp(): Promise<void> {
|
||||
await initUI();
|
||||
console.log('');
|
||||
console.log(header('Docker Deployment'));
|
||||
console.log('');
|
||||
console.log(subheader('Usage:'));
|
||||
console.log(` ${color('ccs docker', 'command')} <command> [options]`);
|
||||
console.log('');
|
||||
|
||||
const sections: [string, [string, string][]][] = [
|
||||
[
|
||||
'Commands:',
|
||||
[
|
||||
['up', 'Build and start the integrated CCS + CLIProxy stack'],
|
||||
['down', 'Stop and remove the integrated stack'],
|
||||
['status', 'Show docker compose and supervisor status'],
|
||||
['update', 'Update CCS and CLIProxy inside the running container'],
|
||||
['logs', 'Show or follow container log output'],
|
||||
['config', 'Show bundled asset paths and deployment defaults'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Common Options:',
|
||||
[
|
||||
['--host <target>', 'Run command on a remote host over SSH (single target or SSH alias)'],
|
||||
['--help, -h', 'Show this help message'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Per-Command Options:',
|
||||
[
|
||||
['up --port <port>', 'Publish dashboard on a custom host port'],
|
||||
['up --proxy-port <port>', 'Publish CLIProxy on a custom host port'],
|
||||
['logs --follow', 'Stream logs continuously'],
|
||||
['logs --service <name>', 'Filter logs to ccs or cliproxy'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Examples:',
|
||||
[
|
||||
['ccs docker up', 'Start the stack locally on ports 3000 and 8317'],
|
||||
['ccs docker up --port 4000 --proxy-port 9317', 'Start locally with custom ports'],
|
||||
['ccs docker --host my-box status', 'Use the documented common-option ordering'],
|
||||
['ccs docker up --host my-box', 'Stage assets to ~/.ccs/docker and deploy remotely'],
|
||||
['ccs docker logs --follow --service ccs', 'Tail dashboard logs only'],
|
||||
['ccs docker update --host my-box', 'Update the running remote stack in place'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
for (const [title, rows] of sections) {
|
||||
console.log(subheader(title));
|
||||
const width = Math.max(...rows.map(([command]) => command.length));
|
||||
for (const [command, description] of rows) {
|
||||
console.log(` ${color(command.padEnd(width + 2), 'command')} ${description}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(
|
||||
dim(' Remote deployments use ~/.ccs/docker on the target host to avoid root-only paths.')
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||
import { handleConfig } from './config-subcommand';
|
||||
import { handleDown } from './down-subcommand';
|
||||
import { showHelp } from './help-subcommand';
|
||||
import { handleLogs } from './logs-subcommand';
|
||||
import { handleStatus } from './status-subcommand';
|
||||
import { handleUp } from './up-subcommand';
|
||||
import { handleUpdate } from './update-subcommand';
|
||||
|
||||
function normalizeLeadingHostArg(args: string[]): string[] {
|
||||
const firstToken = args[0];
|
||||
if (!firstToken || (firstToken !== '--host' && !firstToken.startsWith('--host='))) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const extracted = extractOption(args, ['--host'], {
|
||||
knownFlags: ['--host', '--help', '-h'],
|
||||
});
|
||||
if (extracted.missingValue || !extracted.value?.trim()) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const command = extracted.remainingArgs[0];
|
||||
if (!command || command.startsWith('-')) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return [command, '--host', extracted.value.trim(), ...extracted.remainingArgs.slice(1)];
|
||||
}
|
||||
|
||||
export async function handleDockerCommand(args: string[]): Promise<void> {
|
||||
const normalizedArgs = normalizeLeadingHostArg(args);
|
||||
|
||||
if (hasAnyFlag(normalizedArgs, ['--help', '-h'])) {
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const command = normalizedArgs[0];
|
||||
const commandHandlers: Record<string, (subArgs: string[]) => Promise<void>> = {
|
||||
up: handleUp,
|
||||
down: handleDown,
|
||||
status: handleStatus,
|
||||
update: handleUpdate,
|
||||
logs: handleLogs,
|
||||
config: handleConfig,
|
||||
help: async () => showHelp(),
|
||||
};
|
||||
|
||||
if (!command) {
|
||||
await showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = commandHandlers[command];
|
||||
if (!handler) {
|
||||
await showHelp();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await handler(normalizedArgs.slice(1));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DockerExecutor } from '../../docker';
|
||||
import { box, fail, info, initUI } from '../../utils/ui';
|
||||
import { collectUnexpectedDockerArgs, parseDockerLogsOptions } from './options';
|
||||
|
||||
const KNOWN_FLAGS = ['--host', '--follow', '--service'] as const;
|
||||
|
||||
export async function handleLogs(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseDockerLogsOptions(args, KNOWN_FLAGS);
|
||||
const errors = [
|
||||
...parsed.errors,
|
||||
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
|
||||
knownFlags: ['--follow'],
|
||||
maxPositionals: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const executor = new DockerExecutor();
|
||||
try {
|
||||
if (parsed.follow) {
|
||||
console.log(info(`Following Docker logs${parsed.host ? ` on ${parsed.host}` : ''}...`));
|
||||
await executor.logs({
|
||||
host: parsed.host,
|
||||
follow: true,
|
||||
service: parsed.service,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const output = await executor.logs({
|
||||
host: parsed.host,
|
||||
follow: false,
|
||||
service: parsed.service,
|
||||
});
|
||||
console.log(output ?? '');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
box(fail(error instanceof Error ? error.message : String(error)), {
|
||||
title: 'Docker',
|
||||
padding: 1,
|
||||
})
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { DockerExecutor } from '../../docker';
|
||||
import { box, color, fail, info, initUI, ok, subheader } from '../../utils/ui';
|
||||
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
|
||||
|
||||
const KNOWN_FLAGS = ['--host'] as const;
|
||||
|
||||
export async function handleStatus(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
|
||||
const errors = [
|
||||
...parsed.errors,
|
||||
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
|
||||
knownFlags: [],
|
||||
maxPositionals: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await new DockerExecutor().status({ host: parsed.host });
|
||||
console.log(ok(`Docker status${parsed.host ? ` for ${parsed.host}` : ''}`));
|
||||
console.log('');
|
||||
console.log(subheader('Compose:'));
|
||||
console.log(status.compose.stdout.trim() || info('No docker compose output.'));
|
||||
if (status.supervisor?.exitCode === 0 && status.supervisor.stdout.trim()) {
|
||||
console.log('');
|
||||
console.log(subheader('Supervisor:'));
|
||||
console.log(status.supervisor.stdout.trim());
|
||||
} else if (status.supervisor) {
|
||||
console.log('');
|
||||
console.log(subheader('Supervisor:'));
|
||||
const detail = (status.supervisor.stderr || status.supervisor.stdout).trim();
|
||||
console.log(
|
||||
info(
|
||||
`Supervisor status check failed for ${color('ccs-cliproxy', 'command')}.\n${detail || 'No additional detail provided.'}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(info(`Supervisor status unavailable for ${color('ccs-cliproxy', 'command')}.`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
box(fail(error instanceof Error ? error.message : String(error)), {
|
||||
title: 'Docker',
|
||||
padding: 1,
|
||||
})
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
DOCKER_DEFAULT_DASHBOARD_PORT,
|
||||
DOCKER_DEFAULT_PROXY_PORT,
|
||||
DockerExecutor,
|
||||
} from '../../docker';
|
||||
import { box, fail, info, initUI, ok } from '../../utils/ui';
|
||||
import { collectUnexpectedDockerArgs, parseDockerUpOptions } from './options';
|
||||
|
||||
const KNOWN_FLAGS = ['--host', '--port', '--proxy-port'] as const;
|
||||
|
||||
export async function handleUp(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseDockerUpOptions(args, KNOWN_FLAGS);
|
||||
const errors = [
|
||||
...parsed.errors,
|
||||
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
|
||||
knownFlags: [],
|
||||
maxPositionals: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const executor = new DockerExecutor();
|
||||
const port = parsed.port ?? DOCKER_DEFAULT_DASHBOARD_PORT;
|
||||
const proxyPort = parsed.proxyPort ?? DOCKER_DEFAULT_PROXY_PORT;
|
||||
|
||||
console.log(
|
||||
info(`Starting integrated Docker stack${parsed.host ? ` on ${parsed.host}` : ''}...`)
|
||||
);
|
||||
try {
|
||||
await executor.up({ host: parsed.host, port, proxyPort });
|
||||
console.log(ok(`Docker stack is running${parsed.host ? ` on ${parsed.host}` : ' locally'}.`));
|
||||
console.log(info(`Dashboard port: ${port}`));
|
||||
console.log(info(`CLIProxy port: ${proxyPort}`));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
box(fail(error instanceof Error ? error.message : String(error)), {
|
||||
title: 'Docker',
|
||||
padding: 1,
|
||||
})
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DockerExecutor } from '../../docker';
|
||||
import { box, fail, info, initUI, ok } from '../../utils/ui';
|
||||
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
|
||||
|
||||
const KNOWN_FLAGS = ['--host'] as const;
|
||||
|
||||
export async function handleUpdate(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
|
||||
const errors = [
|
||||
...parsed.errors,
|
||||
...collectUnexpectedDockerArgs(parsed.remainingArgs, {
|
||||
knownFlags: [],
|
||||
maxPositionals: 0,
|
||||
}),
|
||||
];
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(info(`Updating running Docker stack${parsed.host ? ` on ${parsed.host}` : ''}...`));
|
||||
try {
|
||||
await new DockerExecutor().update({ host: parsed.host });
|
||||
console.log(ok(`Docker stack updated${parsed.host ? ` on ${parsed.host}` : ''}.`));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
box(fail(error instanceof Error ? error.message : String(error)), {
|
||||
title: 'Docker',
|
||||
padding: 1,
|
||||
})
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,20 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
writeLine
|
||||
);
|
||||
|
||||
printSubSection(
|
||||
'Docker Deployment',
|
||||
[
|
||||
['ccs docker --help', 'Manage the integrated CCS + CLIProxy Docker stack'],
|
||||
['ccs docker up', 'Build and start the stack locally'],
|
||||
['ccs docker up --host <ssh>', 'Stage assets to ~/.ccs/docker and deploy remotely'],
|
||||
['ccs docker status', 'Show docker compose and supervisor status'],
|
||||
['ccs docker logs --follow', 'Tail combined CCS + CLIProxy logs'],
|
||||
['ccs docker update', 'Update CCS and CLIProxy inside the running container'],
|
||||
['ccs docker config', 'Show bundled asset paths and deployment defaults'],
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
|
||||
// CLI Proxy configuration flags (new)
|
||||
printSubSection(
|
||||
'CLI Proxy Configuration',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import type { DockerConfigSummary } from './docker-types';
|
||||
|
||||
export const DOCKER_REMOTE_DIR = '~/.ccs/docker';
|
||||
export const DOCKER_COMPOSE_SERVICE = 'ccs-cliproxy';
|
||||
export const DOCKER_CONTAINER_NAME = 'ccs-cliproxy';
|
||||
export const DOCKER_DEFAULT_DASHBOARD_PORT = 3000;
|
||||
export const DOCKER_DEFAULT_PROXY_PORT = 8317;
|
||||
|
||||
export const DOCKER_LOG_FILES = {
|
||||
ccs: '/var/log/ccs/ccs-dashboard.log',
|
||||
cliproxy: '/var/log/ccs/cliproxy.log',
|
||||
} as const;
|
||||
|
||||
export interface DockerAssetPaths {
|
||||
dockerDir: string;
|
||||
composeFile: string;
|
||||
dockerfile: string;
|
||||
supervisordConfig: string;
|
||||
entrypoint: string;
|
||||
}
|
||||
|
||||
function getPackageRoot(): string {
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
export function getDockerAssetPaths(): DockerAssetPaths {
|
||||
const packageRoot = getPackageRoot();
|
||||
const dockerDir = path.join(packageRoot, 'docker');
|
||||
const assets: DockerAssetPaths = {
|
||||
dockerDir,
|
||||
composeFile: path.join(dockerDir, 'docker-compose.integrated.yml'),
|
||||
dockerfile: path.join(dockerDir, 'Dockerfile.integrated'),
|
||||
supervisordConfig: path.join(dockerDir, 'supervisord.conf'),
|
||||
entrypoint: path.join(dockerDir, 'entrypoint-integrated.sh'),
|
||||
};
|
||||
|
||||
for (const assetPath of Object.values(assets)) {
|
||||
if (!fs.existsSync(assetPath)) {
|
||||
throw new Error(
|
||||
`Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
export function getInstalledCcsVersion(): string {
|
||||
const packageJsonPath = path.join(getPackageRoot(), 'package.json');
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
||||
version?: string;
|
||||
};
|
||||
return packageJson.version?.trim() || 'latest';
|
||||
} catch {
|
||||
return 'latest';
|
||||
}
|
||||
}
|
||||
|
||||
export function createDockerConfigSummary(options: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
proxyPort?: number;
|
||||
}): DockerConfigSummary {
|
||||
const assets = getDockerAssetPaths();
|
||||
return {
|
||||
host: options.host,
|
||||
remote: Boolean(options.host),
|
||||
ccsDir: getCcsDir(),
|
||||
dockerDir: assets.dockerDir,
|
||||
composeFile: assets.composeFile,
|
||||
dockerfile: assets.dockerfile,
|
||||
supervisordConfig: assets.supervisordConfig,
|
||||
entrypoint: assets.entrypoint,
|
||||
remoteDeployDir: DOCKER_REMOTE_DIR,
|
||||
composeService: DOCKER_COMPOSE_SERVICE,
|
||||
containerName: DOCKER_CONTAINER_NAME,
|
||||
dashboardPort: options.port ?? DOCKER_DEFAULT_DASHBOARD_PORT,
|
||||
proxyPort: options.proxyPort ?? DOCKER_DEFAULT_PROXY_PORT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { ensureCLIProxyBinary } from '../cliproxy/binary-manager';
|
||||
import {
|
||||
configExists,
|
||||
configNeedsRegeneration,
|
||||
generateConfig,
|
||||
getCliproxyWritablePath,
|
||||
regenerateConfig,
|
||||
} from '../cliproxy/config-generator';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { getCliproxyConfigPath } from '../cliproxy/config/path-resolver';
|
||||
|
||||
async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configPath: string }> {
|
||||
const binaryPath = await ensureCLIProxyBinary(false);
|
||||
const configPath = !configExists(CLIPROXY_DEFAULT_PORT)
|
||||
? generateConfig('gemini', CLIPROXY_DEFAULT_PORT)
|
||||
: configNeedsRegeneration()
|
||||
? regenerateConfig(CLIPROXY_DEFAULT_PORT)
|
||||
: getCliproxyConfigPath();
|
||||
|
||||
return { binaryPath, configPath };
|
||||
}
|
||||
|
||||
async function runCliproxy(): Promise<void> {
|
||||
const { binaryPath, configPath } = await prepareIntegratedRuntime();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(binaryPath, ['--config', configPath], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
WRITABLE_PATH: getCliproxyWritablePath(),
|
||||
},
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
child.on('spawn', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const command = process.argv[2];
|
||||
if (command !== 'run-cliproxy') {
|
||||
console.error('[X] Usage: node dist/docker/docker-bootstrap.js run-cliproxy');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await runCliproxy();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main().catch((error) => {
|
||||
console.error(
|
||||
`[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
DOCKER_CONTAINER_NAME,
|
||||
DOCKER_LOG_FILES,
|
||||
DOCKER_REMOTE_DIR,
|
||||
type DockerAssetPaths,
|
||||
createDockerConfigSummary,
|
||||
getInstalledCcsVersion,
|
||||
getDockerAssetPaths,
|
||||
} from './docker-assets';
|
||||
import type {
|
||||
DockerCommandResult,
|
||||
DockerCommandTarget,
|
||||
DockerConfigSummary,
|
||||
DockerLogsOptions,
|
||||
DockerStatusResult,
|
||||
DockerUpOptions,
|
||||
} from './docker-types';
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function renderCommand(command: string, args: string[]): string {
|
||||
return [command, ...args.map((arg) => quotePosix(arg))].join(' ');
|
||||
}
|
||||
|
||||
function normalizeOutput(value: string | Buffer | null | undefined): string {
|
||||
if (typeof value === 'string') return value;
|
||||
return value ? value.toString('utf8') : '';
|
||||
}
|
||||
|
||||
interface DockerSyncRunOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
remote?: boolean;
|
||||
}
|
||||
|
||||
function runSync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: DockerSyncRunOptions = {}
|
||||
): DockerCommandResult {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
const stderr = [normalizeOutput(result.stderr), result.error?.message].filter(Boolean).join('\n');
|
||||
|
||||
return {
|
||||
command: renderCommand(command, args),
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: normalizeOutput(result.stdout),
|
||||
stderr,
|
||||
remote: options.remote ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function runStreaming(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if ((code ?? 1) === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Command failed (${code ?? 1}): ${renderCommand(command, args)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLocalComposePrefix(): string[] {
|
||||
if (runSync('docker', ['compose', 'version']).exitCode === 0) {
|
||||
return ['docker', 'compose'];
|
||||
}
|
||||
if (runSync('docker-compose', ['version']).exitCode === 0) {
|
||||
return ['docker-compose'];
|
||||
}
|
||||
throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.');
|
||||
}
|
||||
|
||||
function buildRemoteComposeCommand(composeArgs: string[]): string {
|
||||
const suffix = composeArgs.map((arg) => quotePosix(arg)).join(' ');
|
||||
return [
|
||||
'if docker compose version >/dev/null 2>&1; then',
|
||||
`docker compose ${suffix};`,
|
||||
'elif docker-compose version >/dev/null 2>&1; then',
|
||||
`docker-compose ${suffix};`,
|
||||
'else',
|
||||
"echo 'Docker Compose is not available on the remote host.' >&2;",
|
||||
'exit 127;',
|
||||
'fi',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildRemoteDockerCommand(args: string[]): string {
|
||||
return ['docker', ...args.map((arg) => quotePosix(arg))].join(' ');
|
||||
}
|
||||
|
||||
interface DockerExecutorDeps {
|
||||
assets?: DockerAssetPaths;
|
||||
getInstalledCcsVersion?: () => string;
|
||||
resolveLocalComposePrefix?: () => string[];
|
||||
runSync?: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: DockerSyncRunOptions
|
||||
) => DockerCommandResult;
|
||||
runStreaming?: (command: string, args: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export class DockerExecutor {
|
||||
private readonly assets: DockerAssetPaths;
|
||||
|
||||
constructor(private readonly deps: DockerExecutorDeps = {}) {
|
||||
this.assets = deps.assets ?? getDockerAssetPaths();
|
||||
}
|
||||
|
||||
getConfig(options: { host?: string; port?: number; proxyPort?: number }): DockerConfigSummary {
|
||||
return createDockerConfigSummary(options);
|
||||
}
|
||||
|
||||
async up(options: DockerUpOptions): Promise<void> {
|
||||
if (options.host) {
|
||||
this.stageRemoteAssets(options.host);
|
||||
}
|
||||
this.ensureSuccess(
|
||||
this.runCompose(['up', '-d', '--build'], options, {
|
||||
CCS_NPM_VERSION: this.getInstalledCcsVersion(),
|
||||
CCS_DASHBOARD_PORT: String(options.port),
|
||||
CCS_CLIPROXY_PORT: String(options.proxyPort),
|
||||
}),
|
||||
'Docker stack startup',
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async down(options: DockerCommandTarget): Promise<void> {
|
||||
this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options);
|
||||
}
|
||||
|
||||
async status(options: DockerCommandTarget): Promise<DockerStatusResult> {
|
||||
const compose = this.runCompose(['ps'], options);
|
||||
this.ensureSuccess(compose, 'Docker status', options);
|
||||
let supervisor: DockerCommandResult | undefined;
|
||||
if (compose.exitCode === 0) {
|
||||
supervisor = this.runDocker(
|
||||
['exec', DOCKER_CONTAINER_NAME, 'supervisorctl', '-c', '/etc/supervisord.conf', 'status'],
|
||||
options
|
||||
);
|
||||
}
|
||||
return { compose, supervisor };
|
||||
}
|
||||
|
||||
async update(options: DockerCommandTarget): Promise<void> {
|
||||
const script =
|
||||
'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy';
|
||||
this.ensureSuccess(
|
||||
this.runDocker(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', script], options),
|
||||
'Docker stack update',
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async logs(options: DockerLogsOptions): Promise<string | void> {
|
||||
const files = options.service
|
||||
? [DOCKER_LOG_FILES[options.service]]
|
||||
: [DOCKER_LOG_FILES.ccs, DOCKER_LOG_FILES.cliproxy];
|
||||
const touch = `mkdir -p /var/log/ccs && touch ${files.map((file) => quotePosix(file)).join(' ')}`;
|
||||
const command = options.follow
|
||||
? `${touch} && tail -n 100 -F ${files.map((file) => quotePosix(file)).join(' ')}`
|
||||
: options.service
|
||||
? `${touch} && tail -n 100 ${quotePosix(files[0])}`
|
||||
: `${touch} && printf '== ccs ==\\n' && tail -n 100 ${quotePosix(
|
||||
DOCKER_LOG_FILES.ccs
|
||||
)} && printf '\\n== cliproxy ==\\n' && tail -n 100 ${quotePosix(DOCKER_LOG_FILES.cliproxy)}`;
|
||||
|
||||
if (options.follow) {
|
||||
await this.runDockerStreaming(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', command], options);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.runDocker(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', command], options);
|
||||
this.ensureSuccess(result, 'Docker log retrieval', options);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
private stageRemoteAssets(host: string): void {
|
||||
this.ensureSuccess(
|
||||
this.runSync('ssh', [host, `mkdir -p ${DOCKER_REMOTE_DIR}`], { remote: true }),
|
||||
'Remote Docker asset staging',
|
||||
{ host }
|
||||
);
|
||||
const files = [
|
||||
this.assets.composeFile,
|
||||
this.assets.dockerfile,
|
||||
this.assets.supervisordConfig,
|
||||
this.assets.entrypoint,
|
||||
];
|
||||
const target = `${host}:${DOCKER_REMOTE_DIR}/`;
|
||||
this.ensureSuccess(
|
||||
this.runSync('scp', [...files, target], { remote: true }),
|
||||
'Remote Docker asset copy',
|
||||
{
|
||||
host,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private runCompose(
|
||||
args: string[],
|
||||
options: DockerCommandTarget,
|
||||
env: Record<string, string> = {}
|
||||
): DockerCommandResult {
|
||||
if (!options.host) {
|
||||
const prefix = this.resolveLocalComposePrefix();
|
||||
const command = prefix[0];
|
||||
const composeArgs = [...prefix.slice(1), '-f', this.assets.composeFile, ...args];
|
||||
return this.runSync(command, composeArgs, {
|
||||
cwd: path.dirname(this.assets.composeFile),
|
||||
env: { ...process.env, ...env },
|
||||
remote: false,
|
||||
});
|
||||
}
|
||||
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${quotePosix(value)}`)
|
||||
.join(' ');
|
||||
const composeArgs = ['-f', path.basename(this.assets.composeFile), ...args];
|
||||
const remoteCommand = `cd ${DOCKER_REMOTE_DIR} && ${envPrefix ? `${envPrefix} ` : ''}${buildRemoteComposeCommand(composeArgs)}`;
|
||||
return this.runSync('ssh', [options.host, remoteCommand], { remote: true });
|
||||
}
|
||||
|
||||
private runDocker(args: string[], options: DockerCommandTarget): DockerCommandResult {
|
||||
if (!options.host) {
|
||||
return this.runSync('docker', args);
|
||||
}
|
||||
return this.runSync('ssh', [options.host, buildRemoteDockerCommand(args)], { remote: true });
|
||||
}
|
||||
|
||||
private async runDockerStreaming(args: string[], options: DockerCommandTarget): Promise<void> {
|
||||
if (!options.host) {
|
||||
await this.runStreaming('docker', args);
|
||||
return;
|
||||
}
|
||||
await this.runStreaming('ssh', [options.host, buildRemoteDockerCommand(args)]);
|
||||
}
|
||||
|
||||
private getInstalledCcsVersion(): string {
|
||||
return this.deps.getInstalledCcsVersion?.() ?? getInstalledCcsVersion();
|
||||
}
|
||||
|
||||
private resolveLocalComposePrefix(): string[] {
|
||||
return this.deps.resolveLocalComposePrefix?.() ?? resolveLocalComposePrefix();
|
||||
}
|
||||
|
||||
private runSync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: DockerSyncRunOptions = {}
|
||||
): DockerCommandResult {
|
||||
return this.deps.runSync?.(command, args, options) ?? runSync(command, args, options);
|
||||
}
|
||||
|
||||
private async runStreaming(command: string, args: string[]): Promise<void> {
|
||||
await (this.deps.runStreaming?.(command, args) ?? runStreaming(command, args));
|
||||
}
|
||||
|
||||
private ensureSuccess(
|
||||
result: DockerCommandResult,
|
||||
label: string,
|
||||
options: DockerCommandTarget
|
||||
): void {
|
||||
if (result.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = (result.stderr || result.stdout).trim();
|
||||
const hint =
|
||||
options.host && /No such file|no configuration file|can't cd|not found/i.test(detail)
|
||||
? `\nRun \`ccs docker up --host ${options.host}\` first.`
|
||||
: '';
|
||||
throw new Error(`${label} failed.${detail ? `\n${detail}` : ''}${hint}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
let calls: string[] = [];
|
||||
let originalExitCode: number | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
|
||||
mock.module('../../../src/commands/docker/help-subcommand', () => ({
|
||||
showHelp: async () => {
|
||||
calls.push('help');
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/docker/up-subcommand', () => ({
|
||||
handleUp: async (args: string[]) => {
|
||||
calls.push(`up:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/docker/down-subcommand', () => ({
|
||||
handleDown: async (args: string[]) => {
|
||||
calls.push(`down:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/docker/status-subcommand', () => ({
|
||||
handleStatus: async (args: string[]) => {
|
||||
calls.push(`status:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/docker/update-subcommand', () => ({
|
||||
handleUpdate: async (args: string[]) => {
|
||||
calls.push(`update:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/docker/logs-subcommand', () => ({
|
||||
handleLogs: async (args: string[]) => {
|
||||
calls.push(`logs:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/docker/config-subcommand', () => ({
|
||||
handleConfig: async (args: string[]) => {
|
||||
calls.push(`config:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
process.exitCode = originalExitCode ?? 0;
|
||||
});
|
||||
|
||||
async function loadHandleDockerCommand() {
|
||||
const mod = await import(`../../../src/commands/docker/index?test=${Date.now()}-${Math.random()}`);
|
||||
return mod.handleDockerCommand;
|
||||
}
|
||||
|
||||
describe('docker command', () => {
|
||||
it('shows help when invoked without a subcommand', async () => {
|
||||
const handleDockerCommand = await loadHandleDockerCommand();
|
||||
|
||||
await handleDockerCommand([]);
|
||||
|
||||
expect(calls).toEqual(['help']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('routes nested subcommands with remaining args intact', async () => {
|
||||
const handleDockerCommand = await loadHandleDockerCommand();
|
||||
|
||||
await handleDockerCommand(['up', '--host', 'my-box', '--port', '4000']);
|
||||
|
||||
expect(calls).toEqual(['up:--host my-box --port 4000']);
|
||||
});
|
||||
|
||||
it('supports --host before the subcommand', async () => {
|
||||
const handleDockerCommand = await loadHandleDockerCommand();
|
||||
|
||||
await handleDockerCommand(['--host', 'my-box', 'status']);
|
||||
|
||||
expect(calls).toEqual(['status:--host my-box']);
|
||||
});
|
||||
|
||||
it('treats help as a nested subcommand alias', async () => {
|
||||
const handleDockerCommand = await loadHandleDockerCommand();
|
||||
|
||||
await handleDockerCommand(['help']);
|
||||
|
||||
expect(calls).toEqual(['help']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('marks unknown subcommands as failures after printing help', async () => {
|
||||
const handleDockerCommand = await loadHandleDockerCommand();
|
||||
|
||||
await handleDockerCommand(['unknown']);
|
||||
|
||||
expect(calls).toEqual(['help']);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
parseDockerLogsOptions,
|
||||
parseDockerTarget,
|
||||
parseDockerUpOptions,
|
||||
} from '../../../src/commands/docker/options';
|
||||
|
||||
describe('docker options', () => {
|
||||
it('accepts a single SSH target for --host', () => {
|
||||
const parsed = parseDockerTarget(['--host', 'my-box'], ['--host']);
|
||||
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(parsed.host).toBe('my-box');
|
||||
});
|
||||
|
||||
it('rejects whitespace-separated SSH command strings', () => {
|
||||
const parsed = parseDockerTarget(['--host', 'user@host -p 2222'], ['--host']);
|
||||
|
||||
expect(parsed.errors).toEqual([
|
||||
'Invalid value for --host. Use a single SSH target or SSH config alias such as my-box or user@host.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects dash-prefixed host tokens', () => {
|
||||
const parsed = parseDockerTarget(['--host', '-p'], ['--host']);
|
||||
|
||||
expect(parsed.errors).toEqual(['Missing value for --host']);
|
||||
});
|
||||
|
||||
it('parses local up port overrides', () => {
|
||||
const parsed = parseDockerUpOptions(['--port', '4000', '--proxy-port', '9317'], [
|
||||
'--port',
|
||||
'--proxy-port',
|
||||
]);
|
||||
|
||||
expect(parsed.errors).toEqual([]);
|
||||
expect(parsed.port).toBe(4000);
|
||||
expect(parsed.proxyPort).toBe(9317);
|
||||
});
|
||||
|
||||
it('rejects invalid numeric up port overrides', () => {
|
||||
const parsed = parseDockerUpOptions(['--port', '99999'], ['--port']);
|
||||
|
||||
expect(parsed.errors).toEqual(['Invalid value for --port']);
|
||||
});
|
||||
|
||||
it('rejects invalid log service filters', () => {
|
||||
const parsed = parseDockerLogsOptions(['--service', 'api'], ['--service']);
|
||||
|
||||
expect(parsed.errors).toEqual(['Invalid value for --service. Use: ccs or cliproxy']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
let logLines: string[] = [];
|
||||
let errorLines: string[] = [];
|
||||
let originalConsoleLog: typeof console.log;
|
||||
let originalConsoleError: typeof console.error;
|
||||
let originalExitCode: number | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
logLines = [];
|
||||
errorLines = [];
|
||||
originalConsoleLog = console.log;
|
||||
originalConsoleError = console.error;
|
||||
originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
logLines.push(args.map(String).join(' '));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errorLines.push(args.map(String).join(' '));
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalConsoleLog;
|
||||
console.error = originalConsoleError;
|
||||
process.exitCode = originalExitCode ?? 0;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
async function loadHandleStatus() {
|
||||
const mod = await import(
|
||||
`../../../src/commands/docker/status-subcommand?test=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
return mod.handleStatus;
|
||||
}
|
||||
|
||||
describe('docker status subcommand', () => {
|
||||
it('prints supervisor failure details instead of masking them as a missing container', async () => {
|
||||
mock.module('../../../src/docker', () => ({
|
||||
DockerExecutor: class {
|
||||
async status() {
|
||||
return {
|
||||
compose: {
|
||||
command: '',
|
||||
exitCode: 0,
|
||||
stdout: 'NAME STATUS',
|
||||
stderr: '',
|
||||
remote: false,
|
||||
},
|
||||
supervisor: {
|
||||
command: '',
|
||||
exitCode: 7,
|
||||
stdout: '',
|
||||
stderr: 'unix:///var/run/supervisor.sock no such file',
|
||||
remote: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const handleStatus = await loadHandleStatus();
|
||||
await handleStatus([]);
|
||||
|
||||
const rendered = logLines.join('\n');
|
||||
expect(rendered).toContain('Docker status');
|
||||
expect(rendered).toContain('Supervisor status check failed');
|
||||
expect(rendered).toContain('supervisor.sock no such file');
|
||||
expect(rendered).not.toContain('may not be running');
|
||||
expect(errorLines).toEqual([]);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,16 @@ describe('help command parity', () => {
|
||||
expect(rendered.includes('Force all-interface binding for remote devices')).toBe(true);
|
||||
});
|
||||
|
||||
test('root help documents docker deployment commands', async () => {
|
||||
const lines: string[] = [];
|
||||
await handleHelpCommand((line) => lines.push(line));
|
||||
|
||||
const rendered = stripAnsi(lines.join('\n'));
|
||||
expect(rendered.includes('ccs docker --help')).toBe(true);
|
||||
expect(rendered.includes('ccs docker up --host <ssh>')).toBe(true);
|
||||
expect(rendered.includes('ccs docker update')).toBe(true);
|
||||
});
|
||||
|
||||
test('root help documents official channels native-only scope and process-env tokens', async () => {
|
||||
const lines: string[] = [];
|
||||
await handleHelpCommand((line) => lines.push(line));
|
||||
|
||||
@@ -33,6 +33,12 @@ beforeEach(() => {
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/docker-command', () => ({
|
||||
handleDockerCommand: async (args: string[]) => {
|
||||
calls.push(`docker:${args.join(' ')}`);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/commands/tokens-command', () => ({
|
||||
handleTokensCommand: async () => 37,
|
||||
}));
|
||||
@@ -86,6 +92,16 @@ describe('root-command-router', () => {
|
||||
expect(calls).toEqual(['api:discover --register']);
|
||||
});
|
||||
|
||||
it('routes docker commands through the root router', async () => {
|
||||
const tryHandleRootCommand = await loadTryHandleRootCommand();
|
||||
|
||||
await expect(tryHandleRootCommand(['docker', 'status', '--host', 'my-box'])).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(calls).toEqual(['docker:status --host my-box']);
|
||||
});
|
||||
|
||||
it('exits with the nested command exit code when required', async () => {
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { DockerExecutor } from '../../../src/docker/docker-executor';
|
||||
import type { DockerCommandResult } from '../../../src/docker/docker-types';
|
||||
|
||||
const fakeAssets = {
|
||||
dockerDir: '/tmp/ccs-docker',
|
||||
composeFile: '/tmp/ccs-docker/docker-compose.integrated.yml',
|
||||
dockerfile: '/tmp/ccs-docker/Dockerfile.integrated',
|
||||
supervisordConfig: '/tmp/ccs-docker/supervisord.conf',
|
||||
entrypoint: '/tmp/ccs-docker/entrypoint-integrated.sh',
|
||||
};
|
||||
|
||||
type SyncCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
remote?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function okResult(remote = false): DockerCommandResult {
|
||||
return {
|
||||
command: '',
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
remote,
|
||||
};
|
||||
}
|
||||
|
||||
describe('docker executor', () => {
|
||||
it('passes compose env and bundled compose file when bringing the stack up locally', async () => {
|
||||
const calls: SyncCall[] = [];
|
||||
const executor = new DockerExecutor({
|
||||
assets: fakeAssets,
|
||||
getInstalledCcsVersion: () => '7.59.0',
|
||||
resolveLocalComposePrefix: () => ['docker', 'compose'],
|
||||
runSync: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return okResult(options?.remote ?? false);
|
||||
},
|
||||
});
|
||||
|
||||
await executor.up({ port: 4000, proxyPort: 9317 });
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].command).toBe('docker');
|
||||
expect(calls[0].args).toEqual([
|
||||
'compose',
|
||||
'-f',
|
||||
fakeAssets.composeFile,
|
||||
'up',
|
||||
'-d',
|
||||
'--build',
|
||||
]);
|
||||
expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir);
|
||||
expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0');
|
||||
expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000');
|
||||
expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317');
|
||||
});
|
||||
|
||||
it('stages bundled assets before remote compose startup', async () => {
|
||||
const calls: SyncCall[] = [];
|
||||
const executor = new DockerExecutor({
|
||||
assets: fakeAssets,
|
||||
getInstalledCcsVersion: () => '7.59.0',
|
||||
runSync: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return okResult(options?.remote ?? false);
|
||||
},
|
||||
});
|
||||
|
||||
await executor.up({ host: 'docker', port: 3000, proxyPort: 8317 });
|
||||
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]).toEqual({
|
||||
command: 'ssh',
|
||||
args: ['docker', 'mkdir -p ~/.ccs/docker'],
|
||||
options: { remote: true },
|
||||
});
|
||||
expect(calls[1].command).toBe('scp');
|
||||
expect(calls[1].args).toEqual([
|
||||
fakeAssets.composeFile,
|
||||
fakeAssets.dockerfile,
|
||||
fakeAssets.supervisordConfig,
|
||||
fakeAssets.entrypoint,
|
||||
'docker:~/.ccs/docker/',
|
||||
]);
|
||||
expect(calls[1].options).toEqual({ remote: true });
|
||||
expect(calls[2].command).toBe('ssh');
|
||||
expect(calls[2].args[0]).toBe('docker');
|
||||
expect(calls[2].args[1]).toContain("CCS_NPM_VERSION='7.59.0'");
|
||||
expect(calls[2].args[1]).toContain("CCS_DASHBOARD_PORT='3000'");
|
||||
expect(calls[2].args[1]).toContain("CCS_CLIPROXY_PORT='8317'");
|
||||
expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1');
|
||||
});
|
||||
|
||||
it('uses npm install latest rather than npm update during in-container updates', async () => {
|
||||
const calls: SyncCall[] = [];
|
||||
const executor = new DockerExecutor({
|
||||
assets: fakeAssets,
|
||||
runSync: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return okResult(options?.remote ?? false);
|
||||
},
|
||||
});
|
||||
|
||||
await executor.update({});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].command).toBe('docker');
|
||||
expect(calls[0].args[0]).toBe('exec');
|
||||
expect(calls[0].args[1]).toBe('ccs-cliproxy');
|
||||
expect(calls[0].args[4]).toContain('npm install -g @kaitranntt/ccs@latest --force');
|
||||
expect(calls[0].args[4]).toContain('ccs cliproxy --latest');
|
||||
expect(calls[0].args[4]).toContain(
|
||||
'supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves supervisorctl failures in status results for CLI rendering', async () => {
|
||||
let callCount = 0;
|
||||
const executor = new DockerExecutor({
|
||||
assets: fakeAssets,
|
||||
resolveLocalComposePrefix: () => ['docker', 'compose'],
|
||||
runSync: (_command, args, options) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
expect(args).toEqual(['compose', '-f', fakeAssets.composeFile, 'ps']);
|
||||
return {
|
||||
command: '',
|
||||
exitCode: 0,
|
||||
stdout: 'NAME STATUS',
|
||||
stderr: '',
|
||||
remote: options?.remote ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
expect(args).toEqual([
|
||||
'exec',
|
||||
'ccs-cliproxy',
|
||||
'supervisorctl',
|
||||
'-c',
|
||||
'/etc/supervisord.conf',
|
||||
'status',
|
||||
]);
|
||||
return {
|
||||
command: '',
|
||||
exitCode: 7,
|
||||
stdout: '',
|
||||
stderr: 'unix:///var/run/supervisor.sock no such file',
|
||||
remote: options?.remote ?? false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const status = await executor.status({});
|
||||
|
||||
expect(status.compose.exitCode).toBe(0);
|
||||
expect(status.supervisor?.exitCode).toBe(7);
|
||||
expect(status.supervisor?.stderr).toContain('supervisor.sock');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user