379 lines
21 KiB
TypeScript
379 lines
21 KiB
TypeScript
import { readConfig } from "./src/config";
|
|
import { debugDispatch, debugHealth, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug";
|
|
import { isRebuildableService, rebuildService, stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
|
|
import { parseE2ERunOptions, runE2E } from "./src/e2e";
|
|
import { emitError, emitJson } from "./src/output";
|
|
import { jobWithTail, listJobs, listJobsSummary, readJob, runJob } from "./src/jobs";
|
|
import { checkHelp, parseCheckOptions, runChecks } from "./src/check";
|
|
import { runSsh } from "./src/ssh";
|
|
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
|
|
import { runMicroserviceCommand } from "./src/microservices";
|
|
import { runCodeQueueCommand } from "./src/code-queue";
|
|
import { runDecisionCenterCommand } from "./src/decision-center";
|
|
import { runCodeQueueDeployCompatCommand, runDeployCommand } from "./src/deploy";
|
|
import { runProviderCommand } from "./src/provider-attach";
|
|
import { runScheduleCommand } from "./src/schedules";
|
|
import { parseNetworkPerfOptions, runNetworkPerf } from "./src/network-perf";
|
|
import { runCiCommand } from "./src/ci";
|
|
import { runSwapCommand } from "./src/swap";
|
|
import { runDevEnvCommand } from "./src/dev-env";
|
|
|
|
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
|
const args = remoteOptions.args;
|
|
const commandName = args.join(" ") || "help";
|
|
|
|
function help(): unknown {
|
|
return {
|
|
entry: "bun scripts/cli.ts",
|
|
output: "json",
|
|
commands: [
|
|
{ command: "help", description: "List supported commands." },
|
|
{ command: "--main-server-ip <ip> <command>", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." },
|
|
{ command: "config show", description: "Validate and print config.json as the single source of truth." },
|
|
{ command: "check [--full|--files|--scripts-typecheck|--components|--compose|--logs|--rust]", description: "Run the lightweight default syntax/config gate; Rust is opt-in and only allowed from D601 CI/dev execution." },
|
|
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server user services." },
|
|
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
|
|
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
|
|
{ command: "server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]", description: "Inspect or idempotently create host swap for low-memory main-server operation." },
|
|
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
|
|
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Build first, then serialize, force-recreate, and validate one Compose service." },
|
|
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force]", description: "Generate the minimal external provider-gateway env/compose bundle; only master server URL and provider id are required." },
|
|
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge with built-in remote helper tools in PATH." },
|
|
{ command: "ssh <providerId> apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
|
|
{ command: "ssh <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
|
|
{ command: "ssh <providerId> skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." },
|
|
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
|
|
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
|
|
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a remote command with each argv token shell-quoted by UniDesk before SSH passthrough." },
|
|
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
|
|
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
|
|
{ command: "microservice health <id>", description: "Probe one user service through backend-core -> provider-gateway HTTP proxy." },
|
|
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." },
|
|
{ command: "microservice diagnostics <id>", description: "Split k3sctl-managed proxy health into provider-gateway, HTTP tunnel, adapter, Kubernetes API service proxy, and target Service checks." },
|
|
{ command: "microservice tunnel-self-test <id>", description: "Trigger an expected provider HTTP tunnel failure and verify requestId/stage diagnostics are returned." },
|
|
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision] [--level G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--linked-goal-id id] [--evidence url]", description: "Upload a meeting note or decision record through backend-core -> decision-center user-service proxy." },
|
|
{ command: "decision diary import <markdown-file> [--source-file path] [--tag tag] [--include-entries]", description: "Import a dated work log Markdown into PostgreSQL diary entries split as YYYY-MM/YYYY-MM-DD.md." },
|
|
{ command: "decision diary list [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "List daily Markdown diary entries stored by Decision Center." },
|
|
{ command: "decision diary months", description: "List available Decision Center diary months with day counts." },
|
|
{ command: "decision diary show <YYYY-MM-DD|id>", description: "Show one daily diary Markdown entry." },
|
|
{ command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." },
|
|
{ command: "decision show <id>", description: "Show one Decision Center record." },
|
|
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and can apply supported dev services." },
|
|
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
|
|
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." },
|
|
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
|
|
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Compatibility wrapper for deploy apply --service code-queue with a temporary repo+commit manifest." },
|
|
{ command: "codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]", description: "Submit a Code Queue task through backend-core -> code-queue proxy; --dry-run shows the structured request without enqueueing." },
|
|
{ command: "codex task <taskId> [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch a compact Code Queue task summary; trace rows are opt-in and paged with next/previous commands to avoid output explosion." },
|
|
{ command: "codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Code Queue output records by seq when a trace row has omitted command/output text." },
|
|
{ command: "codex judge <taskId> --attempt N [--dry-run] [--include-prompt]", description: "Replay one stored Code Queue attempt through the same judge context builder and MiniMax judge call path used by the live queue worker." },
|
|
{ command: "codex interrupt|cancel <taskId>", description: "Request interrupt for a running Code Queue task, or cancel a queued/retry_wait task, through the same private proxy." },
|
|
{ command: "codex (queues | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>)", description: "List/create/merge Code Queue lanes and move a queued task; merge preserves task queue time order and deletes the source queue record." },
|
|
{ command: "job list [--limit N] [--include-command]", description: "List async jobs from .state/jobs with a bounded default page." },
|
|
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
|
|
{ command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." },
|
|
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
|
|
{ command: "debug task <taskId|latest>", description: "Read a dispatched task record from internal core for CLI debugging." },
|
|
{ command: "network perf [--service code-queue --path /api/tasks/overview?limit=30 --count N --concurrency N --label before|after]", description: "Benchmark frontend -> backend-core -> provider/adapter user-service networking and report latency/proxy-mode distributions." },
|
|
{ command: "ci install|status|run|run-dev-e2e|logs", description: "Manage D601 k3s Tekton CI only; run-dev-e2e manually validates master deploy.json dev state in an isolated temporary namespace." },
|
|
{ command: "e2e run [--only pattern[,pattern...]] [--skip pattern[,pattern...]]", description: "Run selected public/internal/Playwright E2E checks; use --only for focused iteration and rerun without filters for final regression." },
|
|
],
|
|
};
|
|
}
|
|
|
|
function isHelpToken(value: string | undefined): boolean {
|
|
return value === "help" || value === "--help" || value === "-h";
|
|
}
|
|
|
|
function serverHelp(action: string | undefined = undefined): unknown {
|
|
return {
|
|
command: action === undefined || isHelpToken(action) ? "server start|stop|status|swap|logs|rebuild" : `server ${action}`,
|
|
output: "json",
|
|
description: "Manage the fixed main-server Docker Compose stack without exposing backend-core REST publicly.",
|
|
usage: {
|
|
start: "bun scripts/cli.ts server start",
|
|
stop: "bun scripts/cli.ts server stop",
|
|
status: "bun scripts/cli.ts server status",
|
|
swap: "bun scripts/cli.ts server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]",
|
|
logs: "bun scripts/cli.ts server logs [--tail-bytes N]",
|
|
rebuild: "bun scripts/cli.ts server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>",
|
|
},
|
|
publicEntrypoints: {
|
|
frontend: "prod UniDesk frontend",
|
|
devFrontend: "dev UniDesk frontend proxy to D601 unidesk-dev/frontend-dev",
|
|
providerIngress: "provider-gateway WebSocket ingress",
|
|
},
|
|
rustBoundary: {
|
|
masterServer: "do not use server rebuild backend-core for Rust iteration; it would build locally",
|
|
d601: "use deploy apply --env dev --service backend-core and CI for Rust build/check",
|
|
},
|
|
};
|
|
}
|
|
|
|
function sshHelp(): unknown {
|
|
return {
|
|
command: "ssh",
|
|
output: "json",
|
|
description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge.",
|
|
usage: [
|
|
"bun scripts/cli.ts ssh <providerId>",
|
|
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
|
|
"bun scripts/cli.ts ssh <providerId> apply-patch < patch.diff",
|
|
"bun scripts/cli.ts ssh <providerId> py [script-args...] < script.py",
|
|
"bun scripts/cli.ts ssh <providerId> skills [--scope all|wsl|windows] [--limit N]",
|
|
"bun scripts/cli.ts ssh <providerId> find <path...> [--contains TEXT] [--limit N]",
|
|
"bun scripts/cli.ts ssh <providerId> glob [--root DIR] [--pattern PATTERN]",
|
|
],
|
|
notes: [
|
|
"ssh --help and ssh <providerId> --help print this JSON help and never open an interactive session.",
|
|
"Use argv when nested shell quoting would be fragile.",
|
|
"Use -- before a remote command that intentionally starts with a dash.",
|
|
],
|
|
};
|
|
}
|
|
|
|
function numberOption(name: string, defaultValue: number): number {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return defaultValue;
|
|
const raw = args[index + 1];
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
function boundedNumberOption(name: string, defaultValue: number, maxValue: number): number {
|
|
return Math.min(numberOption(name, defaultValue), maxValue);
|
|
}
|
|
|
|
function stringOption(name: string): string | undefined {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return undefined;
|
|
const raw = args[index + 1];
|
|
if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`);
|
|
return raw;
|
|
}
|
|
|
|
function jsonOption(name: string): Record<string, unknown> | undefined {
|
|
const raw = stringOption(name);
|
|
if (raw === undefined) return undefined;
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${name} must be a JSON object`);
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
function dispatchPayload(command: DebugDispatchCommand): Record<string, unknown> {
|
|
const explicit = jsonOption("--payload-json") ?? {};
|
|
if (command === "provider.upgrade") {
|
|
return { source: "cli-debug", mode: stringOption("--mode") ?? stringOption("--upgrade-mode") ?? "plan", ...explicit };
|
|
}
|
|
if (command === "host.ssh") {
|
|
const sshCommand = stringOption("--ssh-command");
|
|
return {
|
|
source: "cli-debug",
|
|
mode: sshCommand === undefined ? "probe" : "exec",
|
|
...(sshCommand === undefined ? {} : { command: sshCommand }),
|
|
...(stringOption("--cwd") === undefined ? {} : { cwd: stringOption("--cwd") }),
|
|
...(args.includes("--timeout-ms") ? { timeoutMs: numberOption("--timeout-ms", 8000) } : {}),
|
|
...explicit,
|
|
};
|
|
}
|
|
return { source: "cli-debug", ...explicit };
|
|
}
|
|
|
|
function latestJobId(): string {
|
|
const jobs = listJobs();
|
|
if (jobs.length === 0) throw new Error("No jobs found");
|
|
return jobs[0].id;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
if (remoteOptions.host !== null) {
|
|
process.exitCode = await runRemoteCli(remoteOptions, readConfig());
|
|
return;
|
|
}
|
|
|
|
const [top, sub, third, fourth] = args;
|
|
if (top === undefined || top === "help" || top === "--help" || top === "-h") {
|
|
emitJson(commandName, help());
|
|
return;
|
|
}
|
|
|
|
if (top === "ssh" && (sub === undefined || isHelpToken(sub) || (isHelpToken(third) && args.length === 3))) {
|
|
emitJson(commandName, sshHelp());
|
|
return;
|
|
}
|
|
|
|
if (top === "internal" && sub === "run-job") {
|
|
if (!third) throw new Error("internal run-job requires job id");
|
|
emitJson(commandName, await runJob(third));
|
|
return;
|
|
}
|
|
|
|
if (top === "dev-env") {
|
|
const result = runDevEnvCommand(args.slice(1));
|
|
const ok = (result as { ok?: unknown }).ok !== false;
|
|
emitJson(commandName, result, ok);
|
|
if (!ok) process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const config = readConfig();
|
|
|
|
if (top === "ssh") {
|
|
const exitCode = await runSsh(config, sub ?? "", args.slice(2));
|
|
process.exitCode = exitCode;
|
|
return;
|
|
}
|
|
|
|
if (top === "config" && sub === "show") {
|
|
emitJson(commandName, { config });
|
|
return;
|
|
}
|
|
|
|
if (top === "check") {
|
|
if (isHelpToken(sub)) {
|
|
emitJson(commandName, checkHelp());
|
|
return;
|
|
}
|
|
const result = runChecks(config, parseCheckOptions(args.slice(1)));
|
|
emitJson(commandName, result, result.ok);
|
|
if (!result.ok) process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (top === "server") {
|
|
if (isHelpToken(sub) || args.slice(2).some(isHelpToken)) {
|
|
emitJson(commandName, serverHelp(isHelpToken(sub) ? undefined : sub));
|
|
return;
|
|
}
|
|
if (sub === "start") {
|
|
emitJson(commandName, startStack(config));
|
|
return;
|
|
}
|
|
if (sub === "stop") {
|
|
emitJson(commandName, stopStack(config));
|
|
return;
|
|
}
|
|
if (sub === "status") {
|
|
emitJson(commandName, await stackStatus(config));
|
|
return;
|
|
}
|
|
if (sub === "swap") {
|
|
const result = runSwapCommand(args.slice(2));
|
|
const ok = (result as { ok?: unknown }).ok !== false;
|
|
emitJson(commandName, result, ok);
|
|
if (!ok) process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (sub === "logs") {
|
|
emitJson(commandName, stackLogs(config, boundedNumberOption("--tail-bytes", 3000, 500_000)));
|
|
return;
|
|
}
|
|
if (sub === "rebuild") {
|
|
if (!isRebuildableService(third)) {
|
|
throw new Error("server rebuild requires one of: backend-core, frontend, dev-frontend-proxy, provider-gateway, todo-note, code-queue-mgr, project-manager, baidu-netdisk, oa-event-flow");
|
|
}
|
|
emitJson(commandName, rebuildService(config, third));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (top === "microservice") {
|
|
emitJson(commandName, await runMicroserviceCommand(config, args.slice(1)));
|
|
return;
|
|
}
|
|
|
|
if (top === "decision" || top === "decision-center") {
|
|
emitJson(commandName, await runDecisionCenterCommand(config, args.slice(1)));
|
|
return;
|
|
}
|
|
|
|
if (top === "deploy") {
|
|
const result = await runDeployCommand(config, args.slice(1));
|
|
const ok = (result as { ok?: unknown }).ok !== false;
|
|
emitJson(commandName, result, ok);
|
|
if (!ok) process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (top === "provider") {
|
|
emitJson(commandName, await runProviderCommand(config, args.slice(1)));
|
|
return;
|
|
}
|
|
|
|
if (top === "schedule") {
|
|
emitJson(commandName, await runScheduleCommand(config, args.slice(1)));
|
|
return;
|
|
}
|
|
|
|
if (top === "codex") {
|
|
if (sub === "deploy") {
|
|
const result = await runCodeQueueDeployCompatCommand(config, args.slice(2));
|
|
const ok = (result as { ok?: unknown }).ok !== false;
|
|
emitJson(commandName, result, ok);
|
|
if (!ok) process.exitCode = 1;
|
|
return;
|
|
}
|
|
emitJson(commandName, await runCodeQueueCommand(config, args.slice(1)));
|
|
return;
|
|
}
|
|
|
|
if (top === "job") {
|
|
if (sub === "list") {
|
|
emitJson(commandName, listJobsSummary({ limit: boundedNumberOption("--limit", 50, 500), includeCommand: args.includes("--include-command") }));
|
|
return;
|
|
}
|
|
if (sub === "status") {
|
|
const id = third === "latest" || third === undefined ? latestJobId() : third;
|
|
emitJson(commandName, { job: jobWithTail(readJob(id), boundedNumberOption("--tail-bytes", 12000, 500_000)) });
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (top === "debug") {
|
|
if (sub === "health") {
|
|
emitJson(commandName, await debugHealth(config));
|
|
return;
|
|
}
|
|
if (sub === "dispatch") {
|
|
const providerId = isDebugDispatchCommand(third) ? config.providerGateway.id : third ?? config.providerGateway.id;
|
|
const commandArg = isDebugDispatchCommand(third) ? third : fourth;
|
|
const dispatchCommand = isDebugDispatchCommand(commandArg) ? commandArg : "docker.ps";
|
|
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand, dispatchPayload(dispatchCommand), numberOption("--wait-ms", 0)));
|
|
return;
|
|
}
|
|
if (sub === "task") {
|
|
emitJson(commandName, await debugTask(config, third ?? "latest"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (top === "network" && sub === "perf") {
|
|
emitJson(commandName, await runNetworkPerf(parseNetworkPerfOptions(config, args.slice(2))));
|
|
return;
|
|
}
|
|
|
|
if (top === "ci") {
|
|
const result = await runCiCommand(config, args.slice(1));
|
|
const ok = (result as { ok?: unknown }).ok !== false;
|
|
emitJson(commandName, result, ok);
|
|
if (!ok) process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (top === "e2e" && sub === "run") {
|
|
const result = await runE2E(config, parseE2ERunOptions(args.slice(2)));
|
|
const ok = (result as { ok?: unknown }).ok === true;
|
|
emitJson(commandName, result, ok);
|
|
if (!ok) process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unknown command: ${commandName}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
emitError(commandName, error);
|
|
process.exitCode = 1;
|
|
});
|