Files
pikasTech-unidesk/scripts/cli.ts
T
2026-05-11 07:39:37 +00:00

224 lines
11 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, readJob, runJob } from "./src/jobs";
import { runChecks } from "./src/check";
import { runSsh } from "./src/ssh";
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
import { runMicroserviceCommand } from "./src/microservices";
import { runCodexQueueCommand } from "./src/codex-queue";
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", description: "Run config, TypeScript, file presence, and docker-compose config checks." },
{ 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 logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "server rebuild <backend-core|frontend|provider-gateway|todo-note|codex-queue|project-manager>", description: "Build first, then serialize, force-recreate, and validate one Compose service." },
{ 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> [--raw] [--max-body-bytes N]", description: "GET a private user-service backend path through the same frontend-only proxy used by WebUI; large bodies are summarized unless --raw is set." },
{ command: "codex task <taskId> [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch a compact Codex 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 Codex Queue output records by seq when a trace row has omitted command/output text." },
{ command: "codex queues | codex queue create <queueId> | codex move <taskId> --queue <queueId>", description: "List/create Codex Queue lanes and move a queued task so each queue runs serially while queues run in parallel." },
{ command: "job list", description: "List async jobs from .state/jobs." },
{ 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: "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 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 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 === "internal" && sub === "run-job") {
if (!third) throw new Error("internal run-job requires job id");
emitJson(commandName, await runJob(third));
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") {
const result = runChecks(config);
emitJson(commandName, result, result.ok);
if (!result.ok) process.exitCode = 1;
return;
}
if (top === "server") {
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 === "logs") {
emitJson(commandName, stackLogs(config, numberOption("--tail-bytes", 3000)));
return;
}
if (sub === "rebuild") {
if (!isRebuildableService(third)) {
throw new Error("server rebuild requires one of: backend-core, frontend, provider-gateway, todo-note, codex-queue, project-manager");
}
emitJson(commandName, rebuildService(config, third));
return;
}
}
if (top === "microservice") {
emitJson(commandName, await runMicroserviceCommand(config, args.slice(1)));
return;
}
if (top === "codex") {
emitJson(commandName, await runCodexQueueCommand(config, args.slice(1)));
return;
}
if (top === "job") {
if (sub === "list") {
emitJson(commandName, { jobs: listJobs() });
return;
}
if (sub === "status") {
const id = third === "latest" || third === undefined ? latestJobId() : third;
emitJson(commandName, { job: jobWithTail(readJob(id), numberOption("--tail-bytes", 12000)) });
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 === "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;
});