import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import type { RenderedCliResult } from "./output"; import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy"; import { asRecord, booleanField, integerField, parseOpsApplyOptions, parseOpsCommonOptions, readYamlRecord, recordField, stringField, } from "./platform-infra-ops-library"; const configPath = rootPath("config", "platform-infra", "webterm.yaml"); const serviceName = "webterm"; interface WebtermConfig { defaults: { targetId: string }; sessionStartModes: SessionStartModes; autoResumePrompt: AutoResumePrompt; browserTerminalHistory: BrowserTerminalHistory; browserOutputPolicy: BrowserOutputPolicy; browserPerformance: BrowserPerformance; targets: WebtermTarget[]; } interface SessionStartModes { defaultMode: string; options: Array<{ id: string; label: string; command: string }>; } interface AutoResumePrompt { enabled: boolean; prompt: string; sendEnter: boolean; enterDelayMs: number; readiness: { minDelayMs: number; minOutputBytes: number; quietPeriodMs: number; timeoutMs: number }; } interface BrowserTerminalHistory { desktopScrollbackLines: number; mobileScrollbackLines: number; mobileMaxWidthPx: number; } interface BrowserOutputPolicy { maxFramesPerSecond: number; maxFrameBytes: number; compression: { enabled: boolean; thresholdBytes: number; level: number; memLevel: number; concurrencyLimit: number; serverContextTakeover: boolean; clientContextTakeover: boolean }; } interface BrowserPerformance { animationsEnabled: boolean; defaultBackgroundMode: "disconnect" | "receive-only" | "live"; backgroundModes: Array<{ id: "disconnect" | "receive-only" | "live"; label: string }>; deferredOutputMaxBytes: number; dashboardSampleWindowSeconds: number; dashboardRefreshIntervalMs: number; } interface WebtermTarget { id: string; enabled: boolean; runtime: { mode: "host-docker"; workDir: string; composePath: string; composeProject: string; serviceName: string; containerName: string; image: string; envFile: string; hostPort: number; containerPort: number; privileged: boolean; pid: "host" | "container"; sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; networkSpeedTest: { enabled: boolean; containerName: string; hostPort: number; containerPort: number; publicUrl: string; downloadBytes: number; uploadBytes: number; maxDurationMs: number } | null; autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean; fallbackShell: boolean }>; sessionStartModes: SessionStartModes; autoResumePrompt: AutoResumePrompt; browserTerminalHistory: BrowserTerminalHistory; browserOutputPolicy: BrowserOutputPolicy; browserPerformance: BrowserPerformance; environment: Record; }; publicExposure: { enabled: boolean; publicBaseUrl: string; dns: { hostname: string; expectedA: string; resolvers: string[] }; upstream: { host: string; port: number }; pk01: { route: string; caddyConfigPath: string; caddyServiceName: string; responseHeaderTimeoutSeconds: number; }; }; } export async function runWebtermCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [action = "plan", ...rest] = args; if (action === "plan") { const result = plan(parseCommon(rest).targetId); return wantsFull(rest) ? result : renderPlan(result); } if (action === "apply") { const options = parseOpsApplyOptions(rest); const result = await apply(config, options.targetId, options.confirm); return options.full || options.raw ? result : renderApply(result); } if (action === "status") { const options = parseCommon(rest); const result = status(options.targetId); return options.full || options.raw ? result : renderStatus(result); } if (action === "validate") { const options = parseCommon(rest); const result = validate(options.targetId); return options.full || options.raw ? result : renderValidate(result); } if (action === "logs") { const options = parseOpsCommonOptions(rest, { stringOptions: ["--tail"] }); const result = logs(options.targetId, parseLogTail(options.tail)); return options.full || options.raw ? result : renderLogs(result); } return { ok: false, error: "unsupported-webterm-command", args, help: webtermHelp(), }; } export function webtermHelp(): Record { return { command: "platform-infra webterm plan|apply|status|validate|logs", examples: [ "bun scripts/cli.ts platform-infra webterm plan", "bun scripts/cli.ts platform-infra webterm apply --dry-run", "bun scripts/cli.ts platform-infra webterm apply --confirm", "bun scripts/cli.ts platform-infra webterm status", "bun scripts/cli.ts platform-infra webterm validate", "bun scripts/cli.ts platform-infra webterm logs --target local-7682 --tail 100", ], configPath, description: "Deploy target-scoped host-Docker Web Terminal instances from YAML and optionally expose them through PK01 Caddy HTTPS.", }; } function plan(targetId: string | null): Record { const target = resolveTarget(targetId); const compose = renderCompose(target); return { ok: true, service: serviceName, configPath, target: targetSummary(target), compose: { path: target.runtime.composePath, project: target.runtime.composeProject, service: target.runtime.serviceName, container: target.runtime.containerName, fingerprint: fingerprint(compose), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, }, publicExposure: exposureSummary(target), policy: policyChecks(target, compose), next: { dryRun: "bun scripts/cli.ts platform-infra webterm apply --dry-run", apply: "bun scripts/cli.ts platform-infra webterm apply --confirm", status: "bun scripts/cli.ts platform-infra webterm status", validate: "bun scripts/cli.ts platform-infra webterm validate", }, valuesPrinted: false, }; } async function apply(config: UniDeskConfig, targetId: string | null, confirm: boolean): Promise> { const target = resolveTarget(targetId); const compose = renderCompose(target); const policy = policyChecks(target, compose); const failedPolicy = policy.filter((item) => item.ok === false); if (!confirm) { return { ok: failedPolicy.length === 0, mode: "dry-run", target: targetSummary(target), compose: { path: target.runtime.composePath, fingerprint: fingerprint(compose), preview: compose }, publicExposure: exposureSummary(target), policy, valuesPrinted: false, }; } if (failedPolicy.length > 0) { return { ok: false, mode: "policy-blocked", target: targetSummary(target), policy, valuesPrinted: false, }; } mkdirSync(dirname(target.runtime.composePath), { recursive: true }); writeFileSync(target.runtime.composePath, compose, { encoding: "utf8", mode: 0o644 }); const composeResult = spawnSync("docker", ["compose", "-f", target.runtime.composePath, "-p", target.runtime.composeProject, "up", "-d", "--force-recreate"], { cwd: target.runtime.workDir, encoding: "utf8", }); const localProbe = providerHealthProbeWithRetry(target.runtime.hostPort, 10, 500); const caddy = target.publicExposure.enabled ? await applyPk01CaddySiteBlock( config, serviceName, caddyExposure(target), renderWebtermCaddySiteBlock({ hostname: target.publicExposure.dns.hostname, upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`, responseHeaderTimeoutSeconds: target.publicExposure.pk01.responseHeaderTimeoutSeconds, }), caddyManagedBlockMarkers(serviceName), ) : { ok: true, action: "not-enabled" }; const publicProbe = target.publicExposure.enabled ? publicHttpProbeWithRetry(target.publicExposure.publicBaseUrl, "/login", 6, 1000) : { ok: true, skipped: true }; return { ok: composeResult.status === 0 && localProbe.ok === true && caddy.ok !== false && publicProbe.ok === true, mode: "confirmed", target: targetSummary(target), compose: { path: target.runtime.composePath, project: target.runtime.composeProject, fingerprint: fingerprint(compose), up: commandSummary(composeResult), }, localProbe, pk01Caddy: caddy, publicProbe, valuesPrinted: false, }; } function status(targetId: string | null): Record { const target = resolveTarget(targetId); const inspect = spawnSync("docker", ["inspect", target.runtime.containerName], { encoding: "utf8" }); const ps = spawnSync("docker", ["ps", "--filter", `name=^/${target.runtime.containerName}$`, "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], { encoding: "utf8" }); const localProbe = providerHealthProbe(target.runtime.hostPort); const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; return { ok: ps.status === 0 && ps.stdout.trim().length > 0 && localProbe.ok === true && publicProbe.ok === true, target: targetSummary(target), compose: { path: target.runtime.composePath, exists: existsSync(target.runtime.composePath), fingerprint: existsSync(target.runtime.composePath) ? fingerprint(readFileSync(target.runtime.composePath, "utf8")) : null, }, container: { exists: inspect.status === 0, ps: ps.stdout.trim(), }, localProbe, publicProbe, valuesPrinted: false, }; } function validate(targetId: string | null): Record { const target = resolveTarget(targetId); const dns = dnsProbe(target.publicExposure.dns.hostname); const localProbe = providerHealthProbe(target.runtime.hostPort); const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; return { ok: localProbe.ok === true && publicProbe.ok === true && dns.addresses.includes(target.publicExposure.dns.expectedA), target: targetSummary(target), dns, localProbe, publicProbe, valuesPrinted: false, }; } function logs(targetId: string | null, tail: number): Record { const target = resolveTarget(targetId); const result = spawnSync("docker", ["logs", "--tail", String(tail), target.runtime.containerName], { encoding: "utf8" }); const output = `${result.stdout}${result.stderr}`.slice(-20000); return { ok: result.status === 0, target: targetSummary(target), tail, logs: output, truncated: result.stdout.length + result.stderr.length > output.length, exitCode: result.status, valuesPrinted: false, }; } function parseLogTail(value: unknown): number { if (value === null || value === undefined) return 100; const tail = Number(value); if (!Number.isInteger(tail) || tail < 1 || tail > 1000) throw new Error("--tail must be an integer from 1 to 1000"); return tail; } function readWebtermConfig(): WebtermConfig { const yaml = readYamlRecord(configPath, "platform-infra-webterm"); const defaults = recordField(yaml, "defaults", "webterm"); const sessionStartModes = parseSessionStartModes(recordField(yaml, "sessionStartModes", "webterm"), "webterm.sessionStartModes"); const autoResumePrompt = parseAutoResumePrompt(recordField(yaml, "autoResumePrompt", "webterm"), "webterm.autoResumePrompt"); const browserTerminalHistory = parseBrowserTerminalHistory(recordField(yaml, "browserTerminalHistory", "webterm"), "webterm.browserTerminalHistory"); const browserOutputPolicy = parseBrowserOutputPolicy(recordField(yaml, "browserOutputPolicy", "webterm"), "webterm.browserOutputPolicy"); const browserPerformance = parseBrowserPerformance(recordField(yaml, "browserPerformance", "webterm"), "webterm.browserPerformance"); const targetsRaw = yaml.targets; if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); return { defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, sessionStartModes, autoResumePrompt, browserTerminalHistory, browserOutputPolicy, browserPerformance, targets: targetsRaw.map((item, index) => parseTarget( asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes, autoResumePrompt, browserTerminalHistory, browserOutputPolicy, browserPerformance, )), }; } function parseTarget( record: Record, path: string, sessionStartModes: SessionStartModes, autoResumePrompt: AutoResumePrompt, browserTerminalHistory: BrowserTerminalHistory, browserOutputPolicy: BrowserOutputPolicy, browserPerformance: BrowserPerformance, ): WebtermTarget { const runtime = recordField(record, "runtime", path); const exposure = recordField(record, "publicExposure", path); const dns = recordField(exposure, "dns", `${path}.publicExposure`); const upstream = recordField(exposure, "upstream", `${path}.publicExposure`); const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`); const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`); const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`); const autoStartSessions = parseAutoStartSessions(runtime.autoStartSessions, `${path}.runtime.autoStartSessions`); const speedRaw = runtime.networkSpeedTest === undefined ? null : recordField(runtime, "networkSpeedTest", `${path}.runtime`); const networkSpeedTest = speedRaw === null ? null : { enabled: booleanField(speedRaw, "enabled", `${path}.runtime.networkSpeedTest`), containerName: stringField(speedRaw, "containerName", `${path}.runtime.networkSpeedTest`), hostPort: portField(speedRaw, "hostPort", `${path}.runtime.networkSpeedTest`), containerPort: portField(speedRaw, "containerPort", `${path}.runtime.networkSpeedTest`), publicUrl: stringField(speedRaw, "publicUrl", `${path}.runtime.networkSpeedTest`), downloadBytes: integerField(speedRaw, "downloadBytes", `${path}.runtime.networkSpeedTest`), uploadBytes: integerField(speedRaw, "uploadBytes", `${path}.runtime.networkSpeedTest`), maxDurationMs: integerField(speedRaw, "maxDurationMs", `${path}.runtime.networkSpeedTest`), }; const target: WebtermTarget = { id: stringField(record, "id", path), enabled: booleanField(record, "enabled", path), runtime: { mode: parseLiteral(stringField(runtime, "mode", `${path}.runtime`), "host-docker", `${path}.runtime.mode`), workDir: stringField(runtime, "workDir", `${path}.runtime`), composePath: stringField(runtime, "composePath", `${path}.runtime`), composeProject: stringField(runtime, "composeProject", `${path}.runtime`), serviceName: stringField(runtime, "serviceName", `${path}.runtime`), containerName: stringField(runtime, "containerName", `${path}.runtime`), image: stringField(runtime, "image", `${path}.runtime`), envFile: stringField(runtime, "envFile", `${path}.runtime`), hostPort: portField(runtime, "hostPort", `${path}.runtime`), containerPort: portField(runtime, "containerPort", `${path}.runtime`), privileged: booleanField(runtime, "privileged", `${path}.runtime`), pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`), sourceMounts, networkSpeedTest, autoStartSessions, sessionStartModes, autoResumePrompt, browserTerminalHistory, browserOutputPolicy, browserPerformance, environment, }, publicExposure: { enabled: booleanField(exposure, "enabled", `${path}.publicExposure`), publicBaseUrl: stringField(exposure, "publicBaseUrl", `${path}.publicExposure`), dns: { hostname: stringField(dns, "hostname", `${path}.publicExposure.dns`), expectedA: stringField(dns, "expectedA", `${path}.publicExposure.dns`), resolvers: stringArray(dns.resolvers, `${path}.publicExposure.dns.resolvers`), }, upstream: { host: stringField(upstream, "host", `${path}.publicExposure.upstream`), port: portField(upstream, "port", `${path}.publicExposure.upstream`), }, pk01: { route: stringField(pk01, "route", `${path}.publicExposure.pk01`), caddyConfigPath: stringField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`), caddyServiceName: stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`), responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`), }, }, }; if (!target.publicExposure.publicBaseUrl.startsWith("https://")) throw new Error(`${path}.publicExposure.publicBaseUrl must be https`); return target; } function resolveTarget(targetId: string | null): WebtermTarget { const config = readWebtermConfig(); const id = targetId ?? config.defaults.targetId; const target = config.targets.find((item) => item.id === id); if (target === undefined) throw new Error(`unknown webterm target: ${id}`); return target; } function renderCompose(target: WebtermTarget): string { const environment = { ...target.runtime.environment, AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions.map((session) => ({ ...session, command: resumeCommandWithPrompt(session, target.runtime.autoResumePrompt), }))), SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options), SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode, AUTO_RESUME_PROMPT_JSON: JSON.stringify(target.runtime.autoResumePrompt), BROWSER_TERMINAL_HISTORY_JSON: JSON.stringify(target.runtime.browserTerminalHistory), BROWSER_OUTPUT_POLICY_JSON: JSON.stringify(target.runtime.browserOutputPolicy), BROWSER_PERFORMANCE_JSON: JSON.stringify(target.runtime.browserPerformance), ...(target.runtime.networkSpeedTest?.enabled ? { NETWORK_SPEED_TEST_PUBLIC_URL: target.runtime.networkSpeedTest.publicUrl, NETWORK_SPEED_TEST_DOWNLOAD_BYTES: String(target.runtime.networkSpeedTest.downloadBytes), NETWORK_SPEED_TEST_UPLOAD_BYTES: String(target.runtime.networkSpeedTest.uploadBytes), NETWORK_SPEED_TEST_MAX_DURATION_MS: String(target.runtime.networkSpeedTest.maxDurationMs), } : {}), }; const envLines = Object.entries(environment) .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) .join("\n"); const volumeLines = target.runtime.sourceMounts .map((mount) => ` - ${quoteYaml(`${mount.source}:${mount.target}${mount.readOnly ? ":ro" : ""}`)}`) .join("\n"); const speed = target.runtime.networkSpeedTest; const speedTestService = speed?.enabled ? ` network-speed-test: image: ${target.runtime.image} container_name: ${speed.containerName} restart: unless-stopped command: ["node", "src/speed-server.js"] ports: - "${speed.hostPort}:${speed.containerPort}" volumes: - "/root/webterm/src:/app/src:ro" environment: SPEED_TEST_HOST: "0.0.0.0" SPEED_TEST_PORT: "${speed.containerPort}" ` : ""; return `name: ${target.runtime.composeProject} services: ${target.runtime.serviceName}: image: ${target.runtime.image} container_name: ${target.runtime.containerName} restart: unless-stopped ports: - "${target.runtime.hostPort}:${target.runtime.containerPort}" privileged: ${target.runtime.privileged ? "true" : "false"} pid: ${target.runtime.pid} env_file: - ${target.runtime.envFile} volumes: ${volumeLines} environment: ${envLines} ${speedTestService}`; } function resumeCommandWithPrompt( session: WebtermTarget["runtime"]["autoStartSessions"][number], prompt: AutoResumePrompt, ): string { let command = session.command.trim(); if (session.resumePrompt) { if (!/^\s*(?:mycx|oncx)\s+resume\s+\S+\s*$/.test(session.command)) { throw new Error(`resumePrompt requires a mycx/oncx resume command: ${session.title}`); } command = `${command} ${quoteShellArg(prompt.prompt)}`; } return session.fallbackShell ? `${command}; exec /bin/bash -l` : command; } function quoteShellArg(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } function policyChecks(target: WebtermTarget, compose: string): Array & { ok: boolean }> { return [ { name: "enabled", ok: target.enabled, detail: "target must be enabled" }, { name: "compose-path", ok: target.runtime.composePath.endsWith(".yaml"), detail: target.runtime.composePath }, { name: "container-name", ok: compose.includes(`container_name: ${target.runtime.containerName}`), detail: target.runtime.containerName }, { name: "public-https", ok: target.publicExposure.publicBaseUrl.startsWith("https://"), detail: target.publicExposure.publicBaseUrl }, { name: "compose-port", ok: compose.includes(`"${target.runtime.hostPort}:${target.runtime.containerPort}"`), detail: `${target.runtime.hostPort}:${target.runtime.containerPort}` }, { name: "source-mounts-present", ok: target.runtime.sourceMounts.length >= 3, detail: `mounts=${target.runtime.sourceMounts.length}` }, { name: "network-speed-test", ok: !target.runtime.networkSpeedTest?.enabled || compose.includes(`container_name: ${target.runtime.networkSpeedTest.containerName}`), detail: target.runtime.networkSpeedTest?.enabled ? `${target.runtime.networkSpeedTest.hostPort}:${target.runtime.networkSpeedTest.containerPort}` : "disabled" }, ]; } function caddyExposure(target: WebtermTarget): Parameters[2] { return { enabled: target.publicExposure.enabled, dns: { hostname: target.publicExposure.dns.hostname }, frpc: { remotePort: target.publicExposure.upstream.port }, pk01: target.publicExposure.pk01, }; } function renderWebtermCaddySiteBlock(params: { hostname: string; upstream: string; responseHeaderTimeoutSeconds: number; }): string { return `${params.hostname} { reverse_proxy ${params.upstream} { flush_interval -1 stream_close_delay 5m transport http { response_header_timeout ${params.responseHeaderTimeoutSeconds}s } } }`; } function targetSummary(target: WebtermTarget): Record { return { id: target.id, enabled: target.enabled, mode: target.runtime.mode, workDir: target.runtime.workDir, composeProject: target.runtime.composeProject, containerName: target.runtime.containerName, image: target.runtime.image, autoStartSessions: target.runtime.autoStartSessions.map((session) => session.title), sessionStartModes: { defaultMode: target.runtime.sessionStartModes.defaultMode, options: target.runtime.sessionStartModes.options.map((mode) => mode.id), }, autoResumePrompt: { enabled: target.runtime.autoResumePrompt.enabled, taggedSessions: target.runtime.autoStartSessions.filter((session) => session.resumePrompt).map((session) => session.title), readiness: target.runtime.autoResumePrompt.readiness, promptBytes: Buffer.byteLength(target.runtime.autoResumePrompt.prompt), sendEnter: target.runtime.autoResumePrompt.sendEnter, enterDelayMs: target.runtime.autoResumePrompt.enterDelayMs, }, browserTerminalHistory: target.runtime.browserTerminalHistory, browserOutputPolicy: target.runtime.browserOutputPolicy, browserPerformance: target.runtime.browserPerformance, sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, }; } function exposureSummary(target: WebtermTarget): Record { return { enabled: target.publicExposure.enabled, publicBaseUrl: target.publicExposure.publicBaseUrl, hostname: target.publicExposure.dns.hostname, expectedA: target.publicExposure.dns.expectedA, upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`, pk01Route: target.publicExposure.pk01.route, marker: caddyManagedBlockMarkers(serviceName).start, }; } function localHttpProbe(port: number, path: string): Record { return httpProbe(`http://127.0.0.1:${port}`, path); } function publicHttpProbe(baseUrl: string, path: string): Record { return httpProbe(baseUrl, path); } function localHttpProbeWithRetry(port: number, path: string, attempts: number, delayMs: number): Record { return httpProbeWithRetry(() => localHttpProbe(port, path), attempts, delayMs); } function providerHealthProbe(port: number): Record { const url = `http://127.0.0.1:${port}/healthz`; const result = spawnSync("curl", ["-fsS", "--connect-timeout", "5", "--max-time", "15", url], { encoding: "utf8" }); let payload: Record = {}; try { payload = JSON.parse(result.stdout) as Record; } catch { payload = {}; } const providers = Number(payload.providers); const sessions = Number(payload.sessions); return { ok: result.status === 0 && payload.ok === true && Number.isInteger(providers) && providers >= 1, url, authenticated: false, providers: Number.isInteger(providers) ? providers : null, sessions: Number.isInteger(sessions) ? sessions : null, exitCode: result.status, error: result.status === 0 && providers < 1 ? "no-provider" : undefined, stderrTail: result.status === 0 ? "" : result.stderr.slice(-1000), }; } function providerHealthProbeWithRetry(port: number, attempts: number, delayMs: number): Record { return httpProbeWithRetry(() => providerHealthProbe(port), attempts, delayMs); } function publicHttpProbeWithRetry(baseUrl: string, path: string, attempts: number, delayMs: number): Record { return httpProbeWithRetry(() => publicHttpProbe(baseUrl, path), attempts, delayMs); } function httpProbeWithRetry(run: () => Record, attempts: number, delayMs: number): Record { let latest = run(); for (let attempt = 1; attempt < attempts && latest.ok !== true; attempt += 1) { spawnSync("sleep", [(delayMs / 1000).toFixed(3)]); latest = run(); } return { ...latest, attempts }; } function httpProbe(baseUrl: string, path: string): Record { const url = `${baseUrl.replace(/\/+$/u, "")}${path}`; const result = spawnSync("curl", ["-fsS", "-L", "--connect-timeout", "5", "--max-time", "15", "-o", "/dev/null", "-w", "%{http_code}", url], { encoding: "utf8" }); const status = Number(result.stdout.trim()); return { ok: result.status === 0 && Number.isInteger(status) && status >= 200 && status < 500, url, status: Number.isInteger(status) ? status : null, exitCode: result.status, stderrTail: result.status === 0 ? "" : result.stderr.slice(-1000), }; } function dnsProbe(hostname: string): Record & { addresses: string[] } { const result = spawnSync("getent", ["hosts", hostname], { encoding: "utf8" }); const addresses = result.stdout .split(/\r?\n/u) .map((line) => line.trim().split(/\s+/u)[0] ?? "") .filter((value) => /^\d+\.\d+\.\d+\.\d+$/u.test(value)); return { ok: result.status === 0 && addresses.length > 0, hostname, addresses, exitCode: result.status }; } function commandSummary(result: SpawnSyncReturns): Record { return { exitCode: result.status, stdoutTail: result.status === 0 ? result.stdout.slice(-2000) : result.stdout.slice(-4000), stderrTail: result.status === 0 ? "" : result.stderr.slice(-4000), }; } function parseCommon(args: string[]): ReturnType { return parseOpsCommonOptions(args); } function wantsFull(args: string[]): boolean { return args.includes("--full") || args.includes("--raw"); } function parseEnvironment(record: Record, path: string): Record { const result: Record = {}; for (const [key, value] of Object.entries(record)) { if (!/^[A-Z_][A-Z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} must be an env key`); if (typeof value !== "string") throw new Error(`${path}.${key} must be a string`); result[key] = value; } return result; } function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime"]["sourceMounts"] { if (!Array.isArray(value)) throw new Error(`${path} must be an array`); return value.map((item, index) => { const record = asRecord(item, `${path}[${index}]`); return { source: stringField(record, "source", `${path}[${index}]`), target: stringField(record, "target", `${path}[${index}]`), readOnly: booleanField(record, "readOnly", `${path}[${index}]`), }; }); } function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["runtime"]["autoStartSessions"] { if (!Array.isArray(value)) throw new Error(`${path} must be an array`); return value.map((item, index) => { const itemPath = `${path}[${index}]`; const record = asRecord(item, itemPath); return { title: stringField(record, "title", itemPath), cwd: stringField(record, "cwd", itemPath), command: stringField(record, "command", itemPath), cols: integerField(record, "cols", itemPath), rows: integerField(record, "rows", itemPath), resumePrompt: booleanField(record, "resumePrompt", itemPath), fallbackShell: booleanField(record, "fallbackShell", itemPath), }; }); } function parseSessionStartModes(record: Record, path: string): SessionStartModes { const defaultMode = stringField(record, "defaultMode", path); const optionsRecord = recordField(record, "options", path); const options = Object.entries(optionsRecord).map(([id, item]) => { const itemPath = `${path}.options.${id}`; const option = asRecord(item, itemPath); return { id, label: stringField(option, "label", itemPath), command: commandField(option, itemPath), }; }); if (options.length === 0) throw new Error(`${path}.options must be a non-empty record`); if (!options.some((option) => option.id === defaultMode)) throw new Error(`${path}.defaultMode must reference an option id`); return { defaultMode, options }; } function commandField(record: Record, path: string): string { const value = record.command; if (typeof value !== "string") throw new Error(`${path}.command must be a string`); return value.trim(); } function parseAutoResumePrompt(record: Record, path: string): AutoResumePrompt { const readiness = recordField(record, "readiness", path); return { enabled: booleanField(record, "enabled", path), prompt: stringField(record, "prompt", path), sendEnter: booleanField(record, "sendEnter", path), enterDelayMs: integerField(record, "enterDelayMs", path), readiness: { minDelayMs: integerField(readiness, "minDelayMs", `${path}.readiness`), minOutputBytes: integerField(readiness, "minOutputBytes", `${path}.readiness`), quietPeriodMs: integerField(readiness, "quietPeriodMs", `${path}.readiness`), timeoutMs: integerField(readiness, "timeoutMs", `${path}.readiness`), }, }; } function parseBrowserTerminalHistory(record: Record, path: string): BrowserTerminalHistory { return { desktopScrollbackLines: positiveIntegerField(record, "desktopScrollbackLines", path), mobileScrollbackLines: positiveIntegerField(record, "mobileScrollbackLines", path), mobileMaxWidthPx: positiveIntegerField(record, "mobileMaxWidthPx", path), }; } function parseBrowserOutputPolicy(record: Record, path: string): BrowserOutputPolicy { const compression = recordField(record, "compression", path); return { maxFramesPerSecond: positiveIntegerField(record, "maxFramesPerSecond", path), maxFrameBytes: positiveIntegerField(record, "maxFrameBytes", path), compression: { enabled: booleanField(compression, "enabled", `${path}.compression`), thresholdBytes: positiveIntegerField(compression, "thresholdBytes", `${path}.compression`), level: positiveIntegerField(compression, "level", `${path}.compression`), memLevel: positiveIntegerField(compression, "memLevel", `${path}.compression`), concurrencyLimit: positiveIntegerField(compression, "concurrencyLimit", `${path}.compression`), serverContextTakeover: booleanField(compression, "serverContextTakeover", `${path}.compression`), clientContextTakeover: booleanField(compression, "clientContextTakeover", `${path}.compression`), }, }; } function parseBrowserPerformance(record: Record, path: string): BrowserPerformance { const allowedModes = ["disconnect", "receive-only", "live"] as const; const defaultBackgroundMode = stringField(record, "defaultBackgroundMode", path); if (!allowedModes.includes(defaultBackgroundMode as typeof allowedModes[number])) { throw new Error(`${path}.defaultBackgroundMode must be disconnect, receive-only or live`); } const modesRecord = recordField(record, "backgroundModes", path); const backgroundModes = allowedModes.map((id) => ({ id, label: stringField(recordField(modesRecord, id, `${path}.backgroundModes`), "label", `${path}.backgroundModes.${id}`), })); const unknownModes = Object.keys(modesRecord).filter((id) => !allowedModes.includes(id as typeof allowedModes[number])); if (unknownModes.length > 0) throw new Error(`${path}.backgroundModes contains unsupported modes: ${unknownModes.join(", ")}`); return { animationsEnabled: booleanField(record, "animationsEnabled", path), defaultBackgroundMode: defaultBackgroundMode as BrowserPerformance["defaultBackgroundMode"], backgroundModes, deferredOutputMaxBytes: positiveIntegerField(record, "deferredOutputMaxBytes", path), dashboardSampleWindowSeconds: positiveIntegerField(record, "dashboardSampleWindowSeconds", path), dashboardRefreshIntervalMs: positiveIntegerField(record, "dashboardRefreshIntervalMs", path), }; } function positiveIntegerField(record: Record, key: string, path: string): number { const value = integerField(record, key, path); if (value <= 0) throw new Error(`${path}.${key} must be positive`); return value; } function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; } function parsePid(value: string, path: string): "host" | "container" { if (value !== "host" && value !== "container") throw new Error(`${path} must be host or container`); return value; } function portField(record: Record, key: string, path: string): number { const value = integerField(record, key, path); if (value < 1 || value > 65535) throw new Error(`${path}.${key} must be a TCP port`); return value; } function stringArray(value: unknown, path: string): string[] { if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of strings`); return value as string[]; } function quoteYaml(value: string): string { return JSON.stringify(value); } function fingerprint(value: string): string { const result = spawnSync("sha256sum", { input: value, encoding: "utf8" }); return `sha256:${(result.stdout.split(/\s+/u)[0] ?? "").trim()}`; } function renderPlan(result: Record): RenderedCliResult { const target = rec(result.target); const exposure = rec(result.publicExposure); const failed = arr(result.policy).filter((item) => item.ok === false); const lines = [ "PLATFORM-INFRA WEBTERM PLAN", ...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [ ["TARGET", str(target.id), "mode", str(target.mode)], ["PORT", str(target.hostPort), "container", str(target.containerName)], ["IMAGE", str(target.image), "compose", str(rec(result.compose).path)], ["PUBLIC", bool(exposure.enabled), "url", str(exposure.publicBaseUrl)], ["UPSTREAM", str(exposure.upstream), "pk01", str(exposure.pk01Route)], ["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"], ]), "", "NEXT", " dry-run: bun scripts/cli.ts platform-infra webterm apply --dry-run", " apply: bun scripts/cli.ts platform-infra webterm apply --confirm", " status: bun scripts/cli.ts platform-infra webterm status", ]; return rendered(result, "platform-infra webterm plan", lines); } function renderApply(result: Record): RenderedCliResult { const target = rec(result.target); const compose = rec(result.compose); const localProbe = rec(result.localProbe); const publicProbe = rec(result.publicProbe); const caddy = rec(result.pk01Caddy); const lines = [ "PLATFORM-INFRA WEBTERM APPLY", ...table(["TARGET", "PORT", "MODE", "STATUS"], [[str(target.id), str(target.hostPort), str(result.mode), result.ok === false ? "failed" : "ok"]]), "", "COMPOSE", ...table(["PATH", "PROJECT", "UP"], [[str(compose.path), str(target.composeProject), str(rec(compose.up).exitCode, "-")]]), "", "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["local", bool(localProbe.ok), str(localProbe.url, "-")], ["provider", str(localProbe.providers, "-"), `sessions=${str(localProbe.sessions, "-")}`], ["pk01Caddy", bool(caddy.ok), str(caddy.action, str(caddy.hostname, "-"))], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), "", "NEXT", " status: bun scripts/cli.ts platform-infra webterm status", " validate: bun scripts/cli.ts platform-infra webterm validate", ]; return rendered(result, "platform-infra webterm apply", lines); } function renderStatus(result: Record): RenderedCliResult { const target = rec(result.target); const container = rec(result.container); const localProbe = rec(result.localProbe); const publicProbe = rec(result.publicProbe); const lines = [ "PLATFORM-INFRA WEBTERM STATUS", ...table(["TARGET", "PORT", "CONTAINER", "STATUS"], [[str(target.id), str(target.hostPort), str(target.containerName), result.ok === false ? "failed" : "ok"]]), "", "CONTAINER", str(container.ps, "-"), "", "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["local", bool(localProbe.ok), str(localProbe.url, "-")], ["provider", str(localProbe.providers, "-"), `sessions=${str(localProbe.sessions, "-")}`], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), ]; return rendered(result, "platform-infra webterm status", lines); } function renderValidate(result: Record): RenderedCliResult { const dns = rec(result.dns); const localProbe = rec(result.localProbe); const publicProbe = rec(result.publicProbe); const lines = [ "PLATFORM-INFRA WEBTERM VALIDATE", ...table(["CHECK", "OK", "DETAIL"], [ ["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"], ["local", bool(localProbe.ok), str(localProbe.url, "-")], ["provider", str(localProbe.providers, "-"), `sessions=${str(localProbe.sessions, "-")}`], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), ]; return rendered(result, "platform-infra webterm validate", lines); } function renderLogs(result: Record): RenderedCliResult { const target = rec(result.target); const lines = [ "PLATFORM-INFRA WEBTERM LOGS", ...table(["TARGET", "PORT", "TAIL", "STATUS"], [[str(target.id), str(target.hostPort), str(result.tail), result.ok === false ? "failed" : "ok"]]), "", str(result.logs, "-"), ]; return rendered(result, "platform-infra webterm logs", lines); } function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; } function rec(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } function arr(value: unknown): Record[] { return Array.isArray(value) ? value.map(rec) : []; } function values(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function str(value: unknown, fallback = "-"): string { return value === undefined || value === null || value === "" ? fallback : String(value); } function bool(value: unknown): string { return value === true ? "yes" : value === false ? "no" : "-"; } function table(headers: string[], rows: string[][]): string[] { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))); const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); return [renderRow(headers), ...rows.map(renderRow)]; }