Files
pikasTech-unidesk/scripts/src/platform-infra-webterm.ts
T
2026-07-11 07:58:20 +02:00

749 lines
30 KiB
TypeScript

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;
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 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 }>;
autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean; fallbackShell: boolean }>;
sessionStartModes: SessionStartModes;
autoResumePrompt: AutoResumePrompt;
environment: Record<string, string>;
};
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<Record<string, unknown> | 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);
}
return {
ok: false,
error: "unsupported-webterm-command",
args,
help: webtermHelp(),
};
}
export function webtermHelp(): Record<string, unknown> {
return {
command: "platform-infra webterm plan|apply|status|validate",
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",
],
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<string, unknown> {
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<Record<string, unknown>> {
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 = localHttpProbeWithRetry(target.runtime.hostPort, "/login", 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<string, unknown> {
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 = localHttpProbe(target.runtime.hostPort, "/login");
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<string, unknown> {
const target = resolveTarget(targetId);
const dns = dnsProbe(target.publicExposure.dns.hostname);
const localProbe = localHttpProbe(target.runtime.hostPort, "/login");
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 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 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,
targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes, autoResumePrompt)),
};
}
function parseTarget(record: Record<string, unknown>, path: string, sessionStartModes: SessionStartModes, autoResumePrompt: AutoResumePrompt): 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 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,
autoStartSessions,
sessionStartModes,
autoResumePrompt,
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),
};
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");
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}
`;
}
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<Record<string, unknown> & { 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}` },
];
}
function caddyExposure(target: WebtermTarget): Parameters<typeof applyPk01CaddySiteBlock>[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<string, unknown> {
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,
},
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<string, unknown> {
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<string, unknown> {
return httpProbe(`http://127.0.0.1:${port}`, path);
}
function publicHttpProbe(baseUrl: string, path: string): Record<string, unknown> {
return httpProbe(baseUrl, path);
}
function localHttpProbeWithRetry(port: number, path: string, attempts: number, delayMs: number): Record<string, unknown> {
return httpProbeWithRetry(() => localHttpProbe(port, path), attempts, delayMs);
}
function publicHttpProbeWithRetry(baseUrl: string, path: string, attempts: number, delayMs: number): Record<string, unknown> {
return httpProbeWithRetry(() => publicHttpProbe(baseUrl, path), attempts, delayMs);
}
function httpProbeWithRetry(run: () => Record<string, unknown>, attempts: number, delayMs: number): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> & { 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<string>): Record<string, unknown> {
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<typeof parseOpsCommonOptions> {
return parseOpsCommonOptions(args);
}
function wantsFull(args: string[]): boolean {
return args.includes("--full") || args.includes("--raw");
}
function parseEnvironment(record: Record<string, unknown>, path: string): Record<string, string> {
const result: Record<string, string> = {};
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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 parseLiteral<T extends string>(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<string, unknown>, 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<string, unknown>): 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<string, unknown>): 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, "-")],
["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<string, unknown>): 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, "-")],
["public", bool(publicProbe.ok), str(publicProbe.url, "-")],
]),
];
return rendered(result, "platform-infra webterm status", lines);
}
function renderValidate(result: Record<string, unknown>): 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, "-")],
["public", bool(publicProbe.ok), str(publicProbe.url, "-")],
]),
];
return rendered(result, "platform-infra webterm validate", lines);
}
function rendered(result: Record<string, unknown>, command: string, lines: string[]): RenderedCliResult {
return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" };
}
function rec(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function arr(value: unknown): Record<string, unknown>[] {
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)];
}