Files
pikasTech-unidesk/scripts/src/platform-infra-sub2api-codex/options.ts
T
2026-07-02 02:43:13 +00:00

336 lines
14 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/platform-infra-sub2api-codex.ts.
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:320-620 for #903.
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { UniDeskConfig } from "../config";
import { rootPath } from "../config";
import type { RenderedCliResult } from "../output";
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest, type PublicServiceExposure, type PublicServiceTarget } from "../platform-infra-public-service";
import { shortSha256Fingerprint } from "../platform-infra-ops-library";
import {
codexPoolSentinelSummary,
codexPoolSentinelRuntimeImage,
readCodexPoolSentinelConfig,
renderCodexPoolSentinelManifest,
type CodexPoolSentinelConfig,
type CodexPoolSentinelProfileSecret,
} from "../platform-infra-sub2api-codex-sentinel";
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
import type { ConfirmOptions, DisclosureOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types";
import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions";
import { renderCodexPoolPlan } from "./render";
import { defaultCodexPoolRuntimeTargetId } from "./runtime-target";
import { codexPoolHelp } from "./types";
export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [action = "plan"] = args;
if (action === "plan") {
const options = parseDisclosureOptions(args.slice(1));
const result = codexPoolPlan(options);
return options.full || options.raw ? result : renderCodexPoolPlan(result);
}
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1)));
if (action === "sentinel-image") return await codexPoolSentinelImage(config, parseSentinelImageOptions(args.slice(1)));
if (action === "sentinel-probe") return await codexPoolSentinelProbe(config, parseSentinelProbeOptions(args.slice(1)));
if (action === "sentinel-report") return await codexPoolSentinelReport(config, parseSentinelReportOptions(args.slice(1)));
if (action === "cleanup-probes") return await codexPoolCleanupProbes(config, parseConfirmOptions(args.slice(1)));
if (action === "expose") return await codexPoolExpose(config, parseConfirmOptions(args.slice(1)));
if (action === "configure-local") return await codexPoolConfigureLocal(config, parseConfirmOptions(args.slice(1)));
return {
ok: false,
error: "unsupported-platform-infra-sub2api-codex-pool-command",
args,
help: codexPoolHelp(),
};
}
export function parseSyncOptions(args: string[]): SyncOptions {
validateOptions(args, new Set(["--confirm", "--prune-removed", "--full", "--raw", "--target"]));
const disclosure = parseDisclosureOptions(stripBooleanOptions(args, new Set(["--confirm", "--prune-removed"])));
return { ...disclosure, confirm: args.includes("--confirm"), pruneRemoved: args.includes("--prune-removed") };
}
export function parseConfirmOptions(args: string[]): ConfirmOptions {
validateOptions(args, new Set(["--confirm", "--full", "--raw", "--target"]));
const disclosure = parseDisclosureOptions(stripBooleanOptions(args, new Set(["--confirm"])));
return { ...disclosure, confirm: args.includes("--confirm") };
}
export function parseSentinelImageOptions(args: string[]): SentinelImageOptions {
const [actionRaw = "status", ...rest] = args;
if (actionRaw !== "status" && actionRaw !== "build") throw new Error("sentinel-image usage: status|build [--dry-run|--confirm] [--full|--raw]");
let confirm = false;
let explicitDryRun = false;
const disclosureArgs: string[] = [];
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index]!;
if (arg === "--confirm") {
confirm = true;
continue;
}
if (arg === "--dry-run") {
explicitDryRun = true;
continue;
}
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
disclosureArgs.push(arg);
if (arg === "--target") {
const value = rest[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
disclosureArgs.push(value);
index += 1;
}
continue;
}
throw new Error(`unsupported option: ${arg}`);
}
if (confirm && explicitDryRun) throw new Error("sentinel-image accepts only one of --confirm or --dry-run");
const disclosure = parseDisclosureOptions(disclosureArgs);
return {
...disclosure,
action: actionRaw,
confirm,
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
};
}
export function parseSentinelProbeOptions(args: string[]): SentinelProbeOptions {
const accounts: string[] = [];
const disclosureArgs: string[] = [];
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--confirm") {
confirm = true;
continue;
}
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
disclosureArgs.push(arg);
if (arg === "--target") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
disclosureArgs.push(value);
index += 1;
}
continue;
}
if (arg === "--account") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--account requires an account name");
accounts.push(...splitAccountNames(value));
index += 1;
continue;
}
if (arg.startsWith("--account=")) {
accounts.push(...splitAccountNames(arg.slice("--account=".length)));
continue;
}
throw new Error(`unsupported option: ${arg}`);
}
const uniqueAccounts = [...new Set(accounts)];
if (uniqueAccounts.length === 0) throw new Error("sentinel-probe requires --account <accountName>");
for (const account of uniqueAccounts) validateSub2ApiAccountSelector(account, "--account");
const disclosure = parseDisclosureOptions(disclosureArgs);
return { ...disclosure, confirm, accounts: uniqueAccounts };
}
export function parseSentinelReportOptions(args: string[]): SentinelReportOptions {
let events = 20;
let explicitEvents = false;
const disclosureArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
disclosureArgs.push(arg);
if (arg === "--target") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
disclosureArgs.push(value);
index += 1;
}
continue;
}
if (arg === "--events") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--events requires a positive integer");
events = readReportEventLimit(value, "--events");
explicitEvents = true;
index += 1;
continue;
}
if (arg.startsWith("--events=")) {
events = readReportEventLimit(arg.slice("--events=".length), "--events");
explicitEvents = true;
continue;
}
throw new Error(`unsupported option: ${arg}`);
}
const disclosure = parseDisclosureOptions(disclosureArgs);
if (disclosure.full && !explicitEvents) events = 80;
return { ...disclosure, events };
}
export function parseTraceOptions(args: string[]): TraceOptions {
let requestId: string | null = null;
let since = "24h";
let tail = 20_000;
let contextSeconds = 300;
let showLines = false;
const disclosureArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
disclosureArgs.push(arg);
if (arg === "--target") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
disclosureArgs.push(value);
index += 1;
}
continue;
}
if (arg === "--show-lines") {
showLines = true;
continue;
}
if (arg === "--request-id" || arg === "--id") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a request id`);
requestId = value.trim();
index += 1;
continue;
}
if (arg.startsWith("--request-id=")) {
requestId = arg.slice("--request-id=".length).trim();
continue;
}
if (arg.startsWith("--id=")) {
requestId = arg.slice("--id=".length).trim();
continue;
}
if (arg === "--since") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--since requires a kubectl duration such as 24h or 90m");
since = parseKubectlDuration(value);
index += 1;
continue;
}
if (arg.startsWith("--since=")) {
since = parseKubectlDuration(arg.slice("--since=".length));
continue;
}
if (arg === "--tail") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--tail requires an integer");
tail = parseTraceLimit(value, "--tail", 100, 200_000);
index += 1;
continue;
}
if (arg.startsWith("--tail=")) {
tail = parseTraceLimit(arg.slice("--tail=".length), "--tail", 100, 200_000);
continue;
}
if (arg === "--context-seconds") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--context-seconds requires an integer");
contextSeconds = parseTraceLimit(value, "--context-seconds", 0, 3600);
index += 1;
continue;
}
if (arg.startsWith("--context-seconds=")) {
contextSeconds = parseTraceLimit(arg.slice("--context-seconds=".length), "--context-seconds", 0, 3600);
continue;
}
throw new Error(`unsupported option: ${arg}`);
}
if (requestId === null || requestId.length === 0) throw new Error("trace requires --request-id <requestId>");
if (!/^[A-Za-z0-9_.:-]{8,128}$/u.test(requestId)) throw new Error("--request-id has an unsupported format");
const disclosure = parseDisclosureOptions(disclosureArgs);
return { ...disclosure, requestId, since, tail, contextSeconds, showLines };
}
export function parseKubectlDuration(raw: string): string {
const value = raw.trim();
if (!/^[1-9][0-9]*(?:s|m|h)$/u.test(value)) throw new Error("--since must be a kubectl duration such as 24h, 90m, or 300s");
return value;
}
export function parseTraceLimit(raw: string, option: string, min: number, max: number): number {
const value = Number(raw);
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${option} must be an integer from ${min} to ${max}`);
return value;
}
export function readReportEventLimit(raw: string, option: string): number {
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 200) throw new Error(`${option} must be an integer from 1 to 200`);
return value;
}
export function parseDisclosureOptions(args: string[]): DisclosureOptions {
validateOptions(args, new Set(["--full", "--raw", "--target"]));
const raw = args.includes("--raw");
return { full: raw || args.includes("--full"), raw, targetId: parseTargetId(args) };
}
export function parseTargetId(args: string[]): string {
let targetId: string | null = null;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--target") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
targetId = value;
index += 1;
continue;
}
if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length);
}
const resolvedTargetId = targetId ?? defaultCodexPoolRuntimeTargetId();
if (!/^[A-Za-z0-9._-]+$/u.test(resolvedTargetId)) throw new Error("--target must be a simple target id");
return resolvedTargetId;
}
export function splitAccountNames(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
export function validateSub2ApiAccountSelector(value: string, option: string): void {
if (value.length === 0 || value.length > 256) throw new Error(`${option} must be a non-empty account name up to 256 characters`);
if (/[\r\n]/u.test(value)) throw new Error(`${option} must not contain newlines`);
if (!/^[^<>"'`\\]+$/u.test(value)) throw new Error(`${option} contains unsupported characters`);
}
export function validateOptions(args: string[], booleanOptions: Set<string>): void {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--target") {
index += 1;
continue;
}
if (arg.startsWith("--target=") && booleanOptions.has("--target")) continue;
if (booleanOptions.has(arg)) continue;
throw new Error(`unsupported option: ${arg}`);
}
}
export function stripBooleanOptions(args: string[], stripped: Set<string>): string[] {
return args.filter((arg) => !stripped.has(arg));
}
export interface Sub2ApiRuntimeConfig {
defaultTargetId: string;
appSecretName: string;
secretsRoot: string;
appSourceRef: string;
sentinelEnabledOnTargets: string[];
targets: Record<string, unknown>[];
}