Add platform-infra webterm entrypoint

This commit is contained in:
Codex
2026-07-08 07:21:58 +02:00
parent d56bee641b
commit fc329e3b31
7 changed files with 710 additions and 6 deletions
+64
View File
@@ -0,0 +1,64 @@
version: 1
kind: platform-infra-webterm
metadata:
id: webterm-public
owner: unidesk
defaults:
targetId: local-7683
targets:
- id: local-7683
enabled: true
runtime:
mode: host-docker
workDir: /root/webterm
composePath: /root/webterm/build/docker-compose.7683.yaml
composeProject: webterm-7683
serviceName: web-terminal
containerName: web-terminal-7683
image: webterm-web-terminal-7682:latest
envFile: /root/webterm/.env
hostPort: 7683
containerPort: 7681
privileged: true
pid: host
sourceMounts:
- source: /root/webterm/src
target: /app/src
readOnly: true
- source: /root/webterm/public
target: /app/public
readOnly: true
- source: /root/webterm/scripts/host-shell.sh
target: /usr/local/bin/host-shell
readOnly: true
environment:
HOST: 0.0.0.0
PORT: "7681"
SERVER_PROVIDER_ID: remote-server
SERVER_PROVIDER_NAME: Remote Server 7683
TERMINAL_SHELL: /usr/local/bin/host-shell
TERMINAL_CWD: /root
TERMINAL_REMOTE_CWD: /root/unidesk
TERMINAL_START_COMMAND: mycx
TERMINAL_HISTORY_BYTES: "2097152"
TERMINAL_SNAPSHOT_BYTES: "524288"
BROWSER_KEEPALIVE_INTERVAL_MS: "30000"
BROWSER_STALE_TIMEOUT_MS: "180000"
publicExposure:
enabled: true
publicBaseUrl: https://term.pikapython.com
dns:
hostname: term.pikapython.com
expectedA: 82.156.23.220
resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114]
upstream:
host: 152.53.229.148
port: 7683
pk01:
route: PK01
caddyConfigPath: /etc/caddy/Caddyfile
caddyServiceName: caddy
responseHeaderTimeoutSeconds: 600
+1 -1
View File
@@ -74,7 +74,7 @@ runtimeConfig:
databasePoolMax: "4"
sessionTtlSeconds: "28800"
authUsername: admin
sshClientRouteAllowlist: NC01,NC01:*,JD01,JD01:*,PK01,PK01:*
sshClientRouteAllowlist: "*"
microservicesJson: "[]"
runtimeSecrets:
+6 -3
View File
@@ -70,7 +70,7 @@ export function rootHelp(): unknown {
{ command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|secret|test-accounts --node <node> --lane <lane>", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared k3s infra/tools-image/Argo bootstrap, HWPOD preinstall configRefs, redacted test-account preparation, and G14 v0.3+ runtime lanes, with the node identity passed as data." },
{ command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." },
{ command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." },
{ command: "platform-infra sub2api|langbot|n8n|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot and n8n, manage YAML-controlled public FRP/Caddy exposure and WeChat archive workflows, and inspect status/logs without printing secrets." },
{ command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot, n8n and Web Terminal, manage YAML-controlled public Caddy/FRP exposure and WeChat archive workflows, and inspect status/logs without printing secrets." },
{ command: "secrets plan|sync|status", description: "Plan, push and inspect YAML-declared local secret source keys to Kubernetes Secrets without printing or reverse-engineering values." },
{ command: "platform-db postgres plan|status|export-secrets|apply", description: "Manage YAML-declared host-native PostgreSQL 16 on PK01 for Sub2API/platform state, with PostgreSQL native TLS and redacted secret exports." },
{ command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." },
@@ -663,7 +663,7 @@ function agentRunHelpSummary(): unknown {
function platformInfraHelpSummary(): unknown {
return {
command: "platform-infra sub2api|egress-proxy|langbot|n8n|wechat-archive|gitea ...",
command: "platform-infra sub2api|egress-proxy|langbot|n8n|webterm|wechat-archive|gitea ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan",
@@ -685,6 +685,9 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra n8n apply --confirm",
"bun scripts/cli.ts platform-infra n8n status",
"bun scripts/cli.ts platform-infra n8n validate --full",
"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 wechat-archive plan",
"bun scripts/cli.ts platform-infra wechat-archive apply --confirm",
"bun scripts/cli.ts platform-infra wechat-archive validate --full",
@@ -699,7 +702,7 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra gitea mirror sync --target JD01 --confirm",
"bun scripts/cli.ts platform-infra gitea mirror status --target JD01",
],
description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.",
description: "Operate platform-infra services such as Sub2API, shared egress-proxy benchmarks, LangBot, n8n, Web Terminal, WeChat archive workflows, the YAML-controlled Codex pool, and internal Gitea for the GH-1548/GH-1549 CI/CD migration.",
};
}
+628
View File
@@ -0,0 +1,628 @@
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 };
targets: WebtermTarget[];
}
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 }>;
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 the host-Docker Web Terminal from YAML and expose it through PK01 Caddy HTTPS without touching the existing 7682 service.",
};
}
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 targetsRaw = yaml.targets;
if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array");
return {
defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") },
targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`)),
};
}
function parseTarget(record: Record<string, unknown>, path: string): 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 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,
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.runtime.hostPort === 7682) throw new Error(`${path}.runtime.hostPort must not reuse existing 7682`);
if (target.runtime.containerName === "web-terminal-7682") throw new Error(`${path}.runtime.containerName must not reuse web-terminal-7682`);
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 envLines = Object.entries(target.runtime.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 policyChecks(target: WebtermTarget, compose: string): Array<Record<string, unknown> & { ok: boolean }> {
return [
{ name: "enabled", ok: target.enabled, detail: "target must be enabled" },
{ name: "no-7682-host-port", ok: target.runtime.hostPort !== 7682, detail: "existing 7682 service must not be reused" },
{ name: "no-7682-container", ok: target.runtime.containerName !== "web-terminal-7682", detail: "existing 7682 container must not be reused" },
{ name: "compose-path-7683", ok: target.runtime.composePath.includes("7683"), detail: target.runtime.composePath },
{ name: "public-https", ok: target.publicExposure.publicBaseUrl.startsWith("https://"), detail: target.publicExposure.publicBaseUrl },
{ name: "compose-renders-7683", 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,
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 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)];
}
+6 -2
View File
@@ -369,7 +369,7 @@ export interface ManagedResourceCleanupPlan {
export function platformInfraHelp(): unknown {
const target = sub2ApiHelpTargetSummary();
return {
command: "platform-infra sub2api|langbot|n8n|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...",
command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]",
@@ -396,6 +396,10 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra n8n status [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra n8n logs [--target G14] [--component app|frpc|all]",
"bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra webterm plan",
"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 wechat-archive plan [--target G14]",
"bun scripts/cli.ts platform-infra wechat-archive apply [--target G14] --confirm",
"bun scripts/cli.ts platform-infra wechat-archive status [--target G14] [--full|--raw]",
@@ -443,7 +447,7 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer <consumer>",
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --consumer <consumer> --limit 10",
],
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the D518 Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n, Web Terminal, WeChat archive workflows, OpenTelemetry tracing, the independent target-scoped secret plane, the D518 Kafka event bus, internal Gitea source authority, and Gitea + Pipelines-as-Code closeout for JD01 migrated consumers. Public services use PK01 Caddy/FRP or YAML-declared PK01 Caddy upstreams rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target,
codexPool: {
usage: [
+1
View File
@@ -11,3 +11,4 @@ export * from "./apply-script";
export * from "./status-script";
export * from "./validate-script";
export * from "./utils";
export * from "../platform-infra-webterm";
+4
View File
@@ -49,6 +49,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
const { runN8nCommand } = await import("../platform-infra-n8n");
return await runN8nCommand(config, args.slice(1));
}
if (target === "webterm") {
const { runWebtermCommand } = await import("../platform-infra-webterm");
return await runWebtermCommand(config, args.slice(1));
}
if (target === "wechat-archive") {
const { runWechatArchiveCommand } = await import("../platform-infra-wechat-archive");
return await runWechatArchiveCommand(config, args.slice(1));