From 80edc6c08b5b9d03f7a5ea7803d3c442660cc5fe Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 02:01:47 +0200 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E6=94=B6=E6=95=9B=E9=80=90?= =?UTF-8?q?=E7=BA=A7=E6=8A=AB=E9=9C=B2=E4=B8=8E=E8=BE=93=E5=85=A5=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/cli.ts | 9 +- scripts/src/cli-input-errors.test.ts | 57 + scripts/src/help.ts | 27 + scripts/src/output.ts | 85 +- ...atform-infra-gitea-authority-guard.test.ts | 12 +- scripts/src/platform-infra-gitea-config.ts | 876 ++++++++++++ .../platform-infra-gitea-disclosure.test.ts | 151 ++ .../src/platform-infra-gitea-disclosure.ts | 330 +++++ scripts/src/platform-infra-gitea-render.ts | 343 +++++ scripts/src/platform-infra-gitea.ts | 1255 ++--------------- scripts/src/ssh-help.test.ts | 67 +- scripts/ssh-cli.ts | 27 +- 12 files changed, 2044 insertions(+), 1195 deletions(-) create mode 100644 scripts/src/cli-input-errors.test.ts create mode 100644 scripts/src/platform-infra-gitea-config.ts create mode 100644 scripts/src/platform-infra-gitea-disclosure.test.ts create mode 100644 scripts/src/platform-infra-gitea-disclosure.ts create mode 100644 scripts/src/platform-infra-gitea-render.ts diff --git a/scripts/cli.ts b/scripts/cli.ts index f1dc1188..390afe54 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -4,7 +4,7 @@ import { readConfig } from "./src/config"; import { debugDispatch, debugEgressProxy, debugHealth, debugProviderRootfs, debugSshPool, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug"; import { isRebuildableService, isRestartableService, rebuildService, restartService, stackLogs, stackStatus, startStack, stopStack, unsupportedRebuildService, unsupportedRestartService } from "./src/docker"; -import { emitError, emitJson, emitText, isRenderedCliResult } from "./src/output"; +import { CliInputError, emitError, emitJson, emitText, isRenderedCliResult } from "./src/output"; import { cancelJob, jobWithTail, listJobs, listJobsSummary, readJob, renderJobLaunchSummary, renderJobStatusSummary, runJob } from "./src/jobs"; import { checkHelp, parseCheckOptions, runChecks, runRecoveryGuardrailsCheck } from "./src/check"; import { runSsh } from "./src/ssh"; @@ -786,7 +786,12 @@ async function main(): Promise { throw error; } - throw new Error(`Unknown command: ${commandName}`); + throw new CliInputError(`Unknown command: ${commandName}`, { + code: "unknown-command", + argument: args[0] ?? "", + usage: "bun scripts/cli.ts help", + hint: "Run the top-level help, then use the matching namespace-scoped help before retrying.", + }); } if (import.meta.main && !process.execArgv.includes("--check")) { diff --git a/scripts/src/cli-input-errors.test.ts b/scripts/src/cli-input-errors.test.ts new file mode 100644 index 00000000..54f9ec5b --- /dev/null +++ b/scripts/src/cli-input-errors.test.ts @@ -0,0 +1,57 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, test } from "bun:test"; +import { repoRoot } from "./config"; + +describe("CLI input error hierarchy", () => { + test("unknown root commands are compact and stack-free by default", () => { + const result = runCli("definitely-not-a-command"); + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + expect(result.stdout.trim().split("\n").length).toBeLessThanOrEqual(8); + expect(result.stdout).toContain("ERROR cli-input/unknown-command"); + expect(result.stdout).toContain("usage: bun scripts/cli.ts help"); + expect(result.stdout).not.toContain("stack"); + expect(result.stdout).not.toContain(" at "); + }); + + test("predictable Gitea option validation is compact and stack-free", () => { + const result = runCli("platform-infra", "gitea", "status", "--target", "NC01", "--json"); + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + expect(result.stdout.trim().split("\n").length).toBeLessThanOrEqual(8); + expect(result.stdout).toContain("ERROR cli-input/unsupported-option"); + expect(result.stdout).toContain("argument: --json"); + expect(result.stdout).not.toContain("stack"); + expect(result.stdout).not.toContain("platform-infra-gitea.ts:"); + }); + + test("explicit debug retains stack while internal exceptions remain observable", () => { + const debug = runCliWithEnv({ UNIDESK_CLI_DEBUG: "1" }, "definitely-not-a-command"); + expect(debug.status).toBe(1); + const debugPayload = JSON.parse(debug.stdout) as { error: { debug: boolean; stack: string } }; + expect(debugPayload.error.debug).toBe(true); + expect(debugPayload.error.stack).toContain("CliInputError: Unknown command"); + + const internal = spawnSync("bun", ["-e", "import { emitError } from './scripts/src/output.ts'; emitError('internal-probe', new Error('internal-probe-failed'));"], { + cwd: repoRoot, + env: process.env, + encoding: "utf8", + }); + expect(internal.status).toBe(0); + const internalPayload = JSON.parse(internal.stdout) as { error: { message: string; stack: string } }; + expect(internalPayload.error.message).toBe("internal-probe-failed"); + expect(internalPayload.error.stack).toContain("Error: internal-probe-failed"); + }); +}); + +function runCli(...args: string[]) { + return runCliWithEnv({}, ...args); +} + +function runCliWithEnv(env: NodeJS.ProcessEnv, ...args: string[]) { + return spawnSync("bun", ["scripts/cli.ts", ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: "utf8", + }); +} diff --git a/scripts/src/help.ts b/scripts/src/help.ts index b1fe2040..7fb7ca40 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -330,6 +330,33 @@ export function sshHelp(scope: SshHelpScope | undefined = undefined): unknown { }; } +export function renderSshHelpText(scope: SshHelpScope | undefined = undefined): string { + const entrypoint = sshEntrypoint(); + if (scope === undefined) { + return [ + "TRANS remote route executor", + `usage: ${entrypoint} [operation-args...]`, + "routes: :/workspace | :k3s[:namespace:workload[:container]] | :win[/drive/path] | gh:/owner/repo/...", + `process: ${SSH_OPERATION_GROUPS.process.join(" | ")}`, + `shell: ${SSH_OPERATION_GROUPS.shell.join(" | ")}`, + `filesystem: ${SSH_OPERATION_GROUPS.filesystem.join(" | ")}`, + `patch: ${SSH_OPERATION_GROUPS.patch.join(" | ")}`, + `transfer: ${SSH_OPERATION_GROUPS.transfer.join(" | ")}`, + `scoped help: ${entrypoint} --help `, + "boundary: route only selects the target; use sh/bash/ps/cmd explicitly for shell syntax.", + ].join("\n"); + } + const detail = SSH_OPERATION_HELP[scope]; + return [ + `TRANS HELP ${scope}`, + `group: ${detail.group}`, + `description: ${detail.description}`, + ...detail.usage.slice(0, 3).map((usage) => `usage: ${usage.replace(/^trans\b/u, entrypoint)}`), + ...(detail.notes ?? []).slice(0, 2).map((note) => `note: ${note}`), + `back: ${entrypoint} --help`, + ].join("\n"); +} + function sshOperationHelp(scope: Exclude): unknown { const entrypoint = sshEntrypoint(); const detail = SSH_OPERATION_HELP[scope]; diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 9baf2157..e6785f2a 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -18,6 +18,35 @@ export interface RenderedCliResult { contentType: "text/plain" | "application/json" | "application/yaml"; } +export interface CliInputErrorOptions { + code: string; + argument?: string; + usage?: string | string[]; + supported?: string[]; + hint?: string; +} + +/** + * 可预测的 CLI 输入错误。内部异常不能包装成此类型,以免实现故障被静默降级。 + */ +export class CliInputError extends Error { + readonly code: string; + readonly argument?: string; + readonly usage?: string | string[]; + readonly supported?: string[]; + readonly hint?: string; + + constructor(message: string, options: CliInputErrorOptions) { + super(message); + this.name = "CliInputError"; + this.code = options.code; + this.argument = options.argument; + this.usage = options.usage; + this.supported = options.supported; + this.hint = options.hint; + } +} + const CLI_OUTPUT_CONFIG_RELATIVE_PATH = "config/unidesk-cli.yaml"; const CLI_OUTPUT_CONFIG_PATH = rootPath("config", "unidesk-cli.yaml"); const EMERGENCY_OUTPUT_DUMP_THRESHOLD_BYTES = 10 * 1024; @@ -67,12 +96,35 @@ export function emitText(text: string, command = "text"): void { } export function emitError(command: string, error: unknown): void { - const payload = normalizeErrorPayload(command, error); const safeCommand = redactSensitiveCommandArgs(command); + if (error instanceof CliInputError && !cliDebugEnabled()) { + safeStdoutWrite(renderTextOutput(safeCommand, renderCliInputErrorText(safeCommand, error))); + return; + } + const payload = normalizeErrorPayload(command, error); const envelope: JsonEnvelope = { ok: false, command: safeCommand, error: payload }; safeStdoutWrite(renderEnvelope(safeCommand, envelope)); } +function renderCliInputErrorText(command: string, error: CliInputError): string { + const usage = Array.isArray(error.usage) ? error.usage.join(" | ") : error.usage; + return [ + `ERROR cli-input/${compactErrorLine(error.code)}`, + `command: ${compactErrorLine(command)}`, + `message: ${compactErrorLine(error.message)}`, + ...(error.argument === undefined ? [] : [`argument: ${compactErrorLine(error.argument)}`]), + ...(usage === undefined ? [] : [`usage: ${compactErrorLine(usage)}`]), + ...(error.supported === undefined ? [] : [`supported: ${compactErrorLine(error.supported.join(" | "))}`]), + ...(error.hint === undefined ? [] : [`hint: ${compactErrorLine(error.hint)}`]), + "debug: UNIDESK_CLI_DEBUG=1", + ].join("\n"); +} + +function compactErrorLine(value: string): string { + const compact = value.replace(/\s+/gu, " ").trim(); + return compact.length > 480 ? `${compact.slice(0, 477)}...` : compact; +} + function redactSensitiveCommandArgs(command: string): string { return command .replace(/(--(?:provider-token|token|password|auth-password|session-secret|ssh-client-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/giu, "$1") @@ -85,6 +137,7 @@ export function readCliOutputPolicy(): CliOutputPolicy { function normalizeErrorPayload(command: string, error: unknown): Record { const message = error instanceof Error ? error.message : String(error); + if (error instanceof CliInputError) return cliInputErrorPayload(error); const parsed = parseStructuredErrorMessage(command, message); if (parsed !== null) return parsed; if (shouldSuppressStack(command) || shouldSuppressStack(commandPrefixFromMessage(message))) { @@ -93,7 +146,7 @@ function normalizeErrorPayload(command: string, error: unknown): Record { + const debug = cliDebugEnabled(); + return { + name: error.name, + code: error.code, + level: "error", + kind: "cli-input", + message: error.message, + retryable: false, + ...(error.argument !== undefined ? { argument: error.argument } : {}), + ...(error.usage !== undefined ? { usage: error.usage } : {}), + ...(error.supported !== undefined ? { supported: error.supported } : {}), + ...(error.hint !== undefined ? { hint: error.hint } : {}), + ...(debug + ? { debug: true, stack: error.stack ?? null } + : { + stackOmitted: true, + diagnosticHint: "Set UNIDESK_CLI_DEBUG=1 only when an input error needs a stack trace.", + }), + }; +} + +function cliDebugEnabled(): boolean { + return process.env.UNIDESK_CLI_DEBUG === "1" + || process.env.UNIDESK_CLI_FULL_ERROR === "1" + || process.env.UNIDESK_CLI_RAW_ERROR === "1"; +} + function structuredCliErrorPayload(error: Error, message: string): Record | null { const record = error as Error & Record; if (!Object.prototype.hasOwnProperty.call(record, "replacementExamples") && !Object.prototype.hasOwnProperty.call(record, "migrationHint")) return null; diff --git a/scripts/src/platform-infra-gitea-authority-guard.test.ts b/scripts/src/platform-infra-gitea-authority-guard.test.ts index 0ca2393d..6785dd10 100644 --- a/scripts/src/platform-infra-gitea-authority-guard.test.ts +++ b/scripts/src/platform-infra-gitea-authority-guard.test.ts @@ -33,8 +33,7 @@ test("manual mirror sync and synthetic webhook delivery fail before remote captu expect(webhookTest.status).not.toBe(0); expect(webhookTest.payload.data).toMatchObject({ ok: false, - mutation: false, - mode: "manual-source-delivery-test-disabled", + error: "unsupported-platform-infra-gitea-mirror-webhook-command", }); expect(webhookTest.payload.data.remote).toBeUndefined(); }); @@ -54,11 +53,12 @@ test("default platform-infra help exposes only read-only Gitea authority command expect(result.status).toBe(0); const giteaUsage = (result.payload.data.usage as string[]).filter((line) => line.includes("platform-infra gitea ")); expect(giteaUsage).toEqual([ - "bun scripts/cli.ts platform-infra gitea status --target NC01", - "bun scripts/cli.ts platform-infra gitea validate --target NC01", - "bun scripts/cli.ts platform-infra gitea mirror status --target NC01", - "bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01", + "bun scripts/cli.ts platform-infra gitea status --target [--full|--raw]", + "bun scripts/cli.ts platform-infra gitea validate --target [--full|--raw]", + "bun scripts/cli.ts platform-infra gitea mirror status --target [--full|--raw]", + "bun scripts/cli.ts platform-infra gitea mirror webhook status --target [--repo ] [--delivery-id ] [--full|--raw]", ]); + expect(giteaUsage.join("\n")).not.toMatch(/\b(?:apply|sync|flush|trigger)\b/); }); function cli(args: string[]): { status: number | null; payload: Record } { diff --git a/scripts/src/platform-infra-gitea-config.ts b/scripts/src/platform-infra-gitea-config.ts new file mode 100644 index 00000000..219436b0 --- /dev/null +++ b/scripts/src/platform-infra-gitea-config.ts @@ -0,0 +1,876 @@ +import { dirname } from "node:path"; +import { rootPath } from "./config"; +import { createYamlFieldReader, readYamlRecord } from "./platform-infra-ops-library"; +import type { PublicServiceExposure } from "./platform-infra-public-service"; +import { validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy"; +import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy"; + +export const GITEA_CONFIG_LABEL = "config/platform-infra/gitea.yaml"; +const configFile = rootPath("config", "platform-infra", "gitea.yaml"); +const configLabel = GITEA_CONFIG_LABEL; +const y = createYamlFieldReader(configLabel); + +export interface GiteaTarget { + id: string; + route: string; + namespace: string; + role: string; + enabled: boolean; + createNamespace: boolean; + storageClassName: string; + publicExposureEnabled: boolean; + webhookSync: GiteaTargetWebhookSync | null; +} + +export interface GiteaTargetWebhookSync { + publicPath: string; + frpc: { + proxyName: string; + remotePort: number; + }; +} + +export interface GiteaConfig { + version: number; + kind: "platform-infra-gitea"; + metadata: { + id: string; + owner: string; + spec: string; + relatedIssues: number[]; + }; + defaults: { + targetId: string; + }; + migration: { + role: string; + replaces: string; + parentConfigRef: string; + envReusePolicy: string; + buildPlane: string; + runtimePlane: string; + }; + sourceAuthority: { + enabled: boolean; + stage: string; + statusAuthority: string; + firstCiConsumer: string; + credentials: { + sourceRoot: string; + admin: GiteaAdminCredential; + github: GiteaGithubCredential; + }; + githubProxy: { + enabled: boolean; + url: string; + noProxy: string[]; + }; + webhookSync: GiteaWebhookSync; + responsibilities: GiteaSourceResponsibility[]; + repositories: GiteaMirrorRepository[]; + }; + targets: GiteaTarget[]; + app: { + name: string; + statefulSetName: string; + serviceName: string; + replicas: number; + image: { + repository: string; + tag: string; + pullPolicy: "Always" | "IfNotPresent" | "Never"; + }; + service: { + type: "ClusterIP"; + httpPort: number; + sshPort: number; + }; + server: { + domain: string; + rootUrl: string; + sshDomain: string; + protocol: "http" | "https"; + startSshServer: boolean; + }; + publicExposure: PublicServiceExposure & { secretRoot: string }; + database: { + type: "sqlite3"; + path: string; + }; + actions: { + enabled: boolean; + }; + webhook: { + allowedHostList: string; + }; + registration: { + disabled: boolean; + }; + storage: { + data: { size: string; mountPath: string }; + config: { size: string; mountPath: string }; + }; + securityContext: { + runAsUser: number; + runAsGroup: number; + fsGroup: number; + }; + resources: { + requests: { cpu: string; memory: string }; + limits: { cpu: string; memory: string }; + }; + probes: { + healthPath: string; + initialDelaySeconds: number; + periodSeconds: number; + timeoutSeconds: number; + failureThreshold: number; + }; + }; + validation: { + waitTimeoutSeconds: number; + healthPath: string; + }; +} + +export interface GiteaAdminCredential { + sourceRef: string; + format: "line-pair"; + usernameLine: number; + passwordLine: number; + requiredFor: string[]; +} + +export interface GiteaGithubCredential { + transport: "https-token"; + sourceRef: string; + sourceKey: string; + format: "env" | "raw-token"; + requiredFor: string[]; + gitFetchCredential: { + apiVersion: "unidesk.ai/v1"; + kind: "GitFetchCredential"; + authMode: "github-https-token"; + host: "github.com"; + secretRef: { + namespace: string; + name: string; + key: string; + }; + }; +} + +export interface GiteaWebhookSync { + enabled: boolean; + direction: "github-to-gitea"; + responseBudgetMs: number; + publicPath: string; + events: string[]; + ingressRetry: GiteaWebhookIngressRetry; + secret: { + sourceRef: string; + sourceKey: string; + createIfMissing: boolean; + }; + bridge: { + deploymentName: string; + serviceName: string; + secretName: string; + configMapName: string; + image: string; + replicas: number; + httpPort: number; + serviceAccountName: string; + shutdownGraceMs: number; + candidateGate: { + enabled: boolean; + configMapName: string; + jobName: string; + activeDeadlineSeconds: number; + ttlSecondsAfterFinished: number; + healthTimeoutMs: number; + pollIntervalMs: number; + }; + inbox: { + claimName: string; + path: string; + storageSize: string; + maxBytes: number; + committedRetentionSeconds: number; + cleanupIntervalMs: number; + }; + retry: { + maxAttempts: number; + initialDelayMs: number; + maxDelayMs: number; + terminalRetryDelayMs: number; + attemptTimeoutMs: number; + scanIntervalMs: number; + }; + }; + gitOpsDelivery: GiteaWebhookGitOpsDelivery; + frpc: { + proxyName: string; + remotePort: number; + }; +} + +export interface GiteaWebhookGitOpsDelivery { + enabled: boolean; + targetId: string; + readUrl: string; + writeUrl: string; + branch: string; + sourceSnapshotPrefix: string; + desiredManifestPath: string; + bootstrapApplicationPath: string; + releaseStatePath: string; + application: { + name: string; + namespace: string; + project: string; + repoUrl: string; + targetRevision: string; + path: string; + destinationNamespace: string; + automated: boolean; + }; + author: { + name: string; + email: string; + }; + cas: { + maxAttempts: number; + }; +} + +export interface GiteaSourceResponsibility { + name: string; + current: string; + target: string; + disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; +} + +export interface GiteaMirrorRepository { + key: string; + targetId: string; + upstream: { + repository: string; + cloneUrl: string; + branch: string; + }; + gitea: { + owner: string; + name: string; + mirrorMode: "controlled-push"; + publicRead: boolean; + readUrl: string; + }; + gitops: { + branch: string; + flushDisposition: string; + }; + snapshot: { + prefix: string; + }; + legacyGitMirror: { + readUrl: string; + configRef: string; + disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; + }; +} + +export function readGiteaConfig(): GiteaConfig { + const root = readYamlRecord>(configFile, "platform-infra-gitea"); + const version = y.integerField(root, "version", ""); + if (version !== 1) throw new Error(`${configLabel}.version must be 1`); + const metadata = y.objectField(root, "metadata", ""); + const defaults = y.objectField(root, "defaults", ""); + const migration = y.objectField(root, "migration", ""); + const sourceAuthority = y.objectField(root, "sourceAuthority", ""); + const sourceCredentials = y.objectField(sourceAuthority, "credentials", "sourceAuthority"); + const githubProxy = y.objectField(sourceAuthority, "githubProxy", "sourceAuthority"); + const webhookSync = y.objectField(sourceAuthority, "webhookSync", "sourceAuthority"); + const app = y.objectField(root, "app", ""); + const image = y.objectField(app, "image", "app"); + const service = y.objectField(app, "service", "app"); + const server = y.objectField(app, "server", "app"); + const database = y.objectField(app, "database", "app"); + const actions = y.objectField(app, "actions", "app"); + const webhook = y.objectField(app, "webhook", "app"); + const registration = y.objectField(app, "registration", "app"); + const storage = y.objectField(app, "storage", "app"); + const dataStorage = y.objectField(storage, "data", "app.storage"); + const configStorage = y.objectField(storage, "config", "app.storage"); + const publicExposure = y.objectField(app, "publicExposure", "app"); + const securityContext = y.objectField(app, "securityContext", "app"); + const resources = y.objectField(app, "resources", "app"); + const requests = y.objectField(resources, "requests", "app.resources"); + const limits = y.objectField(resources, "limits", "app.resources"); + const probes = y.objectField(app, "probes", "app"); + const validation = y.objectField(root, "validation", ""); + const parsed: GiteaConfig = { + version, + kind: "platform-infra-gitea", + metadata: { + id: y.stringField(metadata, "id", "metadata"), + owner: y.stringField(metadata, "owner", "metadata"), + spec: y.stringField(metadata, "spec", "metadata"), + relatedIssues: y.numberArrayField(metadata, "relatedIssues", "metadata"), + }, + defaults: { + targetId: y.stringField(defaults, "targetId", "defaults"), + }, + migration: { + role: y.stringField(migration, "role", "migration"), + replaces: y.stringField(migration, "replaces", "migration"), + parentConfigRef: y.stringField(migration, "parentConfigRef", "migration"), + envReusePolicy: y.stringField(migration, "envReusePolicy", "migration"), + buildPlane: y.stringField(migration, "buildPlane", "migration"), + runtimePlane: y.stringField(migration, "runtimePlane", "migration"), + }, + sourceAuthority: { + enabled: y.booleanField(sourceAuthority, "enabled", "sourceAuthority"), + stage: y.stringField(sourceAuthority, "stage", "sourceAuthority"), + statusAuthority: y.stringField(sourceAuthority, "statusAuthority", "sourceAuthority"), + firstCiConsumer: y.stringField(sourceAuthority, "firstCiConsumer", "sourceAuthority"), + credentials: { + sourceRoot: y.absolutePathField(sourceCredentials, "sourceRoot", "sourceAuthority.credentials"), + admin: parseAdminCredential(y.objectField(sourceCredentials, "admin", "sourceAuthority.credentials"), "sourceAuthority.credentials.admin"), + github: parseGithubCredential(y.objectField(sourceCredentials, "github", "sourceAuthority.credentials"), "sourceAuthority.credentials.github"), + }, + githubProxy: { + enabled: y.booleanField(githubProxy, "enabled", "sourceAuthority.githubProxy"), + url: urlField(githubProxy, "url", "sourceAuthority.githubProxy"), + noProxy: y.stringArrayField(githubProxy, "noProxy", "sourceAuthority.githubProxy"), + }, + webhookSync: parseWebhookSync(webhookSync, "sourceAuthority.webhookSync"), + responsibilities: y.arrayOfRecords(sourceAuthority.responsibilities, "sourceAuthority.responsibilities").map(parseResponsibility), + repositories: y.arrayOfRecords(sourceAuthority.repositories, "sourceAuthority.repositories").map(parseMirrorRepository), + }, + targets: y.arrayOfRecords(root.targets, "targets").map(parseTarget), + app: { + name: y.kubernetesNameField(app, "name", "app"), + statefulSetName: y.kubernetesNameField(app, "statefulSetName", "app"), + serviceName: y.kubernetesNameField(app, "serviceName", "app"), + replicas: positiveInteger(app, "replicas", "app"), + image: { + repository: y.stringField(image, "repository", "app.image"), + tag: y.stringField(image, "tag", "app.image"), + pullPolicy: y.enumField(image, "pullPolicy", "app.image", ["Always", "IfNotPresent", "Never"] as const), + }, + service: { + type: y.enumField(service, "type", "app.service", ["ClusterIP"] as const), + httpPort: y.portField(service, "httpPort", "app.service"), + sshPort: y.portField(service, "sshPort", "app.service"), + }, + server: { + domain: y.hostField(server, "domain", "app.server"), + rootUrl: urlField(server, "rootUrl", "app.server"), + sshDomain: y.hostField(server, "sshDomain", "app.server"), + protocol: y.enumField(server, "protocol", "app.server", ["http", "https"] as const), + startSshServer: y.booleanField(server, "startSshServer", "app.server"), + }, + publicExposure: parsePublicExposure(publicExposure, "app.publicExposure"), + database: { + type: y.enumField(database, "type", "app.database", ["sqlite3"] as const), + path: y.absolutePathField(database, "path", "app.database"), + }, + actions: { + enabled: y.booleanField(actions, "enabled", "app.actions"), + }, + webhook: { + allowedHostList: y.stringField(webhook, "allowedHostList", "app.webhook"), + }, + registration: { + disabled: y.booleanField(registration, "disabled", "app.registration"), + }, + storage: { + data: { size: quantity(dataStorage, "size", "app.storage.data"), mountPath: y.absolutePathField(dataStorage, "mountPath", "app.storage.data") }, + config: { size: quantity(configStorage, "size", "app.storage.config"), mountPath: y.absolutePathField(configStorage, "mountPath", "app.storage.config") }, + }, + securityContext: { + runAsUser: positiveInteger(securityContext, "runAsUser", "app.securityContext"), + runAsGroup: positiveInteger(securityContext, "runAsGroup", "app.securityContext"), + fsGroup: positiveInteger(securityContext, "fsGroup", "app.securityContext"), + }, + resources: { + requests: { cpu: y.stringField(requests, "cpu", "app.resources.requests"), memory: quantity(requests, "memory", "app.resources.requests") }, + limits: { cpu: y.stringField(limits, "cpu", "app.resources.limits"), memory: quantity(limits, "memory", "app.resources.limits") }, + }, + probes: { + healthPath: y.apiPathField(probes, "healthPath", "app.probes"), + initialDelaySeconds: positiveInteger(probes, "initialDelaySeconds", "app.probes"), + periodSeconds: positiveInteger(probes, "periodSeconds", "app.probes"), + timeoutSeconds: positiveInteger(probes, "timeoutSeconds", "app.probes"), + failureThreshold: positiveInteger(probes, "failureThreshold", "app.probes"), + }, + }, + validation: { + waitTimeoutSeconds: boundedTimeout(validation, "waitTimeoutSeconds", "validation"), + healthPath: y.apiPathField(validation, "healthPath", "validation"), + }, + }; + validateConfig(parsed); + return parsed; +} + +function parseAdminCredential(record: Record, path: string): GiteaAdminCredential { + return { + sourceRef: secretSourceRefField(record, "sourceRef", path), + format: literalField(record, "format", path, ["line-pair"]), + usernameLine: positiveInteger(record, "usernameLine", path), + passwordLine: positiveInteger(record, "passwordLine", path), + requiredFor: y.stringArrayField(record, "requiredFor", path), + }; +} + +function parseGithubCredential(record: Record, path: string): GiteaGithubCredential { + const gitFetchCredential = y.objectField(record, "gitFetchCredential", path); + const secretRef = y.objectField(gitFetchCredential, "secretRef", `${path}.gitFetchCredential`); + return { + transport: y.enumField(record, "transport", path, ["https-token"] as const), + sourceRef: secretSourceRefField(record, "sourceRef", path), + sourceKey: y.envKeyField(record, "sourceKey", path), + format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env", + requiredFor: y.stringArrayField(record, "requiredFor", path), + gitFetchCredential: { + apiVersion: y.enumField(gitFetchCredential, "apiVersion", `${path}.gitFetchCredential`, ["unidesk.ai/v1"] as const), + kind: y.enumField(gitFetchCredential, "kind", `${path}.gitFetchCredential`, ["GitFetchCredential"] as const), + authMode: y.enumField(gitFetchCredential, "authMode", `${path}.gitFetchCredential`, ["github-https-token"] as const), + host: y.enumField(gitFetchCredential, "host", `${path}.gitFetchCredential`, ["github.com"] as const), + secretRef: { + namespace: y.kubernetesNameField(secretRef, "namespace", `${path}.gitFetchCredential.secretRef`), + name: y.kubernetesNameField(secretRef, "name", `${path}.gitFetchCredential.secretRef`), + key: y.stringField(secretRef, "key", `${path}.gitFetchCredential.secretRef`), + }, + }, + }; +} + +function parseWebhookSync(record: Record, path: string): GiteaWebhookSync { + const ingressRetry = y.objectField(record, "ingressRetry", path); + const secret = y.objectField(record, "secret", path); + const bridge = y.objectField(record, "bridge", path); + const candidateGate = y.objectField(bridge, "candidateGate", `${path}.bridge`); + const inbox = y.objectField(bridge, "inbox", `${path}.bridge`); + const gitOpsDelivery = y.objectField(record, "gitOpsDelivery", path); + const frpc = y.objectField(record, "frpc", path); + return { + enabled: y.booleanField(record, "enabled", path), + direction: y.enumField(record, "direction", path, ["github-to-gitea"] as const), + responseBudgetMs: positiveInteger(record, "responseBudgetMs", path), + publicPath: y.apiPathField(record, "publicPath", path), + events: y.stringArrayField(record, "events", path), + ingressRetry: { + enabled: y.booleanField(ingressRetry, "enabled", `${path}.ingressRetry`), + maxBodyBytes: positiveInteger(ingressRetry, "maxBodyBytes", `${path}.ingressRetry`), + maxRetries: positiveInteger(ingressRetry, "maxRetries", `${path}.ingressRetry`), + tryIntervalMs: positiveInteger(ingressRetry, "tryIntervalMs", `${path}.ingressRetry`), + dialTimeoutMs: positiveInteger(ingressRetry, "dialTimeoutMs", `${path}.ingressRetry`), + writeTimeoutMs: positiveInteger(ingressRetry, "writeTimeoutMs", `${path}.ingressRetry`), + retryStatusCodes: y.numberArrayField(ingressRetry, "retryStatusCodes", `${path}.ingressRetry`), + }, + secret: { + sourceRef: secretSourceRefField(secret, "sourceRef", `${path}.secret`), + sourceKey: y.envKeyField(secret, "sourceKey", `${path}.secret`), + createIfMissing: y.booleanField(secret, "createIfMissing", `${path}.secret`), + }, + bridge: { + deploymentName: y.kubernetesNameField(bridge, "deploymentName", `${path}.bridge`), + serviceName: y.kubernetesNameField(bridge, "serviceName", `${path}.bridge`), + secretName: y.kubernetesNameField(bridge, "secretName", `${path}.bridge`), + configMapName: y.kubernetesNameField(bridge, "configMapName", `${path}.bridge`), + image: y.stringField(bridge, "image", `${path}.bridge`), + replicas: positiveInteger(bridge, "replicas", `${path}.bridge`), + httpPort: y.portField(bridge, "httpPort", `${path}.bridge`), + serviceAccountName: y.kubernetesNameField(bridge, "serviceAccountName", `${path}.bridge`), + shutdownGraceMs: positiveInteger(bridge, "shutdownGraceMs", `${path}.bridge`), + candidateGate: { + enabled: y.booleanField(candidateGate, "enabled", `${path}.bridge.candidateGate`), + configMapName: y.kubernetesNameField(candidateGate, "configMapName", `${path}.bridge.candidateGate`), + jobName: y.kubernetesNameField(candidateGate, "jobName", `${path}.bridge.candidateGate`), + activeDeadlineSeconds: positiveInteger(candidateGate, "activeDeadlineSeconds", `${path}.bridge.candidateGate`), + ttlSecondsAfterFinished: positiveInteger(candidateGate, "ttlSecondsAfterFinished", `${path}.bridge.candidateGate`), + healthTimeoutMs: positiveInteger(candidateGate, "healthTimeoutMs", `${path}.bridge.candidateGate`), + pollIntervalMs: positiveInteger(candidateGate, "pollIntervalMs", `${path}.bridge.candidateGate`), + }, + inbox: { + claimName: y.kubernetesNameField(inbox, "claimName", `${path}.bridge.inbox`), + path: y.absolutePathField(inbox, "path", `${path}.bridge.inbox`), + storageSize: quantity(inbox, "storageSize", `${path}.bridge.inbox`), + maxBytes: positiveInteger(inbox, "maxBytes", `${path}.bridge.inbox`), + committedRetentionSeconds: positiveInteger(inbox, "committedRetentionSeconds", `${path}.bridge.inbox`), + cleanupIntervalMs: positiveInteger(inbox, "cleanupIntervalMs", `${path}.bridge.inbox`), + }, + retry: parseWebhookRetry(y.objectField(bridge, "retry", `${path}.bridge`), `${path}.bridge.retry`), + }, + gitOpsDelivery: parseWebhookGitOpsDelivery(gitOpsDelivery, `${path}.gitOpsDelivery`), + frpc: { + proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), + remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), + }, + }; +} + +function parseWebhookGitOpsDelivery(record: Record, path: string): GiteaWebhookGitOpsDelivery { + const application = y.objectField(record, "application", path); + const author = y.objectField(record, "author", path); + const cas = y.objectField(record, "cas", path); + return { + enabled: y.booleanField(record, "enabled", path), + targetId: y.stringField(record, "targetId", path), + readUrl: urlField(record, "readUrl", path), + writeUrl: urlField(record, "writeUrl", path), + branch: gitBranchField(record, "branch", path), + sourceSnapshotPrefix: refPrefixField(record, "sourceSnapshotPrefix", path), + desiredManifestPath: safeRelativePathField(record, "desiredManifestPath", path), + bootstrapApplicationPath: safeRelativePathField(record, "bootstrapApplicationPath", path), + releaseStatePath: safeRelativePathField(record, "releaseStatePath", path), + application: { + name: y.kubernetesNameField(application, "name", `${path}.application`), + namespace: y.kubernetesNameField(application, "namespace", `${path}.application`), + project: y.kubernetesNameField(application, "project", `${path}.application`), + repoUrl: urlField(application, "repoUrl", `${path}.application`), + targetRevision: gitBranchField(application, "targetRevision", `${path}.application`), + path: safeRelativePathField(application, "path", `${path}.application`), + destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", `${path}.application`), + automated: y.booleanField(application, "automated", `${path}.application`), + }, + author: { + name: y.stringField(author, "name", `${path}.author`), + email: y.stringField(author, "email", `${path}.author`), + }, + cas: { + maxAttempts: positiveInteger(cas, "maxAttempts", `${path}.cas`), + }, + }; +} + +function parseWebhookRetry(record: Record, path: string): GiteaWebhookSync["bridge"]["retry"] { + return { + maxAttempts: positiveInteger(record, "maxAttempts", path), + initialDelayMs: positiveInteger(record, "initialDelayMs", path), + maxDelayMs: positiveInteger(record, "maxDelayMs", path), + terminalRetryDelayMs: positiveInteger(record, "terminalRetryDelayMs", path), + attemptTimeoutMs: positiveInteger(record, "attemptTimeoutMs", path), + scanIntervalMs: positiveInteger(record, "scanIntervalMs", path), + }; +} + +function parsePublicExposure(record: Record, path: string): PublicServiceExposure & { secretRoot: string } { + const dns = y.objectField(record, "dns", path); + const frpc = y.objectField(record, "frpc", path); + const pk01 = y.objectField(record, "pk01", path); + const publicBaseUrl = httpsUrlField(record, "publicBaseUrl", path); + const hostname = y.hostField(dns, "hostname", `${path}.dns`); + if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${configLabel}.${path}.dns.hostname must match publicBaseUrl`); + return { + enabled: y.booleanField(record, "enabled", path), + publicBaseUrl, + secretRoot: y.absolutePathField(record, "secretRoot", path), + dns: { + hostname, + expectedA: y.stringField(dns, "expectedA", `${path}.dns`), + resolvers: y.stringArrayField(dns, "resolvers", `${path}.dns`), + }, + frpc: { + deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.frpc`), + secretName: y.kubernetesNameField(frpc, "secretName", `${path}.frpc`), + secretKey: y.stringField(frpc, "secretKey", `${path}.frpc`), + image: y.stringField(frpc, "image", `${path}.frpc`), + serverAddr: y.hostField(frpc, "serverAddr", `${path}.frpc`), + serverPort: y.portField(frpc, "serverPort", `${path}.frpc`), + proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), + remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), + localIP: y.hostField(frpc, "localIP", `${path}.frpc`), + localPort: y.portField(frpc, "localPort", `${path}.frpc`), + tokenSourceRef: secretSourceRefField(frpc, "tokenSourceRef", `${path}.frpc`), + tokenSourceKey: y.envKeyField(frpc, "tokenSourceKey", `${path}.frpc`), + }, + pk01: { + route: y.stringField(pk01, "route", `${path}.pk01`), + caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.pk01`), + caddyServiceName: y.stringField(pk01, "caddyServiceName", `${path}.pk01`), + responseHeaderTimeoutSeconds: y.integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.pk01`), + }, + }; +} + +function parseResponsibility(record: Record, index: number): GiteaSourceResponsibility { + const path = `sourceAuthority.responsibilities[${index}]`; + return { + name: y.stringField(record, "name", path), + current: y.stringField(record, "current", path), + target: y.stringField(record, "target", path), + disposition: y.enumField(record, "disposition", path, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), + }; +} + +function parseMirrorRepository(record: Record, index: number): GiteaMirrorRepository { + const path = `sourceAuthority.repositories[${index}]`; + const upstream = y.objectField(record, "upstream", path); + const gitea = y.objectField(record, "gitea", path); + const gitops = y.objectField(record, "gitops", path); + const snapshot = y.objectField(record, "snapshot", path); + const legacyGitMirror = y.objectField(record, "legacyGitMirror", path); + return { + key: y.stringField(record, "key", path), + targetId: y.stringField(record, "targetId", path), + upstream: { + repository: repositoryField(upstream, "repository", `${path}.upstream`), + cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`), + branch: y.stringField(upstream, "branch", `${path}.upstream`), + }, + gitea: { + owner: y.stringField(gitea, "owner", `${path}.gitea`), + name: giteaRepoNameField(gitea, "name", `${path}.gitea`), + mirrorMode: y.enumField(gitea, "mirrorMode", `${path}.gitea`, ["controlled-push"] as const), + publicRead: y.booleanField(gitea, "publicRead", `${path}.gitea`), + readUrl: urlField(gitea, "readUrl", `${path}.gitea`), + }, + gitops: { + branch: y.stringField(gitops, "branch", `${path}.gitops`), + flushDisposition: y.stringField(gitops, "flushDisposition", `${path}.gitops`), + }, + snapshot: { + prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`), + }, + legacyGitMirror: { + readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`), + configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`), + disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), + }, + }; +} + +function parseTarget(record: Record, index: number): GiteaTarget { + const path = `targets[${index}]`; + const publicExposureRaw = record.publicExposure; + if (publicExposureRaw !== undefined && (typeof publicExposureRaw !== "object" || publicExposureRaw === null || Array.isArray(publicExposureRaw))) { + throw new Error(`${configLabel}.${path}.publicExposure must be an object`); + } + const webhookSyncRaw = record.webhookSync; + if (webhookSyncRaw !== undefined && (typeof webhookSyncRaw !== "object" || webhookSyncRaw === null || Array.isArray(webhookSyncRaw))) { + throw new Error(`${configLabel}.${path}.webhookSync must be an object`); + } + const publicExposure = publicExposureRaw as Record | undefined; + return { + id: y.stringField(record, "id", path), + route: y.stringField(record, "route", path), + namespace: y.kubernetesNameField(record, "namespace", path), + role: y.stringField(record, "role", path), + enabled: y.booleanField(record, "enabled", path), + createNamespace: y.booleanField(record, "createNamespace", path), + storageClassName: y.stringField(record, "storageClassName", path), + publicExposureEnabled: publicExposure === undefined ? true : y.booleanField(publicExposure, "enabled", `${path}.publicExposure`), + webhookSync: webhookSyncRaw === undefined ? null : parseTargetWebhookSync(webhookSyncRaw as Record, `${path}.webhookSync`), + }; +} + +function parseTargetWebhookSync(record: Record, path: string): GiteaTargetWebhookSync { + const frpc = y.objectField(record, "frpc", path); + return { + publicPath: y.apiPathField(record, "publicPath", path), + frpc: { + proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), + remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), + }, + }; +} + +function validateConfig(gitea: GiteaConfig): void { + resolveTarget(gitea, gitea.defaults.targetId); + const gitFetchCredential = gitea.sourceAuthority.credentials.github.gitFetchCredential; + if (!gitea.sourceAuthority.credentials.github.requiredFor.includes("managed-repository-fetch")) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.requiredFor must include managed-repository-fetch`); + } + if (!/^[A-Za-z0-9._-]+$/u.test(gitFetchCredential.secretRef.key)) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key must be a Kubernetes Secret data key`); + } + if (gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`); + } + if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`); + } + if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`); + if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`); + if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`); + if (!/-rootless$/u.test(gitea.app.image.tag)) throw new Error(`${configLabel}.app.image.tag must use a rootless Gitea image`); + if (gitea.app.service.type !== "ClusterIP") throw new Error(`${configLabel}.app.service.type must stay ClusterIP`); + if (!gitea.app.actions.enabled) throw new Error(`${configLabel}.app.actions.enabled must stay explicit while Gitea is the source-authority service`); + if (!gitea.app.registration.disabled) throw new Error(`${configLabel}.app.registration.disabled must be true for the internal POC service`); + if (gitea.app.probes.healthPath !== gitea.validation.healthPath) throw new Error(`${configLabel}.app.probes.healthPath must match validation.healthPath`); + if (gitea.app.server.rootUrl !== gitea.app.publicExposure.publicBaseUrl.replace(/\/?$/u, "/")) throw new Error(`${configLabel}.app.server.rootUrl must match app.publicExposure.publicBaseUrl for the Web UI`); + if (gitea.sourceAuthority.webhookSync.enabled) { + const events = new Set(gitea.sourceAuthority.webhookSync.events); + if (!events.has("push")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.events must include push for GitHub -> Gitea source sync`); + if (gitea.sourceAuthority.webhookSync.frpc.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel}.sourceAuthority.webhookSync.frpc.remotePort must differ from app.publicExposure.frpc.remotePort`); + const ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry; + const bridge = gitea.sourceAuthority.webhookSync.bridge; + if (bridge.replicas !== 1) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.replicas must be 1 because the durable inbox uses a single-writer Recreate deployment`); + if (bridge.retry.terminalRetryDelayMs < bridge.retry.maxDelayMs) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.retry.terminalRetryDelayMs must be >= maxDelayMs`); + if (ingressRetry.retryStatusCodes.length < 1 || ingressRetry.retryStatusCodes.some((statusCode) => ![502, 503, 504].includes(statusCode))) { + throw new Error(`${configLabel}.sourceAuthority.webhookSync.ingressRetry.retryStatusCodes may only contain 502, 503 and 504`); + } + if (ingressRetry.enabled) { + try { + validateGiteaWebhookTiming(gitea.sourceAuthority.webhookSync.responseBudgetMs, ingressRetry); + } catch (error) { + throw new Error(`${configLabel}.sourceAuthority.webhookSync timing invalid: ${String((error as Error).message || error)}`); + } + } + const delivery = gitea.sourceAuthority.webhookSync.gitOpsDelivery; + if (delivery.enabled) { + const target = resolveTarget(gitea, delivery.targetId); + if (target.id !== "NC01") throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.targetId must be NC01`); + if (delivery.application.targetRevision !== delivery.branch) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.targetRevision must match branch`); + if (delivery.application.repoUrl !== delivery.readUrl) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.repoUrl must match readUrl`); + if (dirname(delivery.desiredManifestPath) !== delivery.application.path) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must be directly under application.path`); + if (!delivery.bootstrapApplicationPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.bootstrapApplicationPath must be under the existing unidesk-host bootstrap path`); + if (delivery.desiredManifestPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must use an independent GitOps path`); + if (!/^[^@\s]+@[^@\s]+$/u.test(delivery.author.email)) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.author.email must be an email address`); + if (delivery.cas.maxAttempts > 5) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.cas.maxAttempts must be <= 5`); + } + } + const webhookPaths = new Set(); + const webhookPorts = new Set(); + for (const route of webhookCaddyRoutes(gitea)) { + if (webhookPaths.has(route.publicPath)) throw new Error(`${configLabel} webhook publicPath must be unique: ${route.publicPath}`); + if (webhookPorts.has(route.remotePort)) throw new Error(`${configLabel} webhook frpc.remotePort must be unique: ${route.remotePort}`); + if (route.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel} webhook frpc.remotePort must differ from app public exposure port: ${route.remotePort}`); + webhookPaths.add(route.publicPath); + webhookPorts.add(route.remotePort); + } + const repoKeys = new Set(); + for (const repo of gitea.sourceAuthority.repositories) { + if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`); + repoKeys.add(repo.key); + resolveTarget(gitea, repo.targetId); + const readUrl = new URL(repo.gitea.readUrl); + if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`); + if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`); + } +} + +export function resolveTarget(gitea: GiteaConfig, targetId: string | null): GiteaTarget { + const resolved = targetId ?? gitea.defaults.targetId; + const target = gitea.targets.find((item) => item.id.toLowerCase() === resolved.toLowerCase()); + if (target === undefined) throw new Error(`unknown gitea target ${resolved}; known targets: ${gitea.targets.map((item) => item.id).join(", ")}`); + if (!target.enabled) throw new Error(`gitea target ${target.id} is disabled in ${configLabel}`); + return target; +} + +export function targetWebhookSync(gitea: GiteaConfig, target: GiteaTarget): GiteaWebhookSync { + const base = gitea.sourceAuthority.webhookSync; + if (target.webhookSync === null) return base; + return { + ...base, + publicPath: target.webhookSync.publicPath, + frpc: target.webhookSync.frpc, + }; +} + +export function webhookCaddyRoutes(gitea: GiteaConfig): Array<{ publicPath: string; remotePort: number }> { + const sync = gitea.sourceAuthority.webhookSync; + if (!sync.enabled) return []; + const routes = new Map(); + routes.set(sync.publicPath, { publicPath: sync.publicPath, remotePort: sync.frpc.remotePort }); + for (const target of gitea.targets) { + if (!target.enabled || target.webhookSync === null) continue; + const targetSync = targetWebhookSync(gitea, target); + routes.set(targetSync.publicPath, { publicPath: targetSync.publicPath, remotePort: targetSync.frpc.remotePort }); + } + return [...routes.values()]; +} + +function positiveInteger(obj: Record, key: string, path: string): number { + const value = y.integerField(obj, key, path); + if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`); + return value; +} + +function boundedTimeout(obj: Record, key: string, path: string): number { + const value = positiveInteger(obj, key, path); + if (value > 55) throw new Error(`${configLabel}.${path}.${key} must fit the 60s trans short-connection budget`); + return value; +} + +function literalField(obj: Record, key: string, path: string, allowed: T[]): T { + const value = y.stringField(obj, key, path); + if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`); + return value as T; +} + +function optionalLiteralField(obj: Record, key: string, path: string, allowed: readonly T[]): T | null { + if (obj[key] === undefined || obj[key] === null) return null; + const value = y.stringField(obj, key, path); + if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`); + return value as T; +} + +function quantity(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^[0-9]+(?:m|Ki|Mi|Gi|Ti)?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes quantity`); + return value; +} + +function secretSourceRefField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a source ref path without ..`); + return value; +} + +function repositoryField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be owner/repo`); + return value; +} + +function giteaRepoNameField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^[A-Za-z0-9_.-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a Gitea repository name`); + return value; +} + +function gitCloneUrlField(obj: Record, key: string, path: string): string { + const value = urlField(obj, key, path); + if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be an official GitHub HTTPS clone URL`); + return value; +} + +function refPrefixField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a git ref prefix`); + return value.replace(/\/+$/u, ""); +} + +function urlField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + const parsed = new URL(value); + if (!["http:", "https:"].includes(parsed.protocol) || parsed.search || parsed.hash) throw new Error(`${configLabel}.${path}.${key} must be an http(s) URL without query or hash`); + return value; +} + +function gitBranchField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (!/^[A-Za-z0-9._/-]+$/u.test(value) || value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//")) { + throw new Error(`${configLabel}.${path}.${key} must be a safe Git branch name`); + } + return value; +} + +function safeRelativePathField(obj: Record, key: string, path: string): string { + const value = y.stringField(obj, key, path); + if (value.startsWith("/") || value.endsWith("/") || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) { + throw new Error(`${configLabel}.${path}.${key} must be a safe relative path`); + } + return value; +} + +function httpsUrlField(obj: Record, key: string, path: string): string { + const value = urlField(obj, key, path); + if (new URL(value).protocol !== "https:") throw new Error(`${configLabel}.${path}.${key} must be an https URL`); + return value; +} diff --git a/scripts/src/platform-infra-gitea-disclosure.test.ts b/scripts/src/platform-infra-gitea-disclosure.test.ts new file mode 100644 index 00000000..72b55b69 --- /dev/null +++ b/scripts/src/platform-infra-gitea-disclosure.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { buildMirrorWebhookStatusDisclosure, rawMirrorWebhookStatus } from "./platform-infra-gitea-disclosure"; + +describe("Gitea webhook status progressive disclosure", () => { + test("keeps --full as a bounded domain aggregate with repository drill-down commands", () => { + const view = buildMirrorWebhookStatusDisclosure(fixture(), { repoKey: null, deliveryId: null }); + const serialized = JSON.stringify({ ok: true, command: "status --full", data: view }, null, 2); + + expect(view.ok).toBe(true); + expect(view.view).toBe("domain-aggregate"); + expect((view.repositories as unknown[]).length).toBe(2); + expect((view.deliveries as unknown[]).length).toBe(2); + expect((view.bridge as { durableInbox: { retainedDeliveryCount: number } }).durableInbox.retainedDeliveryCount).toBe(82); + expect(Object.prototype.hasOwnProperty.call(view, "raw")).toBe(false); + expect(serialized).not.toContain('"payloadSha"'); + expect(serialized).not.toContain("outputTruncated"); + expect(Buffer.byteLength(serialized, "utf8")).toBeLessThan(10_240); + expect((view.next as { repositories: Array<{ command: string }> }).repositories[0]?.command).toContain("--repo repo-a"); + }); + + test("repository and deliveryId drill-down expose one inbox record, retry, logs and first error", () => { + const repository = buildMirrorWebhookStatusDisclosure(fixture(), { repoKey: "repo-a", deliveryId: null }); + expect(repository.view).toBe("repository-drill-down"); + expect((repository.deliveries as unknown[]).length).toBe(1); + expect((repository.selection as { effectiveDeliveryId: string }).effectiveDeliveryId).toBe("delivery-current-a"); + expect((repository.retry as { totalAttempts: number }).totalAttempts).toBe(1); + + const delivery = buildMirrorWebhookStatusDisclosure(fixture(), { repoKey: null, deliveryId: "delivery-5" }); + expect(delivery.ok).toBe(true); + expect(delivery.view).toBe("delivery-drill-down"); + expect((delivery.selection as { selectedRepoKey: string }).selectedRepoKey).toBe("repo-a"); + expect((delivery.deliveries as Array<{ deliveryId: string }>)[0]?.deliveryId).toBe("delivery-5"); + expect((delivery.retry as { totalAttempts: number; lastError: string }).totalAttempts).toBe(3); + expect((delivery.retry as { lastError: string }).lastError).toContain("authentication-failed"); + expect((delivery.logs as { matched: number; events: unknown[] }).matched).toBe(1); + expect((delivery.diagnostics as { firstError: { domain: string } }).firstError.domain).toBe("durable-inbox-record"); + }); + + test("reports missing retained delivery without hiding the operational payload", () => { + const view = buildMirrorWebhookStatusDisclosure(fixture(), { repoKey: "repo-a", deliveryId: "not-retained" }); + expect(view.ok).toBe(false); + expect((view.selection as { error: string }).error).toBe("delivery-not-observed-in-retained-evidence"); + + const raw = rawMirrorWebhookStatus(fixture()); + expect(raw.view).toBe("raw"); + expect(raw.raw).toBeDefined(); + expect((raw.safety as { explicitRawRequired: boolean; valuesPrinted: boolean }).explicitRawRequired).toBe(true); + expect((raw.safety as { valuesPrinted: boolean }).valuesPrinted).toBe(false); + }); +}); + +function fixture(): Record { + const retained = Array.from({ length: 80 }, (_, index) => deliveryRecord(`delivery-${index}`, index === 5 || index % 2 === 0 ? "repo-a" : "repo-b", { + totalAttempts: index === 5 ? 3 : 1, + lastError: index === 5 ? { type: "authentication-failed", message: "authentication-failed" } : null, + })); + const currentA = deliveryRecord("delivery-current-a", "repo-a"); + const currentB = deliveryRecord("delivery-current-b", "repo-b"); + const bridge = { + deployment: "gitea-github-sync", + ready: true, + readyReplicas: "1/1", + serviceExists: true, + durableInbox: { + ready: true, + storageReady: true, + capacityAvailable: true, + counts: { committed: 81, retryWait: 1 }, + deliveries: [...retained, currentA, currentB], + totalBytes: 2048, + maxBytes: 1_048_576, + errorType: null, + journal: { version: 1, kind: "GiteaGithubSyncDurableInbox", createdAt: "2026-07-11T00:00:00Z" }, + }, + }; + return { + ok: true, + action: "platform-infra-gitea-mirror-webhook-status", + mutation: false, + target: { id: "NC01", route: "NC01:k3s", namespace: "devops-infra" }, + webhook: { + enabled: true, + direction: "github-to-gitea", + publicUrl: "https://gitea.example/_unidesk/github-to-gitea/nc01", + events: ["push"], + responseBudgetMs: 8_000, + ingressAttemptBudgetMs: 2_000, + ingressResponseHeaderTimeoutMs: 3_000, + ingressRetry: { enabled: true, maxAttempts: 3, initialDelayMs: 1_000, maxDelayMs: 30_000 }, + bridge: { deploymentName: "gitea-github-sync", serviceName: "gitea-github-sync" }, + }, + bridge, + repositories: [repository("repo-a", currentA), repository("repo-b", currentB)], + bridgeLogs: { + exitCode: 0, + events: [ + { event: "github-to-gitea-sync", repo: "repo-a", deliveryId: "delivery-current-a", ok: true, terminal: true, syncAttempt: 1, disposition: "committed", elapsedMs: 90 }, + { event: "github-to-gitea-sync", repo: "repo-a", deliveryId: "delivery-5", ok: false, terminal: true, syncAttempt: 3, errorType: "authentication-failed", error: "authentication-failed", elapsedMs: 120 }, + ], + errorTail: "", + }, + mirrorStatus: { exitCode: 0, errorTail: "" }, + remote: { ok: true, bridge, repositories: [repository("repo-a", currentA), repository("repo-b", currentB)] }, + }; +} + +function repository(key: string, delivery: Record): Record { + return { + key, + repository: `pikasTech/${key}`, + branch: "master", + hookReady: true, + githubHead: "a".repeat(40), + branchCommit: "a".repeat(40), + snapshotCommit: "a".repeat(40), + authorityMatches: true, + bridgeEventStale: false, + staleReason: null, + deliveryId: delivery.deliveryId, + deliveryAccepted: true, + durableInboxState: "committed", + durableInboxCommitted: true, + durableInboxRecord: delivery, + error: null, + }; +} + +function deliveryRecord(deliveryId: string, repo: string, overrides: Record = {}): Record { + return { + deliveryId, + repo, + repository: `pikasTech/${repo}`, + ref: "refs/heads/master", + requestedCommit: "a".repeat(40), + payloadSha: "b".repeat(64), + state: "committed", + acceptedSequence: 1, + totalAttempts: 1, + cycleAttempt: 1, + nextAttemptAt: null, + updatedAt: "2026-07-11T00:00:00Z", + result: { + sourceCommit: "a".repeat(40), + authorityCommit: "a".repeat(40), + snapshotRef: `refs/unidesk/snapshots/${repo}/${"a".repeat(40)}`, + disposition: "atomic-source-authority-committed", + }, + lastError: null, + ...overrides, + }; +} diff --git a/scripts/src/platform-infra-gitea-disclosure.ts b/scripts/src/platform-infra-gitea-disclosure.ts new file mode 100644 index 00000000..7189e62a --- /dev/null +++ b/scripts/src/platform-infra-gitea-disclosure.ts @@ -0,0 +1,330 @@ +export interface MirrorWebhookDisclosureSelection { + repoKey: string | null; + deliveryId: string | null; +} + +export function rawMirrorWebhookStatus(result: Record): Record { + return { + ok: result.ok === true, + action: "platform-infra-gitea-mirror-webhook-status", + mutation: false, + view: "raw", + target: result.target, + raw: record(result.remote), + safety: { + explicitRawRequired: true, + valuesPrinted: false, + stdoutGuard: "The YAML-configured stdout guard remains active; oversized raw evidence is written once to the protected dump path.", + }, + }; +} + +export function buildMirrorWebhookStatusDisclosure( + result: Record, + selection: MirrorWebhookDisclosureSelection, +): Record { + const target = record(result.target); + const targetId = stringValue(target.id, ""); + const webhook = record(result.webhook); + const webhookRetry = record(webhook.ingressRetry); + const webhookBridge = record(webhook.bridge); + const bridge = record(result.bridge); + const durableInbox = record(bridge.durableInbox); + const allRepositories = arrayRecords(result.repositories); + const inboxDeliveries = arrayRecords(durableInbox.deliveries); + const bridgeLogs = record(result.bridgeLogs); + const allLogEvents = arrayRecords(bridgeLogs.events); + const aggregateView = selection.repoKey === null && selection.deliveryId === null; + + let selectedRepoKey = selection.repoKey; + let effectiveDeliveryId = selection.deliveryId; + let selectedInbox = selection.deliveryId === null + ? undefined + : inboxDeliveries.find((item) => stringValue(item.deliveryId, "") === selection.deliveryId + && (selection.repoKey === null || stringValue(item.repo, "") === selection.repoKey)); + + if (selectedRepoKey === null && selectedInbox !== undefined) selectedRepoKey = stringValue(selectedInbox.repo, "") || null; + if (selectedRepoKey === null && selection.deliveryId !== null) { + const event = allLogEvents.find((item) => stringValue(item.deliveryId, "") === selection.deliveryId); + selectedRepoKey = event === undefined ? null : stringValue(event.repo, "") || null; + } + + let selectedRepository = selectedRepoKey === null + ? undefined + : allRepositories.find((item) => stringValue(item.key, "") === selectedRepoKey); + if (selectedRepository === undefined && selection.deliveryId !== null) { + selectedRepository = allRepositories.find((item) => stringValue(item.deliveryId, "") === selection.deliveryId); + if (selectedRepoKey === null && selectedRepository !== undefined) selectedRepoKey = stringValue(selectedRepository.key, "") || null; + } + if (effectiveDeliveryId === null && selectedRepository !== undefined) effectiveDeliveryId = stringValue(selectedRepository.deliveryId, "") || null; + if (selectedInbox === undefined && effectiveDeliveryId !== null) { + selectedInbox = inboxDeliveries.find((item) => stringValue(item.deliveryId, "") === effectiveDeliveryId + && (selectedRepoKey === null || stringValue(item.repo, "") === selectedRepoKey)); + } + if (selectedInbox === undefined && selectedRepository !== undefined) { + const repositoryInbox = record(selectedRepository.durableInboxRecord); + if (stringValue(repositoryInbox.deliveryId, "") === (effectiveDeliveryId ?? "")) selectedInbox = repositoryInbox; + } + + const repositories = selectedRepoKey === null + ? allRepositories + : allRepositories.filter((item) => stringValue(item.key, "") === selectedRepoKey); + const matchingLogEvents = allLogEvents.filter((item) => { + if (selectedRepoKey !== null && stringValue(item.repo, "") !== selectedRepoKey) return false; + if (effectiveDeliveryId !== null && stringValue(item.deliveryId, "") !== effectiveDeliveryId) return false; + return true; + }); + const selectedLogEvents = matchingLogEvents.slice(aggregateView ? -3 : -4).map(webhookLogEventSummary); + const deliveryFound = effectiveDeliveryId !== null && ( + selectedInbox !== undefined + || (selectedRepository !== undefined && stringValue(selectedRepository.deliveryId, "") === effectiveDeliveryId) + || matchingLogEvents.length > 0 + ); + const selectionError = selection.repoKey !== null && selectedRepository === undefined + ? "repository-not-observed" + : selection.deliveryId !== null && !deliveryFound + ? "delivery-not-observed-in-retained-evidence" + : null; + const selectedDelivery = selectedInbox === undefined ? null : webhookDeliverySummary(selectedInbox); + const currentDeliveries = repositories + .map((repo) => record(repo.durableInboxRecord)) + .filter((item) => stringValue(item.deliveryId, "") !== "") + .map(webhookDeliveryAggregateSummary); + const firstError = firstWebhookStatusError({ + repository: selectedRepository ?? allRepositories.find((repo) => compactErrorValue(repo.error) !== null), + delivery: selectedInbox, + logEvents: matchingLogEvents, + durableInbox, + bridgeLogs, + mirrorStatus: record(result.mirrorStatus), + }); + const selectorArgs = [ + selectedRepoKey === null ? "" : ` --repo ${selectedRepoKey}`, + effectiveDeliveryId === null ? "" : ` --delivery-id ${effectiveDeliveryId}`, + ].join(""); + const commandBase = `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId}${selectorArgs}`; + const repoCommands = allRepositories.slice(0, 8).map((repo) => { + const key = stringValue(repo.key, ""); + return { + key, + deliveryId: stringValue(repo.deliveryId, "") || null, + command: `bun scripts/cli.ts platform-infra gitea mirror webhook status --target ${targetId} --repo ${key}`, + }; + }); + + return { + ok: result.ok === true && selectionError === null, + action: "platform-infra-gitea-mirror-webhook-status", + mutation: false, + view: selection.deliveryId !== null ? "delivery-drill-down" : selection.repoKey !== null ? "repository-drill-down" : "domain-aggregate", + target: { + id: target.id, + route: target.route, + namespace: target.namespace, + role: target.role, + }, + selection: { + requestedRepoKey: selection.repoKey, + selectedRepoKey, + requestedDeliveryId: selection.deliveryId, + effectiveDeliveryId, + repositoryFound: selection.repoKey === null || selectedRepository !== undefined, + deliveryFound: selection.deliveryId === null || deliveryFound, + error: selectionError, + }, + webhook: { + enabled: webhook.enabled === true, + direction: webhook.direction, + publicUrl: webhook.publicUrl, + events: webhook.events, + responseBudgetMs: webhook.responseBudgetMs, + ingressAttemptBudgetMs: webhook.ingressAttemptBudgetMs, + ingressResponseHeaderTimeoutMs: webhook.ingressResponseHeaderTimeoutMs, + retry: { + enabled: webhookRetry.enabled === true, + maxAttempts: webhookRetry.maxAttempts, + initialDelayMs: webhookRetry.initialDelayMs, + maxDelayMs: webhookRetry.maxDelayMs, + }, + bridge: { + deploymentName: webhookBridge.deploymentName, + serviceName: webhookBridge.serviceName, + }, + }, + bridge: { + deployment: bridge.deployment, + ready: bridge.ready === true, + readyReplicas: bridge.readyReplicas, + serviceExists: bridge.serviceExists === true, + durableInbox: { + ready: durableInbox.ready === true, + storageReady: durableInbox.storageReady === true, + capacityAvailable: durableInbox.capacityAvailable === true, + counts: durableInbox.counts, + retainedDeliveryCount: inboxDeliveries.length, + totalBytes: durableInbox.totalBytes, + maxBytes: durableInbox.maxBytes, + errorType: durableInbox.errorType ?? null, + journal: durableInbox.journal, + }, + }, + repositories: repositories.map(webhookRepositorySummary), + deliveries: selection.repoKey === null && selection.deliveryId === null ? currentDeliveries : selectedDelivery === null ? [] : [selectedDelivery], + retry: selectedInbox === undefined ? null : { + state: selectedInbox.state, + totalAttempts: selectedInbox.totalAttempts, + cycleAttempt: selectedInbox.cycleAttempt, + nextAttemptAt: selectedInbox.nextAttemptAt ?? null, + lastError: compactErrorValue(selectedInbox.lastError), + }, + logs: { + exitCode: bridgeLogs.exitCode, + matched: matchingLogEvents.length, + returned: selectedLogEvents.length, + omitted: Math.max(0, matchingLogEvents.length - selectedLogEvents.length), + events: selectedLogEvents, + errorTail: compactErrorValue(bridgeLogs.errorTail), + }, + diagnostics: { + mirrorStatus: { + exitCode: record(result.mirrorStatus).exitCode, + errorTail: compactErrorValue(record(result.mirrorStatus).errorTail), + }, + firstError, + }, + disclosure: { + rawOmitted: true, + retainedInboxRecords: inboxDeliveries.length, + rawRequiresExplicitFlag: true, + valuesPrinted: false, + }, + next: { + repositories: repoCommands, + full: `${commandBase} --full`, + raw: `${commandBase} --raw`, + }, + }; +} + +function webhookRepositorySummary(repo: Record): Record { + return { + key: repo.key, + repository: repo.repository, + branch: repo.branch, + hookReady: repo.hookReady === true, + githubHead: repo.githubHead, + branchCommit: repo.branchCommit, + snapshotCommit: repo.snapshotCommit, + authorityMatches: repo.authorityMatches === true, + stale: repo.bridgeEventStale === true, + staleReason: repo.staleReason ?? null, + deliveryId: repo.deliveryId, + deliveryAccepted: repo.deliveryAccepted === true, + durableState: repo.durableInboxState, + durableCommitted: repo.durableInboxCommitted === true, + error: compactErrorValue(repo.error), + }; +} + +function webhookDeliverySummary(delivery: Record): Record { + const result = record(delivery.result); + return { + deliveryId: delivery.deliveryId, + repo: delivery.repo, + repository: delivery.repository, + ref: delivery.ref, + requestedCommit: delivery.requestedCommit, + state: delivery.state, + acceptedSequence: delivery.acceptedSequence, + totalAttempts: delivery.totalAttempts, + cycleAttempt: delivery.cycleAttempt, + nextAttemptAt: delivery.nextAttemptAt ?? null, + updatedAt: delivery.updatedAt, + result: { + sourceCommit: result.sourceCommit, + authorityCommit: result.authorityCommit, + snapshotRef: result.snapshotRef, + disposition: result.disposition, + }, + lastError: compactErrorValue(delivery.lastError), + }; +} + +function webhookDeliveryAggregateSummary(delivery: Record): Record { + const result = record(delivery.result); + return { + deliveryId: delivery.deliveryId, + repo: delivery.repo, + state: delivery.state, + totalAttempts: delivery.totalAttempts, + updatedAt: delivery.updatedAt, + requestedCommit: delivery.requestedCommit, + sourceCommit: result.sourceCommit, + disposition: result.disposition, + lastError: compactErrorValue(delivery.lastError), + }; +} + +function webhookLogEventSummary(event: Record): Record { + return { + event: event.event, + repo: event.repo, + deliveryId: event.deliveryId, + ok: event.ok === true, + terminal: event.terminal === true, + syncAttempt: event.syncAttempt, + disposition: event.disposition, + sourceCommit: event.sourceCommit, + authorityCommit: event.authorityCommit, + elapsedMs: event.elapsedMs, + errorType: event.errorType ?? null, + error: compactErrorValue(event.error), + }; +} + +function firstWebhookStatusError(input: { + repository?: Record; + delivery?: Record; + logEvents: Record[]; + durableInbox: Record; + bridgeLogs: Record; + mirrorStatus: Record; +}): Record | null { + const candidates: Array<[string, unknown]> = [ + ["repository", input.repository?.error], + ["durable-inbox-record", input.delivery?.lastError], + ["bridge-event", input.logEvents.find((event) => compactErrorValue(event.error) !== null)?.error], + ["durable-inbox", input.durableInbox.errorType], + ["bridge-logs", Number(input.bridgeLogs.exitCode) === 0 ? null : input.bridgeLogs.errorTail], + ["mirror-status", Number(input.mirrorStatus.exitCode) === 0 ? null : input.mirrorStatus.errorTail], + ]; + for (const [domain, value] of candidates) { + const message = compactErrorValue(value); + if (message !== null) return { domain, message }; + } + return null; +} + +function compactErrorValue(value: unknown): string | null { + if (value === null || value === undefined || value === "") return null; + const text = typeof value === "string" ? value : JSON.stringify(value); + const compact = text.replace(/\s+/gu, " ").trim(); + if (compact === "" || compact === "null" || compact === "{}") return null; + return compact.length > 240 ? `${compact.slice(0, 237)}...` : compact; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) + ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record[] + : []; +} + +function stringValue(value: unknown, fallback = "-"): string { + if (typeof value === "string" && value.length > 0) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return fallback; +} diff --git a/scripts/src/platform-infra-gitea-render.ts b/scripts/src/platform-infra-gitea-render.ts new file mode 100644 index 00000000..8caefcce --- /dev/null +++ b/scripts/src/platform-infra-gitea-render.ts @@ -0,0 +1,343 @@ +import type { RenderedCliResult } from "./output"; + +export function renderMirrorPlan(result: Record): RenderedCliResult { + const target = record(result.target); + const source = record(result.sourceAuthority); + const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.upstreamRepository), stringValue(repo.upstreamBranch), stringValue(repo.readUrl), stringValue(repo.legacyDisposition)]); + const responsibilities = arrayRecords(result.responsibilities).map((item) => [stringValue(item.name), stringValue(item.current), stringValue(item.target), stringValue(item.disposition)]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror plan", [ + "PLATFORM-INFRA GITEA MIRROR PLAN", + ...table(["TARGET", "STAGE", "AUTHORITY", "REPOS"], [[stringValue(target.id), stringValue(source.stage), stringValue(source.statusAuthority), stringValue(source.repositoryCount)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "UPSTREAM", "BRANCH", "GITEA_READ_URL", "LEGACY"], repos)), + "", + "RESPONSIBILITIES", + ...(responsibilities.length === 0 ? ["-"] : table(["NAME", "CURRENT", "TARGET", "DISPOSITION"], responsibilities)), + "", + "NEXT", + ` status: ${stringValue(next.status)}`, + ` full: ${stringValue(next.statusFull)}`, + ` webhook-status: ${stringValue(next.webhookStatus)}`, + ` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`, + "", + "Disclosure: credentials are summarized by presence/fingerprint only.", + ]); +} + +export function renderMirrorBootstrap(result: Record): RenderedCliResult { + const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.owner), stringValue(repo.name), boolText(record(repo.create).ok)]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror bootstrap", [ + "PLATFORM-INFRA GITEA MIRROR BOOTSTRAP", + ...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "API_OK"], repos)), + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(next.webhookStatus)}`, + ]); +} + +export function renderMirrorSync(result: Record): RenderedCliResult { + const bundles = arrayRecords(result.sourceBundles).map((bundle) => [stringValue(bundle.key), stringValue(bundle.sourceCommit).slice(0, 12), stringValue(bundle.snapshotRef), stringValue(bundle.readUrl)]); + const repos = arrayRecords(result.repositories).map((repo) => [ + stringValue(repo.key), + boolText(repo.syncOk), + stringValue(repo.fetchRc), + stringValue(repo.pushRc), + stringValue(repo.gitopsPushRc), + stringValue(repo.sourceCommit).slice(0, 12), + compactTail(stringValue(repo.fetchTail) || stringValue(repo.pushTail) || stringValue(repo.revparseTail)), + ]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror sync", [ + "PLATFORM-INFRA GITEA MIRROR SYNC", + ...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "SYNC", "FETCH", "PUSH", "GITOPS", "COMMIT", "ERROR"], repos)), + "", + "SOURCE BUNDLES", + ...(bundles.length === 0 ? ["-"] : table(["KEY", "COMMIT", "SNAPSHOT_REF", "READ_URL"], bundles)), + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(next.status)}`, + ]); +} + +export function renderMirrorStatus(result: Record): RenderedCliResult { + const source = record(result.sourceAuthority); + const service = record(result.serviceHealth); + const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.mirrorState), stringValue(repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), stringValue(repo.snapshotRef)]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror status", [ + "PLATFORM-INFRA GITEA MIRROR STATUS", + ...table(["TARGET", "SERVICE", "MIRROR_READY", "AUTHORITY"], [[stringValue(record(result.target).id), boolText(service.ok), boolText(source.mirrorReady), stringValue(source.statusAuthority)]]), + "", + "REPOSITORIES", + ...(repos.length === 0 ? ["-"] : table(["KEY", "STATE", "BRANCH", "SNAPSHOT", "SNAPSHOT_REF"], repos)), + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(next.webhookStatus)}`, + ]); +} + +export function renderMirrorWebhookApply(result: Record): RenderedCliResult { + const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.repository), boolText(repo.hookReady), stringValue(repo.hookId), compactTail(stringValue(repo.error))]); + const next = record(result.next); + return rendered(result, "platform-infra gitea mirror webhook apply", [ + "PLATFORM-INFRA GITEA MIRROR WEBHOOK APPLY", + ...table(["OK", "MUTATION", "TARGET", "URL"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id), stringValue(record(result.webhook).publicUrl)]]), + "", + "GITHUB HOOKS", + ...(repos.length === 0 ? ["-"] : table(["KEY", "REPOSITORY", "READY", "HOOK_ID", "ERROR"], repos)), + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(next.webhookStatus)}`, + ]); +} + +export function renderMirrorWebhookStatus(result: Record, disclosure: Record): RenderedCliResult { + const repos = arrayRecords(result.repositories).map((repo) => { + const latestPush = record(repo.latestPushDelivery); + const latest = record(repo.latestDelivery); + const delivery = latestPush.event !== "" ? latestPush : latest; + const inbox = record(repo.durableInboxRecord); + const inboxResult = record(inbox.result); + const inboxState = stringValue(repo.durableInboxState, "missing"); + const durableProof = repo.preDurableBootstrap === true + ? "pre-durable-bootstrap" + : repo.durableInboxCommitted === true + ? `${stringValue(inboxResult.disposition)}:${stringValue(inboxResult.sourceCommit).slice(0, 12)}` + : "-"; + return [ + stringValue(repo.key), + boolText(repo.hookReady), + stringValue(repo.githubHead).slice(0, 12), + stringValue(repo.branchCommit).slice(0, 12), + stringValue(repo.snapshotCommit).slice(0, 12), + boolText(repo.authorityMatches), + boolText(repo.bridgeEventStale), + `${stringValue(repo.deliveryId)}:${stringValue(delivery.statusCode)}:${stringValue(delivery.status)}`, + inboxState, + durableProof, + compactTail(stringValue(repo.error)), + ]; + }); + const bridge = record(result.bridge); + const bridgeLogs = record(result.bridgeLogs); + const retry = record(record(record(result.webhook).bridge).retry); + const retryText = `${stringValue(retry.maxAttempts)}x/${stringValue(retry.initialDelayMs)}-${stringValue(retry.maxDelayMs)}ms`; + const selection = record(disclosure.selection); + const selectedRepoKey = stringValue(selection.selectedRepoKey, ""); + const delivery = arrayRecords(disclosure.deliveries)[0]; + const deliveryResult = record(delivery?.result); + const retryDetail = record(disclosure.retry); + const logs = record(disclosure.logs); + const logRows = arrayRecords(logs.events).map((event) => [ + stringValue(event.syncAttempt), + boolText(event.ok), + boolText(event.terminal), + stringValue(event.disposition), + stringValue(event.elapsedMs), + compactLine(stringValue(event.error)), + ]); + const firstError = record(record(disclosure.diagnostics).firstError); + const repositoryCommands = arrayRecords(record(disclosure.next).repositories).map((item) => [ + stringValue(item.key), + stringValue(item.deliveryId), + stringValue(item.command), + ]); + const drillDown = selectedRepoKey === "" + ? [ + "", + "DRILL-DOWN", + ...(repositoryCommands.length === 0 ? ["-"] : table(["REPOSITORY", "DELIVERY", "COMMAND"], repositoryCommands)), + ] + : [ + "", + "DELIVERY", + ...(delivery === undefined ? ["-"] : table(["ID", "REPOSITORY", "STATE", "ATTEMPTS", "NEXT", "COMMIT", "DISPOSITION"], [[ + stringValue(delivery.deliveryId), + stringValue(delivery.repo), + stringValue(delivery.state), + `${stringValue(retryDetail.cycleAttempt)}/${stringValue(retryDetail.totalAttempts)}`, + stringValue(retryDetail.nextAttemptAt), + stringValue(deliveryResult.sourceCommit).slice(0, 12), + stringValue(deliveryResult.disposition), + ]])), + "", + "BRIDGE EVENTS", + ...(logRows.length === 0 ? ["-"] : table(["ATTEMPT", "OK", "TERMINAL", "DISPOSITION", "ELAPSED_MS", "ERROR"], logRows)), + ...(firstError.message === undefined ? [] : ["", `FIRST ERROR ${stringValue(firstError.domain)}: ${stringValue(firstError.message)}`]), + ]; + return rendered(disclosure, "platform-infra gitea mirror webhook status", [ + "PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS", + ...table(["TARGET", "BRIDGE", "READY", "RETRY", "URL", "LOGS"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), retryText, stringValue(record(result.webhook).publicUrl), `rc=${stringValue(bridgeLogs.exitCode)}`]]), + "", + "GITHUB HOOKS", + ...(repos.length === 0 ? ["-"] : table(["KEY", "HOOK", "GH_HEAD", "GITEA", "SNAPSHOT", "MATCH", "STALE", "DELIVERY", "DURABLE", "PROOF", "ERROR"], repos)), + ...drillDown, + ...remoteErrorLines(result), + "", + `NEXT ${stringValue(record(disclosure.next).full)}`, + ]); +} + +export function renderPlan(result: Record): RenderedCliResult { + const config = record(result.config); + const target = record(config.target); + const app = record(config.app); + const migration = record(config.migration); + const policy = arrayRecords(result.policy); + const failed = policy.filter((item) => item.ok === false); + const next = record(result.next); + return rendered(result, "platform-infra gitea plan", [ + "PLATFORM-INFRA GITEA PLAN", + ...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [ + ["TARGET", stringValue(target.id), "route", stringValue(target.route)], + ["NAMESPACE", stringValue(target.namespace), "role", stringValue(target.role)], + ["IMAGE", stringValue(app.image), "replicas", stringValue(app.replicas)], + ["SERVICE", `${stringValue(app.serviceName)}:${stringValue(record(app.service).httpPort)}`, "dns", serviceDnsFromObjects(app, target)], + ["WEBUI", stringValue(record(app.publicExposure).publicBaseUrl), "internalGit", serviceDnsFromObjects(app, target)], + ["ACTIONS", boolText(app.actionsEnabled), "registrationDisabled", boolText(app.registrationDisabled)], + ["MIGRATION", stringValue(migration.role), "replaces", stringValue(migration.replaces)], + ["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"], + ]), + "", + "NEXT", + ` status: ${stringValue(next.status)}`, + ` validate: ${stringValue(next.validate)}`, + ` mirror-status: ${stringValue(next.mirrorStatus)}`, + ` webhook-status: ${stringValue(next.webhookStatus)}`, + ` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`, + "", + "Boundary: Gitea is internal ClusterIP source authority for GH-1560; CI trigger authority is Pipelines-as-Code, not Gitea Actions/act_runner.", + "Disclosure: Secret values are not printed; this stage does not create runner credentials.", + ]); +} + +export function renderApply(result: Record): RenderedCliResult { + const target = record(result.target); + const payload = record(result.payloadTransport); + const remote = record(result.remote); + const steps = record(remote.steps); + const serverDryRunStep = record(steps.serverDryRun); + const frpcStep = record(steps.frpcSecret); + const frpcCleanupStep = record(steps.frpcCleanup); + const webhookSecretStep = record(steps.webhookSyncSecret); + const applyStep = record(steps.apply); + const rolloutStep = record(steps.rollout); + const frpcRolloutStep = record(steps.frpcRollout); + const webhookRolloutStep = record(steps.webhookSyncRollout); + const caddy = record(result.pk01Caddy); + return rendered(result, "platform-infra gitea apply", [ + "PLATFORM-INFRA GITEA APPLY", + ...table(["TARGET", "NAMESPACE", "MODE", "OK"], [[stringValue(target.id), stringValue(target.namespace), stringValue(result.mode), boolText(result.ok)]]), + ...table(["TRANSPORT", "BYTES", "MATERIALIZATION", "VALUES"], [[stringValue(payload.kind), stringValue(payload.totalBytes), stringValue(payload.materialization), stringValue(payload.valuesPrinted)]]), + "", + "STEPS", + ...table(["STEP", "EXIT", "DETAIL"], [ + ["serverDryRun", stringValue(serverDryRunStep.exitCode), compactLine(stringValue(serverDryRunStep.stderrTail, stringValue(serverDryRunStep.stdoutTail)))], + ["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))], + ["frpc-cleanup", stringValue(frpcCleanupStep.exitCode), compactLine(stringValue(frpcCleanupStep.stderrTail, stringValue(frpcCleanupStep.stdoutTail)))], + ["webhook-secret", stringValue(webhookSecretStep.exitCode), compactLine(stringValue(webhookSecretStep.stderrTail, stringValue(webhookSecretStep.stdoutTail)))], + ["apply", stringValue(applyStep.exitCode), compactLine(stringValue(applyStep.stderrTail, stringValue(applyStep.stdoutTail)))], + ["rollout", stringValue(rolloutStep.exitCode), compactLine(stringValue(rolloutStep.stderrTail, stringValue(rolloutStep.stdoutTail)))], + ["frpc-rollout", stringValue(frpcRolloutStep.exitCode), compactLine(stringValue(frpcRolloutStep.stderrTail, stringValue(frpcRolloutStep.stdoutTail)))], + ["webhook-rollout", stringValue(webhookRolloutStep.exitCode), compactLine(stringValue(webhookRolloutStep.stderrTail, stringValue(webhookRolloutStep.stdoutTail)))], + ["pk01-caddy", stringValue(caddy.exitCode ?? (caddy.ok === false ? 1 : 0)), compactLine(stringValue(caddy.error, stringValue(caddy.status, stringValue(caddy.reason))))], + ]), + ...remoteErrorLines(result), + "", + `NEXT bun scripts/cli.ts platform-infra gitea status --target ${stringValue(target.id)}`, + ]); +} + +export function renderStatus(result: Record): RenderedCliResult { + const summary = record(result.summary); + const statefulSet = record(summary.statefulSet); + const service = record(summary.service); + const networkPolicy = record(summary.networkPolicy); + const pods = arrayRecords(summary.pods).map((pod) => [stringValue(pod.name), stringValue(pod.phase), boolText(pod.ready), stringValue(pod.restarts)]); + const pvcs = arrayRecords(summary.pvcs).map((pvc) => [stringValue(pvc.name), stringValue(pvc.phase), stringValue(pvc.capacity)]); + return rendered(result, "platform-infra gitea status", [ + "PLATFORM-INFRA GITEA STATUS", + ...table(["TARGET", "ROUTE", "NAMESPACE", "READY"], [[stringValue(summary.target), stringValue(summary.route), stringValue(summary.namespace), boolText(summary.ready)]]), + "", + "CONTROL", + ...table(["CHECK", "VALUE", "DETAIL"], [ + ["statefulSet", `${stringValue(statefulSet.readyReplicas)}/${stringValue(statefulSet.desired)}`, stringValue(statefulSet.name)], + ["service", stringValue(service.type), stringValue(summary.serviceDns)], + ["endpoints", boolText(summary.endpointsReady), stringValue(service.clusterIP)], + ["allow-all", boolText(networkPolicy.allowAllPresent), "NetworkPolicy"], + ]), + "", + "PODS", + ...(pods.length === 0 ? ["-"] : table(["POD", "PHASE", "READY", "RESTARTS"], pods)), + "", + "PVCS", + ...(pvcs.length === 0 ? ["-"] : table(["PVC", "PHASE", "CAPACITY"], pvcs)), + ...remoteErrorLines(result), + "", + `NEXT bun scripts/cli.ts platform-infra gitea validate --target ${stringValue(summary.target)}`, + ]); +} + +function remoteErrorLines(result: Record): string[] { + if (result.ok !== false) return []; + const remote = record(result.remote); + const exitCode = stringValue(remote.exitCode); + const stderr = compactLine(stringValue(remote.stderrTail)); + const stdout = compactLine(stringValue(remote.stdoutTail)); + const detail = stderr !== "-" ? stderr : stdout; + return ["", "ERROR", ...table(["EXIT", "DETAIL"], [[exitCode, detail]])]; +} + +function compactTail(value: string): string { + const line = value.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean).at(-1) ?? ""; + return line.length > 96 ? `${line.slice(0, 93)}...` : line; +} + +function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { + return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; +} + +function serviceDnsFromObjects(app: Record, target: Record): string { + const service = record(app.service); + return `${stringValue(app.serviceName)}.${stringValue(target.namespace)}.svc.cluster.local:${stringValue(service.httpPort)}`; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) + ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record[] + : []; +} + +function stringValue(value: unknown, fallback = "-"): string { + if (typeof value === "string" && value.length > 0) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return fallback; +} + +function boolText(value: unknown): string { + return value === true ? "true" : "false"; +} + +function compactLine(value: string): string { + const trimmed = value.replace(/\s+/gu, " ").trim(); + return trimmed.length > 0 ? trimmed.slice(0, 220) : "-"; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); + const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" "); + return [format(headers), format(headers.map((header, index) => "-".repeat(Math.max(header.length, widths[index])))), ...rows.map(format)]; +} diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 25a60f29..35639ea9 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -3,305 +3,59 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n import { dirname, join } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; -import type { RenderedCliResult } from "./output"; +import { CliInputError, type RenderedCliResult } from "./output"; import { capture, compactCapture, - createYamlFieldReader, fingerprintValues, parseJsonOutput, - readYamlRecord, shQuote, } from "./platform-infra-ops-library"; import { applyPk01CaddyBlock, escapeTomlString, prepareFrpcSecret, renderFrpcManifest } from "./platform-infra-public-service"; -import type { PublicServiceExposure, PublicServiceTarget, FrpcSecretMaterial } from "./platform-infra-public-service"; +import type { PublicServiceTarget, FrpcSecretMaterial } from "./platform-infra-public-service"; import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy"; -import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle, validateGiteaWebhookTiming } from "./platform-infra-gitea-caddy"; -import type { GiteaWebhookIngressRetry } from "./platform-infra-gitea-caddy"; +import { deriveGiteaWebhookAttemptBudgetMs, deriveGiteaWebhookResponseHeaderTimeoutMs, renderGiteaWebhookCaddyHandle } from "./platform-infra-gitea-caddy"; import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } from "./platform-infra-gitea-payload"; import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; import { PAC_AUTOMATIC_DELIVERY_REFERENCE } from "./cicd-delivery-authority"; import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments"; +import { + buildMirrorWebhookStatusDisclosure, + rawMirrorWebhookStatus, +} from "./platform-infra-gitea-disclosure"; +import { + renderApply, + renderMirrorBootstrap, + renderMirrorPlan, + renderMirrorStatus, + renderMirrorSync, + renderMirrorWebhookApply, + renderMirrorWebhookStatus, + renderPlan, + renderStatus, +} from "./platform-infra-gitea-render"; +import { + GITEA_CONFIG_LABEL, + readGiteaConfig, + resolveTarget, + targetWebhookSync, + webhookCaddyRoutes, + type GiteaAdminCredential, + type GiteaConfig, + type GiteaGithubCredential, + type GiteaMirrorRepository, + type GiteaSourceResponsibility, + type GiteaTarget, + type GiteaWebhookGitOpsDelivery, + type GiteaWebhookSync, +} from "./platform-infra-gitea-config"; -const configFile = rootPath("config", "platform-infra", "gitea.yaml"); -const configLabel = "config/platform-infra/gitea.yaml"; +const configLabel = GITEA_CONFIG_LABEL; const remoteScriptFile = rootPath("scripts", "src", "platform-infra-gitea-remote.sh"); const githubSyncServerFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-server.mjs"); const githubSyncEntrypointFile = rootPath("scripts", "src", "platform-infra-gitea-github-sync-entrypoint.mjs"); const statusEvaluatorFile = rootPath("scripts", "src", "platform_infra_gitea_status_evaluator.py"); const fieldManager = "unidesk-platform-infra-gitea"; -const y = createYamlFieldReader(configLabel); - -interface GiteaTarget { - id: string; - route: string; - namespace: string; - role: string; - enabled: boolean; - createNamespace: boolean; - storageClassName: string; - publicExposureEnabled: boolean; - webhookSync: GiteaTargetWebhookSync | null; -} - -interface GiteaTargetWebhookSync { - publicPath: string; - frpc: { - proxyName: string; - remotePort: number; - }; -} - -interface GiteaConfig { - version: number; - kind: "platform-infra-gitea"; - metadata: { - id: string; - owner: string; - spec: string; - relatedIssues: number[]; - }; - defaults: { - targetId: string; - }; - migration: { - role: string; - replaces: string; - parentConfigRef: string; - envReusePolicy: string; - buildPlane: string; - runtimePlane: string; - }; - sourceAuthority: { - enabled: boolean; - stage: string; - statusAuthority: string; - firstCiConsumer: string; - credentials: { - sourceRoot: string; - admin: GiteaAdminCredential; - github: GiteaGithubCredential; - }; - githubProxy: { - enabled: boolean; - url: string; - noProxy: string[]; - }; - webhookSync: GiteaWebhookSync; - responsibilities: GiteaSourceResponsibility[]; - repositories: GiteaMirrorRepository[]; - }; - targets: GiteaTarget[]; - app: { - name: string; - statefulSetName: string; - serviceName: string; - replicas: number; - image: { - repository: string; - tag: string; - pullPolicy: "Always" | "IfNotPresent" | "Never"; - }; - service: { - type: "ClusterIP"; - httpPort: number; - sshPort: number; - }; - server: { - domain: string; - rootUrl: string; - sshDomain: string; - protocol: "http" | "https"; - startSshServer: boolean; - }; - publicExposure: PublicServiceExposure & { secretRoot: string }; - database: { - type: "sqlite3"; - path: string; - }; - actions: { - enabled: boolean; - }; - webhook: { - allowedHostList: string; - }; - registration: { - disabled: boolean; - }; - storage: { - data: { size: string; mountPath: string }; - config: { size: string; mountPath: string }; - }; - securityContext: { - runAsUser: number; - runAsGroup: number; - fsGroup: number; - }; - resources: { - requests: { cpu: string; memory: string }; - limits: { cpu: string; memory: string }; - }; - probes: { - healthPath: string; - initialDelaySeconds: number; - periodSeconds: number; - timeoutSeconds: number; - failureThreshold: number; - }; - }; - validation: { - waitTimeoutSeconds: number; - healthPath: string; - }; -} - -interface GiteaAdminCredential { - sourceRef: string; - format: "line-pair"; - usernameLine: number; - passwordLine: number; - requiredFor: string[]; -} - -interface GiteaGithubCredential { - transport: "https-token"; - sourceRef: string; - sourceKey: string; - format: "env" | "raw-token"; - requiredFor: string[]; - gitFetchCredential: { - apiVersion: "unidesk.ai/v1"; - kind: "GitFetchCredential"; - authMode: "github-https-token"; - host: "github.com"; - secretRef: { - namespace: string; - name: string; - key: string; - }; - }; -} - -interface GiteaWebhookSync { - enabled: boolean; - direction: "github-to-gitea"; - responseBudgetMs: number; - publicPath: string; - events: string[]; - ingressRetry: GiteaWebhookIngressRetry; - secret: { - sourceRef: string; - sourceKey: string; - createIfMissing: boolean; - }; - bridge: { - deploymentName: string; - serviceName: string; - secretName: string; - configMapName: string; - image: string; - replicas: number; - httpPort: number; - serviceAccountName: string; - shutdownGraceMs: number; - candidateGate: { - enabled: boolean; - configMapName: string; - jobName: string; - activeDeadlineSeconds: number; - ttlSecondsAfterFinished: number; - healthTimeoutMs: number; - pollIntervalMs: number; - }; - inbox: { - claimName: string; - path: string; - storageSize: string; - maxBytes: number; - committedRetentionSeconds: number; - cleanupIntervalMs: number; - }; - retry: { - maxAttempts: number; - initialDelayMs: number; - maxDelayMs: number; - terminalRetryDelayMs: number; - attemptTimeoutMs: number; - scanIntervalMs: number; - }; - }; - gitOpsDelivery: GiteaWebhookGitOpsDelivery; - frpc: { - proxyName: string; - remotePort: number; - }; -} - -export interface GiteaWebhookGitOpsDelivery { - enabled: boolean; - targetId: string; - readUrl: string; - writeUrl: string; - branch: string; - sourceSnapshotPrefix: string; - desiredManifestPath: string; - bootstrapApplicationPath: string; - releaseStatePath: string; - application: { - name: string; - namespace: string; - project: string; - repoUrl: string; - targetRevision: string; - path: string; - destinationNamespace: string; - automated: boolean; - }; - author: { - name: string; - email: string; - }; - cas: { - maxAttempts: number; - }; -} - -interface GiteaSourceResponsibility { - name: string; - current: string; - target: string; - disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; -} - -interface GiteaMirrorRepository { - key: string; - targetId: string; - upstream: { - repository: string; - cloneUrl: string; - branch: string; - }; - gitea: { - owner: string; - name: string; - mirrorMode: "controlled-push"; - publicRead: boolean; - readUrl: string; - }; - gitops: { - branch: string; - flushDisposition: string; - }; - snapshot: { - prefix: string; - }; - legacyGitMirror: { - readUrl: string; - configRef: string; - disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; - }; -} - interface CommonOptions { targetId: string | null; full: boolean; @@ -319,6 +73,10 @@ interface MirrorOptions extends CommonOptions { repoKey: string | null; } +interface MirrorWebhookStatusOptions extends MirrorOptions { + deliveryId: string | null; +} + interface MirrorSecrets { adminUsername: string; adminPassword: string; @@ -381,494 +139,13 @@ export function giteaHelp(scope?: string): Record { "bun scripts/cli.ts platform-infra gitea status --target [--full|--raw]", "bun scripts/cli.ts platform-infra gitea validate --target [--full|--raw]", "bun scripts/cli.ts platform-infra gitea mirror status --target [--full|--raw]", - "bun scripts/cli.ts platform-infra gitea mirror webhook status --target [--full|--raw]", + "bun scripts/cli.ts platform-infra gitea mirror webhook status --target [--repo ] [--delivery-id ] [--full|--raw]", ], scopedHelp: ["bun scripts/cli.ts platform-infra gitea help platform-bootstrap"], boundary: "PR merge 是唯一 delivery 触发;默认入口只读观察和校验,不能充当合并后的恢复动作。", }; } -function readGiteaConfig(): GiteaConfig { - const root = readYamlRecord>(configFile, "platform-infra-gitea"); - const version = y.integerField(root, "version", ""); - if (version !== 1) throw new Error(`${configLabel}.version must be 1`); - const metadata = y.objectField(root, "metadata", ""); - const defaults = y.objectField(root, "defaults", ""); - const migration = y.objectField(root, "migration", ""); - const sourceAuthority = y.objectField(root, "sourceAuthority", ""); - const sourceCredentials = y.objectField(sourceAuthority, "credentials", "sourceAuthority"); - const githubProxy = y.objectField(sourceAuthority, "githubProxy", "sourceAuthority"); - const webhookSync = y.objectField(sourceAuthority, "webhookSync", "sourceAuthority"); - const app = y.objectField(root, "app", ""); - const image = y.objectField(app, "image", "app"); - const service = y.objectField(app, "service", "app"); - const server = y.objectField(app, "server", "app"); - const database = y.objectField(app, "database", "app"); - const actions = y.objectField(app, "actions", "app"); - const webhook = y.objectField(app, "webhook", "app"); - const registration = y.objectField(app, "registration", "app"); - const storage = y.objectField(app, "storage", "app"); - const dataStorage = y.objectField(storage, "data", "app.storage"); - const configStorage = y.objectField(storage, "config", "app.storage"); - const publicExposure = y.objectField(app, "publicExposure", "app"); - const securityContext = y.objectField(app, "securityContext", "app"); - const resources = y.objectField(app, "resources", "app"); - const requests = y.objectField(resources, "requests", "app.resources"); - const limits = y.objectField(resources, "limits", "app.resources"); - const probes = y.objectField(app, "probes", "app"); - const validation = y.objectField(root, "validation", ""); - const parsed: GiteaConfig = { - version, - kind: "platform-infra-gitea", - metadata: { - id: y.stringField(metadata, "id", "metadata"), - owner: y.stringField(metadata, "owner", "metadata"), - spec: y.stringField(metadata, "spec", "metadata"), - relatedIssues: y.numberArrayField(metadata, "relatedIssues", "metadata"), - }, - defaults: { - targetId: y.stringField(defaults, "targetId", "defaults"), - }, - migration: { - role: y.stringField(migration, "role", "migration"), - replaces: y.stringField(migration, "replaces", "migration"), - parentConfigRef: y.stringField(migration, "parentConfigRef", "migration"), - envReusePolicy: y.stringField(migration, "envReusePolicy", "migration"), - buildPlane: y.stringField(migration, "buildPlane", "migration"), - runtimePlane: y.stringField(migration, "runtimePlane", "migration"), - }, - sourceAuthority: { - enabled: y.booleanField(sourceAuthority, "enabled", "sourceAuthority"), - stage: y.stringField(sourceAuthority, "stage", "sourceAuthority"), - statusAuthority: y.stringField(sourceAuthority, "statusAuthority", "sourceAuthority"), - firstCiConsumer: y.stringField(sourceAuthority, "firstCiConsumer", "sourceAuthority"), - credentials: { - sourceRoot: y.absolutePathField(sourceCredentials, "sourceRoot", "sourceAuthority.credentials"), - admin: parseAdminCredential(y.objectField(sourceCredentials, "admin", "sourceAuthority.credentials"), "sourceAuthority.credentials.admin"), - github: parseGithubCredential(y.objectField(sourceCredentials, "github", "sourceAuthority.credentials"), "sourceAuthority.credentials.github"), - }, - githubProxy: { - enabled: y.booleanField(githubProxy, "enabled", "sourceAuthority.githubProxy"), - url: urlField(githubProxy, "url", "sourceAuthority.githubProxy"), - noProxy: y.stringArrayField(githubProxy, "noProxy", "sourceAuthority.githubProxy"), - }, - webhookSync: parseWebhookSync(webhookSync, "sourceAuthority.webhookSync"), - responsibilities: y.arrayOfRecords(sourceAuthority.responsibilities, "sourceAuthority.responsibilities").map(parseResponsibility), - repositories: y.arrayOfRecords(sourceAuthority.repositories, "sourceAuthority.repositories").map(parseMirrorRepository), - }, - targets: y.arrayOfRecords(root.targets, "targets").map(parseTarget), - app: { - name: y.kubernetesNameField(app, "name", "app"), - statefulSetName: y.kubernetesNameField(app, "statefulSetName", "app"), - serviceName: y.kubernetesNameField(app, "serviceName", "app"), - replicas: positiveInteger(app, "replicas", "app"), - image: { - repository: y.stringField(image, "repository", "app.image"), - tag: y.stringField(image, "tag", "app.image"), - pullPolicy: y.enumField(image, "pullPolicy", "app.image", ["Always", "IfNotPresent", "Never"] as const), - }, - service: { - type: y.enumField(service, "type", "app.service", ["ClusterIP"] as const), - httpPort: y.portField(service, "httpPort", "app.service"), - sshPort: y.portField(service, "sshPort", "app.service"), - }, - server: { - domain: y.hostField(server, "domain", "app.server"), - rootUrl: urlField(server, "rootUrl", "app.server"), - sshDomain: y.hostField(server, "sshDomain", "app.server"), - protocol: y.enumField(server, "protocol", "app.server", ["http", "https"] as const), - startSshServer: y.booleanField(server, "startSshServer", "app.server"), - }, - publicExposure: parsePublicExposure(publicExposure, "app.publicExposure"), - database: { - type: y.enumField(database, "type", "app.database", ["sqlite3"] as const), - path: y.absolutePathField(database, "path", "app.database"), - }, - actions: { - enabled: y.booleanField(actions, "enabled", "app.actions"), - }, - webhook: { - allowedHostList: y.stringField(webhook, "allowedHostList", "app.webhook"), - }, - registration: { - disabled: y.booleanField(registration, "disabled", "app.registration"), - }, - storage: { - data: { size: quantity(dataStorage, "size", "app.storage.data"), mountPath: y.absolutePathField(dataStorage, "mountPath", "app.storage.data") }, - config: { size: quantity(configStorage, "size", "app.storage.config"), mountPath: y.absolutePathField(configStorage, "mountPath", "app.storage.config") }, - }, - securityContext: { - runAsUser: positiveInteger(securityContext, "runAsUser", "app.securityContext"), - runAsGroup: positiveInteger(securityContext, "runAsGroup", "app.securityContext"), - fsGroup: positiveInteger(securityContext, "fsGroup", "app.securityContext"), - }, - resources: { - requests: { cpu: y.stringField(requests, "cpu", "app.resources.requests"), memory: quantity(requests, "memory", "app.resources.requests") }, - limits: { cpu: y.stringField(limits, "cpu", "app.resources.limits"), memory: quantity(limits, "memory", "app.resources.limits") }, - }, - probes: { - healthPath: y.apiPathField(probes, "healthPath", "app.probes"), - initialDelaySeconds: positiveInteger(probes, "initialDelaySeconds", "app.probes"), - periodSeconds: positiveInteger(probes, "periodSeconds", "app.probes"), - timeoutSeconds: positiveInteger(probes, "timeoutSeconds", "app.probes"), - failureThreshold: positiveInteger(probes, "failureThreshold", "app.probes"), - }, - }, - validation: { - waitTimeoutSeconds: boundedTimeout(validation, "waitTimeoutSeconds", "validation"), - healthPath: y.apiPathField(validation, "healthPath", "validation"), - }, - }; - validateConfig(parsed); - return parsed; -} - -function parseAdminCredential(record: Record, path: string): GiteaAdminCredential { - return { - sourceRef: secretSourceRefField(record, "sourceRef", path), - format: literalField(record, "format", path, ["line-pair"]), - usernameLine: positiveInteger(record, "usernameLine", path), - passwordLine: positiveInteger(record, "passwordLine", path), - requiredFor: y.stringArrayField(record, "requiredFor", path), - }; -} - -function parseGithubCredential(record: Record, path: string): GiteaGithubCredential { - const gitFetchCredential = y.objectField(record, "gitFetchCredential", path); - const secretRef = y.objectField(gitFetchCredential, "secretRef", `${path}.gitFetchCredential`); - return { - transport: y.enumField(record, "transport", path, ["https-token"] as const), - sourceRef: secretSourceRefField(record, "sourceRef", path), - sourceKey: y.envKeyField(record, "sourceKey", path), - format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env", - requiredFor: y.stringArrayField(record, "requiredFor", path), - gitFetchCredential: { - apiVersion: y.enumField(gitFetchCredential, "apiVersion", `${path}.gitFetchCredential`, ["unidesk.ai/v1"] as const), - kind: y.enumField(gitFetchCredential, "kind", `${path}.gitFetchCredential`, ["GitFetchCredential"] as const), - authMode: y.enumField(gitFetchCredential, "authMode", `${path}.gitFetchCredential`, ["github-https-token"] as const), - host: y.enumField(gitFetchCredential, "host", `${path}.gitFetchCredential`, ["github.com"] as const), - secretRef: { - namespace: y.kubernetesNameField(secretRef, "namespace", `${path}.gitFetchCredential.secretRef`), - name: y.kubernetesNameField(secretRef, "name", `${path}.gitFetchCredential.secretRef`), - key: y.stringField(secretRef, "key", `${path}.gitFetchCredential.secretRef`), - }, - }, - }; -} - -function parseWebhookSync(record: Record, path: string): GiteaWebhookSync { - const ingressRetry = y.objectField(record, "ingressRetry", path); - const secret = y.objectField(record, "secret", path); - const bridge = y.objectField(record, "bridge", path); - const candidateGate = y.objectField(bridge, "candidateGate", `${path}.bridge`); - const inbox = y.objectField(bridge, "inbox", `${path}.bridge`); - const gitOpsDelivery = y.objectField(record, "gitOpsDelivery", path); - const frpc = y.objectField(record, "frpc", path); - return { - enabled: y.booleanField(record, "enabled", path), - direction: y.enumField(record, "direction", path, ["github-to-gitea"] as const), - responseBudgetMs: positiveInteger(record, "responseBudgetMs", path), - publicPath: y.apiPathField(record, "publicPath", path), - events: y.stringArrayField(record, "events", path), - ingressRetry: { - enabled: y.booleanField(ingressRetry, "enabled", `${path}.ingressRetry`), - maxBodyBytes: positiveInteger(ingressRetry, "maxBodyBytes", `${path}.ingressRetry`), - maxRetries: positiveInteger(ingressRetry, "maxRetries", `${path}.ingressRetry`), - tryIntervalMs: positiveInteger(ingressRetry, "tryIntervalMs", `${path}.ingressRetry`), - dialTimeoutMs: positiveInteger(ingressRetry, "dialTimeoutMs", `${path}.ingressRetry`), - writeTimeoutMs: positiveInteger(ingressRetry, "writeTimeoutMs", `${path}.ingressRetry`), - retryStatusCodes: y.numberArrayField(ingressRetry, "retryStatusCodes", `${path}.ingressRetry`), - }, - secret: { - sourceRef: secretSourceRefField(secret, "sourceRef", `${path}.secret`), - sourceKey: y.envKeyField(secret, "sourceKey", `${path}.secret`), - createIfMissing: y.booleanField(secret, "createIfMissing", `${path}.secret`), - }, - bridge: { - deploymentName: y.kubernetesNameField(bridge, "deploymentName", `${path}.bridge`), - serviceName: y.kubernetesNameField(bridge, "serviceName", `${path}.bridge`), - secretName: y.kubernetesNameField(bridge, "secretName", `${path}.bridge`), - configMapName: y.kubernetesNameField(bridge, "configMapName", `${path}.bridge`), - image: y.stringField(bridge, "image", `${path}.bridge`), - replicas: positiveInteger(bridge, "replicas", `${path}.bridge`), - httpPort: y.portField(bridge, "httpPort", `${path}.bridge`), - serviceAccountName: y.kubernetesNameField(bridge, "serviceAccountName", `${path}.bridge`), - shutdownGraceMs: positiveInteger(bridge, "shutdownGraceMs", `${path}.bridge`), - candidateGate: { - enabled: y.booleanField(candidateGate, "enabled", `${path}.bridge.candidateGate`), - configMapName: y.kubernetesNameField(candidateGate, "configMapName", `${path}.bridge.candidateGate`), - jobName: y.kubernetesNameField(candidateGate, "jobName", `${path}.bridge.candidateGate`), - activeDeadlineSeconds: positiveInteger(candidateGate, "activeDeadlineSeconds", `${path}.bridge.candidateGate`), - ttlSecondsAfterFinished: positiveInteger(candidateGate, "ttlSecondsAfterFinished", `${path}.bridge.candidateGate`), - healthTimeoutMs: positiveInteger(candidateGate, "healthTimeoutMs", `${path}.bridge.candidateGate`), - pollIntervalMs: positiveInteger(candidateGate, "pollIntervalMs", `${path}.bridge.candidateGate`), - }, - inbox: { - claimName: y.kubernetesNameField(inbox, "claimName", `${path}.bridge.inbox`), - path: y.absolutePathField(inbox, "path", `${path}.bridge.inbox`), - storageSize: quantity(inbox, "storageSize", `${path}.bridge.inbox`), - maxBytes: positiveInteger(inbox, "maxBytes", `${path}.bridge.inbox`), - committedRetentionSeconds: positiveInteger(inbox, "committedRetentionSeconds", `${path}.bridge.inbox`), - cleanupIntervalMs: positiveInteger(inbox, "cleanupIntervalMs", `${path}.bridge.inbox`), - }, - retry: parseWebhookRetry(y.objectField(bridge, "retry", `${path}.bridge`), `${path}.bridge.retry`), - }, - gitOpsDelivery: parseWebhookGitOpsDelivery(gitOpsDelivery, `${path}.gitOpsDelivery`), - frpc: { - proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), - remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), - }, - }; -} - -function parseWebhookGitOpsDelivery(record: Record, path: string): GiteaWebhookGitOpsDelivery { - const application = y.objectField(record, "application", path); - const author = y.objectField(record, "author", path); - const cas = y.objectField(record, "cas", path); - return { - enabled: y.booleanField(record, "enabled", path), - targetId: y.stringField(record, "targetId", path), - readUrl: urlField(record, "readUrl", path), - writeUrl: urlField(record, "writeUrl", path), - branch: gitBranchField(record, "branch", path), - sourceSnapshotPrefix: refPrefixField(record, "sourceSnapshotPrefix", path), - desiredManifestPath: safeRelativePathField(record, "desiredManifestPath", path), - bootstrapApplicationPath: safeRelativePathField(record, "bootstrapApplicationPath", path), - releaseStatePath: safeRelativePathField(record, "releaseStatePath", path), - application: { - name: y.kubernetesNameField(application, "name", `${path}.application`), - namespace: y.kubernetesNameField(application, "namespace", `${path}.application`), - project: y.kubernetesNameField(application, "project", `${path}.application`), - repoUrl: urlField(application, "repoUrl", `${path}.application`), - targetRevision: gitBranchField(application, "targetRevision", `${path}.application`), - path: safeRelativePathField(application, "path", `${path}.application`), - destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", `${path}.application`), - automated: y.booleanField(application, "automated", `${path}.application`), - }, - author: { - name: y.stringField(author, "name", `${path}.author`), - email: y.stringField(author, "email", `${path}.author`), - }, - cas: { - maxAttempts: positiveInteger(cas, "maxAttempts", `${path}.cas`), - }, - }; -} - -function parseWebhookRetry(record: Record, path: string): GiteaWebhookSync["bridge"]["retry"] { - return { - maxAttempts: positiveInteger(record, "maxAttempts", path), - initialDelayMs: positiveInteger(record, "initialDelayMs", path), - maxDelayMs: positiveInteger(record, "maxDelayMs", path), - terminalRetryDelayMs: positiveInteger(record, "terminalRetryDelayMs", path), - attemptTimeoutMs: positiveInteger(record, "attemptTimeoutMs", path), - scanIntervalMs: positiveInteger(record, "scanIntervalMs", path), - }; -} - -function parsePublicExposure(record: Record, path: string): PublicServiceExposure & { secretRoot: string } { - const dns = y.objectField(record, "dns", path); - const frpc = y.objectField(record, "frpc", path); - const pk01 = y.objectField(record, "pk01", path); - const publicBaseUrl = httpsUrlField(record, "publicBaseUrl", path); - const hostname = y.hostField(dns, "hostname", `${path}.dns`); - if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${configLabel}.${path}.dns.hostname must match publicBaseUrl`); - return { - enabled: y.booleanField(record, "enabled", path), - publicBaseUrl, - secretRoot: y.absolutePathField(record, "secretRoot", path), - dns: { - hostname, - expectedA: y.stringField(dns, "expectedA", `${path}.dns`), - resolvers: y.stringArrayField(dns, "resolvers", `${path}.dns`), - }, - frpc: { - deploymentName: y.kubernetesNameField(frpc, "deploymentName", `${path}.frpc`), - secretName: y.kubernetesNameField(frpc, "secretName", `${path}.frpc`), - secretKey: y.stringField(frpc, "secretKey", `${path}.frpc`), - image: y.stringField(frpc, "image", `${path}.frpc`), - serverAddr: y.hostField(frpc, "serverAddr", `${path}.frpc`), - serverPort: y.portField(frpc, "serverPort", `${path}.frpc`), - proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), - remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), - localIP: y.hostField(frpc, "localIP", `${path}.frpc`), - localPort: y.portField(frpc, "localPort", `${path}.frpc`), - tokenSourceRef: secretSourceRefField(frpc, "tokenSourceRef", `${path}.frpc`), - tokenSourceKey: y.envKeyField(frpc, "tokenSourceKey", `${path}.frpc`), - }, - pk01: { - route: y.stringField(pk01, "route", `${path}.pk01`), - caddyConfigPath: y.absolutePathField(pk01, "caddyConfigPath", `${path}.pk01`), - caddyServiceName: y.stringField(pk01, "caddyServiceName", `${path}.pk01`), - responseHeaderTimeoutSeconds: y.integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.pk01`), - }, - }; -} - -function parseResponsibility(record: Record, index: number): GiteaSourceResponsibility { - const path = `sourceAuthority.responsibilities[${index}]`; - return { - name: y.stringField(record, "name", path), - current: y.stringField(record, "current", path), - target: y.stringField(record, "target", path), - disposition: y.enumField(record, "disposition", path, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), - }; -} - -function parseMirrorRepository(record: Record, index: number): GiteaMirrorRepository { - const path = `sourceAuthority.repositories[${index}]`; - const upstream = y.objectField(record, "upstream", path); - const gitea = y.objectField(record, "gitea", path); - const gitops = y.objectField(record, "gitops", path); - const snapshot = y.objectField(record, "snapshot", path); - const legacyGitMirror = y.objectField(record, "legacyGitMirror", path); - return { - key: y.stringField(record, "key", path), - targetId: y.stringField(record, "targetId", path), - upstream: { - repository: repositoryField(upstream, "repository", `${path}.upstream`), - cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`), - branch: y.stringField(upstream, "branch", `${path}.upstream`), - }, - gitea: { - owner: y.stringField(gitea, "owner", `${path}.gitea`), - name: giteaRepoNameField(gitea, "name", `${path}.gitea`), - mirrorMode: y.enumField(gitea, "mirrorMode", `${path}.gitea`, ["controlled-push"] as const), - publicRead: y.booleanField(gitea, "publicRead", `${path}.gitea`), - readUrl: urlField(gitea, "readUrl", `${path}.gitea`), - }, - gitops: { - branch: y.stringField(gitops, "branch", `${path}.gitops`), - flushDisposition: y.stringField(gitops, "flushDisposition", `${path}.gitops`), - }, - snapshot: { - prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`), - }, - legacyGitMirror: { - readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`), - configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`), - disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), - }, - }; -} - -function parseTarget(record: Record, index: number): GiteaTarget { - const path = `targets[${index}]`; - const publicExposureRaw = record.publicExposure; - if (publicExposureRaw !== undefined && (typeof publicExposureRaw !== "object" || publicExposureRaw === null || Array.isArray(publicExposureRaw))) { - throw new Error(`${configLabel}.${path}.publicExposure must be an object`); - } - const webhookSyncRaw = record.webhookSync; - if (webhookSyncRaw !== undefined && (typeof webhookSyncRaw !== "object" || webhookSyncRaw === null || Array.isArray(webhookSyncRaw))) { - throw new Error(`${configLabel}.${path}.webhookSync must be an object`); - } - const publicExposure = publicExposureRaw as Record | undefined; - return { - id: y.stringField(record, "id", path), - route: y.stringField(record, "route", path), - namespace: y.kubernetesNameField(record, "namespace", path), - role: y.stringField(record, "role", path), - enabled: y.booleanField(record, "enabled", path), - createNamespace: y.booleanField(record, "createNamespace", path), - storageClassName: y.stringField(record, "storageClassName", path), - publicExposureEnabled: publicExposure === undefined ? true : y.booleanField(publicExposure, "enabled", `${path}.publicExposure`), - webhookSync: webhookSyncRaw === undefined ? null : parseTargetWebhookSync(webhookSyncRaw as Record, `${path}.webhookSync`), - }; -} - -function parseTargetWebhookSync(record: Record, path: string): GiteaTargetWebhookSync { - const frpc = y.objectField(record, "frpc", path); - return { - publicPath: y.apiPathField(record, "publicPath", path), - frpc: { - proxyName: y.stringField(frpc, "proxyName", `${path}.frpc`), - remotePort: y.portField(frpc, "remotePort", `${path}.frpc`), - }, - }; -} - -function validateConfig(gitea: GiteaConfig): void { - resolveTarget(gitea, gitea.defaults.targetId); - const gitFetchCredential = gitea.sourceAuthority.credentials.github.gitFetchCredential; - if (!gitea.sourceAuthority.credentials.github.requiredFor.includes("managed-repository-fetch")) { - throw new Error(`${configLabel}.sourceAuthority.credentials.github.requiredFor must include managed-repository-fetch`); - } - if (!/^[A-Za-z0-9._-]+$/u.test(gitFetchCredential.secretRef.key)) { - throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key must be a Kubernetes Secret data key`); - } - if (gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) { - throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`); - } - if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) { - throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`); - } - if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`); - if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`); - if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`); - if (!/-rootless$/u.test(gitea.app.image.tag)) throw new Error(`${configLabel}.app.image.tag must use a rootless Gitea image`); - if (gitea.app.service.type !== "ClusterIP") throw new Error(`${configLabel}.app.service.type must stay ClusterIP`); - if (!gitea.app.actions.enabled) throw new Error(`${configLabel}.app.actions.enabled must stay explicit while Gitea is the source-authority service`); - if (!gitea.app.registration.disabled) throw new Error(`${configLabel}.app.registration.disabled must be true for the internal POC service`); - if (gitea.app.probes.healthPath !== gitea.validation.healthPath) throw new Error(`${configLabel}.app.probes.healthPath must match validation.healthPath`); - if (gitea.app.server.rootUrl !== gitea.app.publicExposure.publicBaseUrl.replace(/\/?$/u, "/")) throw new Error(`${configLabel}.app.server.rootUrl must match app.publicExposure.publicBaseUrl for the Web UI`); - if (gitea.sourceAuthority.webhookSync.enabled) { - const events = new Set(gitea.sourceAuthority.webhookSync.events); - if (!events.has("push")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.events must include push for GitHub -> Gitea source sync`); - if (gitea.sourceAuthority.webhookSync.frpc.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel}.sourceAuthority.webhookSync.frpc.remotePort must differ from app.publicExposure.frpc.remotePort`); - const ingressRetry = gitea.sourceAuthority.webhookSync.ingressRetry; - const bridge = gitea.sourceAuthority.webhookSync.bridge; - if (bridge.replicas !== 1) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.replicas must be 1 because the durable inbox uses a single-writer Recreate deployment`); - if (bridge.retry.terminalRetryDelayMs < bridge.retry.maxDelayMs) throw new Error(`${configLabel}.sourceAuthority.webhookSync.bridge.retry.terminalRetryDelayMs must be >= maxDelayMs`); - if (ingressRetry.retryStatusCodes.length < 1 || ingressRetry.retryStatusCodes.some((statusCode) => ![502, 503, 504].includes(statusCode))) { - throw new Error(`${configLabel}.sourceAuthority.webhookSync.ingressRetry.retryStatusCodes may only contain 502, 503 and 504`); - } - if (ingressRetry.enabled) { - try { - validateGiteaWebhookTiming(gitea.sourceAuthority.webhookSync.responseBudgetMs, ingressRetry); - } catch (error) { - throw new Error(`${configLabel}.sourceAuthority.webhookSync timing invalid: ${String((error as Error).message || error)}`); - } - } - const delivery = gitea.sourceAuthority.webhookSync.gitOpsDelivery; - if (delivery.enabled) { - const target = resolveTarget(gitea, delivery.targetId); - if (target.id !== "NC01") throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.targetId must be NC01`); - if (delivery.application.targetRevision !== delivery.branch) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.targetRevision must match branch`); - if (delivery.application.repoUrl !== delivery.readUrl) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.application.repoUrl must match readUrl`); - if (dirname(delivery.desiredManifestPath) !== delivery.application.path) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must be directly under application.path`); - if (!delivery.bootstrapApplicationPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.bootstrapApplicationPath must be under the existing unidesk-host bootstrap path`); - if (delivery.desiredManifestPath.startsWith("deploy/gitops/unidesk-host/")) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.desiredManifestPath must use an independent GitOps path`); - if (!/^[^@\s]+@[^@\s]+$/u.test(delivery.author.email)) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.author.email must be an email address`); - if (delivery.cas.maxAttempts > 5) throw new Error(`${configLabel}.sourceAuthority.webhookSync.gitOpsDelivery.cas.maxAttempts must be <= 5`); - } - } - const webhookPaths = new Set(); - const webhookPorts = new Set(); - for (const route of webhookCaddyRoutes(gitea)) { - if (webhookPaths.has(route.publicPath)) throw new Error(`${configLabel} webhook publicPath must be unique: ${route.publicPath}`); - if (webhookPorts.has(route.remotePort)) throw new Error(`${configLabel} webhook frpc.remotePort must be unique: ${route.remotePort}`); - if (route.remotePort === gitea.app.publicExposure.frpc.remotePort) throw new Error(`${configLabel} webhook frpc.remotePort must differ from app public exposure port: ${route.remotePort}`); - webhookPaths.add(route.publicPath); - webhookPorts.add(route.remotePort); - } - const repoKeys = new Set(); - for (const repo of gitea.sourceAuthority.repositories) { - if (repoKeys.has(repo.key)) throw new Error(`${configLabel}.sourceAuthority.repositories contains duplicate key ${repo.key}`); - repoKeys.add(repo.key); - resolveTarget(gitea, repo.targetId); - const readUrl = new URL(repo.gitea.readUrl); - if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`); - if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`); - } -} - -function resolveTarget(gitea: GiteaConfig, targetId: string | null): GiteaTarget { - const resolved = targetId ?? gitea.defaults.targetId; - const target = gitea.targets.find((item) => item.id.toLowerCase() === resolved.toLowerCase()); - if (target === undefined) throw new Error(`unknown gitea target ${resolved}; known targets: ${gitea.targets.map((item) => item.id).join(", ")}`); - if (!target.enabled) throw new Error(`gitea target ${target.id} is disabled in ${configLabel}`); - return target; -} function plan(options: CommonOptions): Record { const gitea = readGiteaConfig(); @@ -986,7 +263,7 @@ async function mirrorCommand(config: UniDeskConfig, args: string[]): Promise] [--delivery-id ] [--full|--raw]", ], }, }; @@ -1000,14 +277,16 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom return options.full || options.raw ? result : renderMirrorWebhookApply(result); } if (action === "status") { - const options = parseMirrorOptions(args.slice(1)); + const options = parseMirrorWebhookStatusOptions(args.slice(1)); const result = await mirrorWebhookStatus(config, options); - return options.full || options.raw ? result : renderMirrorWebhookStatus(result); + if (options.raw) return rawMirrorWebhookStatus(result); + const disclosure = buildMirrorWebhookStatusDisclosure(result, options); + return options.full ? disclosure : renderMirrorWebhookStatus(result, disclosure); } return { ok: false, error: "unsupported-platform-infra-gitea-mirror-webhook-command", - usage: "platform-infra gitea mirror webhook status --target [--repo ] [--full|--raw]", + usage: "platform-infra gitea mirror webhook status --target [--repo ] [--delivery-id ] [--full|--raw]", }; } @@ -1135,7 +414,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions) }; } -async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorOptions): Promise> { +async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhookStatusOptions): Promise> { const gitea = readGiteaConfig(); const target = resolveTarget(gitea, options.targetId); const repos = selectedRepositories(gitea, target, options.repoKey); @@ -1934,15 +1213,6 @@ function targetFrpcExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfi }; } -function targetWebhookSync(gitea: GiteaConfig, target: GiteaTarget): GiteaWebhookSync { - const base = gitea.sourceAuthority.webhookSync; - if (target.webhookSync === null) return base; - return { - ...base, - publicPath: target.webhookSync.publicPath, - frpc: target.webhookSync.frpc, - }; -} function caddyExposureNeeded(gitea: GiteaConfig, target: GiteaTarget): boolean { return publicExposureEnabled(gitea, target) || (gitea.sourceAuthority.webhookSync.enabled && target.webhookSync !== null); @@ -1975,18 +1245,6 @@ ${webhookHandles} return await applyPk01CaddySiteBlock(config, "gitea", exposure, siteBlock, caddyManagedBlockMarkers("gitea")); } -function webhookCaddyRoutes(gitea: GiteaConfig): Array<{ publicPath: string; remotePort: number }> { - const sync = gitea.sourceAuthority.webhookSync; - if (!sync.enabled) return []; - const routes = new Map(); - routes.set(sync.publicPath, { publicPath: sync.publicPath, remotePort: sync.frpc.remotePort }); - for (const target of gitea.targets) { - if (!target.enabled || target.webhookSync === null) continue; - const targetSync = targetWebhookSync(gitea, target); - routes.set(targetSync.publicPath, { publicPath: targetSync.publicPath, remotePort: targetSync.frpc.remotePort }); - } - return [...routes.values()]; -} function publicExposureSummary(gitea: GiteaConfig): Record { const exposure = gitea.app.publicExposure; @@ -2080,7 +1338,12 @@ function selectedRepositories(gitea: GiteaConfig, target: GiteaTarget, repoKey: const repos = repositoriesForTarget(gitea, target); if (repoKey === null) return repos; const repo = repos.find((item) => item.key === repoKey); - if (repo === undefined) throw new Error(`unknown gitea mirror repo ${repoKey}; known repos: ${repos.map((item) => item.key).join(", ")}`); + if (repo === undefined) throw new CliInputError(`Unknown Gitea mirror repository key: ${repoKey}`, { + code: "unknown-repository-key", + argument: repoKey, + supported: repos.map((item) => item.key), + usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target --repo ", + }); return [repo]; } @@ -2300,268 +1563,6 @@ function fingerprintSecretParts(parts: Array): string | null { return `sha256:${hash.digest("hex").slice(0, 16)}`; } -function renderMirrorPlan(result: Record): RenderedCliResult { - const target = record(result.target); - const source = record(result.sourceAuthority); - const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.upstreamRepository), stringValue(repo.upstreamBranch), stringValue(repo.readUrl), stringValue(repo.legacyDisposition)]); - const responsibilities = arrayRecords(result.responsibilities).map((item) => [stringValue(item.name), stringValue(item.current), stringValue(item.target), stringValue(item.disposition)]); - const next = record(result.next); - return rendered(result, "platform-infra gitea mirror plan", [ - "PLATFORM-INFRA GITEA MIRROR PLAN", - ...table(["TARGET", "STAGE", "AUTHORITY", "REPOS"], [[stringValue(target.id), stringValue(source.stage), stringValue(source.statusAuthority), stringValue(source.repositoryCount)]]), - "", - "REPOSITORIES", - ...(repos.length === 0 ? ["-"] : table(["KEY", "UPSTREAM", "BRANCH", "GITEA_READ_URL", "LEGACY"], repos)), - "", - "RESPONSIBILITIES", - ...(responsibilities.length === 0 ? ["-"] : table(["NAME", "CURRENT", "TARGET", "DISPOSITION"], responsibilities)), - "", - "NEXT", - ` status: ${stringValue(next.status)}`, - ` full: ${stringValue(next.statusFull)}`, - ` webhook-status: ${stringValue(next.webhookStatus)}`, - ` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`, - "", - "Disclosure: credentials are summarized by presence/fingerprint only.", - ]); -} - -function renderMirrorBootstrap(result: Record): RenderedCliResult { - const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.owner), stringValue(repo.name), boolText(record(repo.create).ok)]); - const next = record(result.next); - return rendered(result, "platform-infra gitea mirror bootstrap", [ - "PLATFORM-INFRA GITEA MIRROR BOOTSTRAP", - ...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]), - "", - "REPOSITORIES", - ...(repos.length === 0 ? ["-"] : table(["KEY", "OWNER", "REPO", "API_OK"], repos)), - ...remoteErrorLines(result), - "", - `NEXT ${stringValue(next.webhookStatus)}`, - ]); -} - -function renderMirrorSync(result: Record): RenderedCliResult { - const bundles = arrayRecords(result.sourceBundles).map((bundle) => [stringValue(bundle.key), stringValue(bundle.sourceCommit).slice(0, 12), stringValue(bundle.snapshotRef), stringValue(bundle.readUrl)]); - const repos = arrayRecords(result.repositories).map((repo) => [ - stringValue(repo.key), - boolText(repo.syncOk), - stringValue(repo.fetchRc), - stringValue(repo.pushRc), - stringValue(repo.gitopsPushRc), - stringValue(repo.sourceCommit).slice(0, 12), - compactTail(stringValue(repo.fetchTail) || stringValue(repo.pushTail) || stringValue(repo.revparseTail)), - ]); - const next = record(result.next); - return rendered(result, "platform-infra gitea mirror sync", [ - "PLATFORM-INFRA GITEA MIRROR SYNC", - ...table(["OK", "MUTATION", "TARGET"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id)]]), - "", - "REPOSITORIES", - ...(repos.length === 0 ? ["-"] : table(["KEY", "SYNC", "FETCH", "PUSH", "GITOPS", "COMMIT", "ERROR"], repos)), - "", - "SOURCE BUNDLES", - ...(bundles.length === 0 ? ["-"] : table(["KEY", "COMMIT", "SNAPSHOT_REF", "READ_URL"], bundles)), - ...remoteErrorLines(result), - "", - `NEXT ${stringValue(next.status)}`, - ]); -} - -function renderMirrorStatus(result: Record): RenderedCliResult { - const source = record(result.sourceAuthority); - const service = record(result.serviceHealth); - const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.mirrorState), stringValue(repo.branchCommit).slice(0, 12), stringValue(repo.snapshotCommit).slice(0, 12), stringValue(repo.snapshotRef)]); - const next = record(result.next); - return rendered(result, "platform-infra gitea mirror status", [ - "PLATFORM-INFRA GITEA MIRROR STATUS", - ...table(["TARGET", "SERVICE", "MIRROR_READY", "AUTHORITY"], [[stringValue(record(result.target).id), boolText(service.ok), boolText(source.mirrorReady), stringValue(source.statusAuthority)]]), - "", - "REPOSITORIES", - ...(repos.length === 0 ? ["-"] : table(["KEY", "STATE", "BRANCH", "SNAPSHOT", "SNAPSHOT_REF"], repos)), - ...remoteErrorLines(result), - "", - `NEXT ${stringValue(next.webhookStatus)}`, - ]); -} - -function renderMirrorWebhookApply(result: Record): RenderedCliResult { - const repos = arrayRecords(result.repositories).map((repo) => [stringValue(repo.key), stringValue(repo.repository), boolText(repo.hookReady), stringValue(repo.hookId), compactTail(stringValue(repo.error))]); - const next = record(result.next); - return rendered(result, "platform-infra gitea mirror webhook apply", [ - "PLATFORM-INFRA GITEA MIRROR WEBHOOK APPLY", - ...table(["OK", "MUTATION", "TARGET", "URL"], [[boolText(result.ok), boolText(result.mutation), stringValue(record(result.target).id), stringValue(record(result.webhook).publicUrl)]]), - "", - "GITHUB HOOKS", - ...(repos.length === 0 ? ["-"] : table(["KEY", "REPOSITORY", "READY", "HOOK_ID", "ERROR"], repos)), - ...remoteErrorLines(result), - "", - `NEXT ${stringValue(next.webhookStatus)}`, - ]); -} - -function renderMirrorWebhookStatus(result: Record): RenderedCliResult { - const repos = arrayRecords(result.repositories).map((repo) => { - const latestPush = record(repo.latestPushDelivery); - const latest = record(repo.latestDelivery); - const delivery = latestPush.event !== "" ? latestPush : latest; - const inbox = record(repo.durableInboxRecord); - const inboxResult = record(inbox.result); - const inboxState = stringValue(repo.durableInboxState, "missing"); - const durableProof = repo.preDurableBootstrap === true - ? "pre-durable-bootstrap" - : repo.durableInboxCommitted === true - ? `${stringValue(inboxResult.disposition)}:${stringValue(inboxResult.sourceCommit).slice(0, 12)}` - : "-"; - return [ - stringValue(repo.key), - boolText(repo.hookReady), - stringValue(repo.githubHead).slice(0, 12), - stringValue(repo.branchCommit).slice(0, 12), - stringValue(repo.snapshotCommit).slice(0, 12), - boolText(repo.authorityMatches), - boolText(repo.bridgeEventStale), - `${stringValue(repo.deliveryId)}:${stringValue(delivery.statusCode)}:${stringValue(delivery.status)}`, - inboxState, - durableProof, - compactTail(stringValue(repo.error)), - ]; - }); - const bridge = record(result.bridge); - const bridgeLogs = record(result.bridgeLogs); - const retry = record(record(record(result.webhook).bridge).retry); - const retryText = `${stringValue(retry.maxAttempts)}x/${stringValue(retry.initialDelayMs)}-${stringValue(retry.maxDelayMs)}ms`; - const next = record(result.next); - return rendered(result, "platform-infra gitea mirror webhook status", [ - "PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS", - ...table(["TARGET", "BRIDGE", "READY", "RETRY", "URL", "LOGS"], [[stringValue(record(result.target).id), stringValue(bridge.deployment), boolText(bridge.ready), retryText, stringValue(record(result.webhook).publicUrl), `rc=${stringValue(bridgeLogs.exitCode)}`]]), - "", - "GITHUB HOOKS", - ...(repos.length === 0 ? ["-"] : table(["KEY", "HOOK", "GH_HEAD", "GITEA", "SNAPSHOT", "MATCH", "STALE", "DELIVERY", "DURABLE", "PROOF", "ERROR"], repos)), - ...remoteErrorLines(result), - "", - `NEXT ${stringValue(next.webhookStatusFull)}`, - ]); -} - -function renderPlan(result: Record): RenderedCliResult { - const config = record(result.config); - const target = record(config.target); - const app = record(config.app); - const migration = record(config.migration); - const policy = arrayRecords(result.policy); - const failed = policy.filter((item) => item.ok === false); - const next = record(result.next); - return rendered(result, "platform-infra gitea plan", [ - "PLATFORM-INFRA GITEA PLAN", - ...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [ - ["TARGET", stringValue(target.id), "route", stringValue(target.route)], - ["NAMESPACE", stringValue(target.namespace), "role", stringValue(target.role)], - ["IMAGE", stringValue(app.image), "replicas", stringValue(app.replicas)], - ["SERVICE", `${stringValue(app.serviceName)}:${stringValue(record(app.service).httpPort)}`, "dns", serviceDnsFromObjects(app, target)], - ["WEBUI", stringValue(record(app.publicExposure).publicBaseUrl), "internalGit", serviceDnsFromObjects(app, target)], - ["ACTIONS", boolText(app.actionsEnabled), "registrationDisabled", boolText(app.registrationDisabled)], - ["MIGRATION", stringValue(migration.role), "replaces", stringValue(migration.replaces)], - ["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"], - ]), - "", - "NEXT", - ` status: ${stringValue(next.status)}`, - ` validate: ${stringValue(next.validate)}`, - ` mirror-status: ${stringValue(next.mirrorStatus)}`, - ` webhook-status: ${stringValue(next.webhookStatus)}`, - ` fix-automatic-delivery: ${stringValue(next.fixAutomaticDelivery)}`, - "", - "Boundary: Gitea is internal ClusterIP source authority for GH-1560; CI trigger authority is Pipelines-as-Code, not Gitea Actions/act_runner.", - "Disclosure: Secret values are not printed; this stage does not create runner credentials.", - ]); -} - -function renderApply(result: Record): RenderedCliResult { - const target = record(result.target); - const payload = record(result.payloadTransport); - const remote = record(result.remote); - const steps = record(remote.steps); - const serverDryRunStep = record(steps.serverDryRun); - const frpcStep = record(steps.frpcSecret); - const frpcCleanupStep = record(steps.frpcCleanup); - const webhookSecretStep = record(steps.webhookSyncSecret); - const applyStep = record(steps.apply); - const rolloutStep = record(steps.rollout); - const frpcRolloutStep = record(steps.frpcRollout); - const webhookRolloutStep = record(steps.webhookSyncRollout); - const caddy = record(result.pk01Caddy); - return rendered(result, "platform-infra gitea apply", [ - "PLATFORM-INFRA GITEA APPLY", - ...table(["TARGET", "NAMESPACE", "MODE", "OK"], [[stringValue(target.id), stringValue(target.namespace), stringValue(result.mode), boolText(result.ok)]]), - ...table(["TRANSPORT", "BYTES", "MATERIALIZATION", "VALUES"], [[stringValue(payload.kind), stringValue(payload.totalBytes), stringValue(payload.materialization), stringValue(payload.valuesPrinted)]]), - "", - "STEPS", - ...table(["STEP", "EXIT", "DETAIL"], [ - ["serverDryRun", stringValue(serverDryRunStep.exitCode), compactLine(stringValue(serverDryRunStep.stderrTail, stringValue(serverDryRunStep.stdoutTail)))], - ["frpc-secret", stringValue(frpcStep.exitCode), compactLine(stringValue(frpcStep.stderrTail, stringValue(frpcStep.stdoutTail)))], - ["frpc-cleanup", stringValue(frpcCleanupStep.exitCode), compactLine(stringValue(frpcCleanupStep.stderrTail, stringValue(frpcCleanupStep.stdoutTail)))], - ["webhook-secret", stringValue(webhookSecretStep.exitCode), compactLine(stringValue(webhookSecretStep.stderrTail, stringValue(webhookSecretStep.stdoutTail)))], - ["apply", stringValue(applyStep.exitCode), compactLine(stringValue(applyStep.stderrTail, stringValue(applyStep.stdoutTail)))], - ["rollout", stringValue(rolloutStep.exitCode), compactLine(stringValue(rolloutStep.stderrTail, stringValue(rolloutStep.stdoutTail)))], - ["frpc-rollout", stringValue(frpcRolloutStep.exitCode), compactLine(stringValue(frpcRolloutStep.stderrTail, stringValue(frpcRolloutStep.stdoutTail)))], - ["webhook-rollout", stringValue(webhookRolloutStep.exitCode), compactLine(stringValue(webhookRolloutStep.stderrTail, stringValue(webhookRolloutStep.stdoutTail)))], - ["pk01-caddy", stringValue(caddy.exitCode ?? (caddy.ok === false ? 1 : 0)), compactLine(stringValue(caddy.error, stringValue(caddy.status, stringValue(caddy.reason))))], - ]), - ...remoteErrorLines(result), - "", - `NEXT bun scripts/cli.ts platform-infra gitea status --target ${stringValue(target.id)}`, - ]); -} - -function renderStatus(result: Record): RenderedCliResult { - const summary = record(result.summary); - const statefulSet = record(summary.statefulSet); - const service = record(summary.service); - const networkPolicy = record(summary.networkPolicy); - const pods = arrayRecords(summary.pods).map((pod) => [stringValue(pod.name), stringValue(pod.phase), boolText(pod.ready), stringValue(pod.restarts)]); - const pvcs = arrayRecords(summary.pvcs).map((pvc) => [stringValue(pvc.name), stringValue(pvc.phase), stringValue(pvc.capacity)]); - return rendered(result, "platform-infra gitea status", [ - "PLATFORM-INFRA GITEA STATUS", - ...table(["TARGET", "ROUTE", "NAMESPACE", "READY"], [[stringValue(summary.target), stringValue(summary.route), stringValue(summary.namespace), boolText(summary.ready)]]), - "", - "CONTROL", - ...table(["CHECK", "VALUE", "DETAIL"], [ - ["statefulSet", `${stringValue(statefulSet.readyReplicas)}/${stringValue(statefulSet.desired)}`, stringValue(statefulSet.name)], - ["service", stringValue(service.type), stringValue(summary.serviceDns)], - ["endpoints", boolText(summary.endpointsReady), stringValue(service.clusterIP)], - ["allow-all", boolText(networkPolicy.allowAllPresent), "NetworkPolicy"], - ]), - "", - "PODS", - ...(pods.length === 0 ? ["-"] : table(["POD", "PHASE", "READY", "RESTARTS"], pods)), - "", - "PVCS", - ...(pvcs.length === 0 ? ["-"] : table(["PVC", "PHASE", "CAPACITY"], pvcs)), - ...remoteErrorLines(result), - "", - `NEXT bun scripts/cli.ts platform-infra gitea validate --target ${stringValue(summary.target)}`, - ]); -} - -function remoteErrorLines(result: Record): string[] { - if (result.ok !== false) return []; - const remote = record(result.remote); - const exitCode = stringValue(remote.exitCode); - const stderr = compactLine(stringValue(remote.stderrTail)); - const stdout = compactLine(stringValue(remote.stdoutTail)); - const detail = stderr !== "-" ? stderr : stdout; - return ["", "ERROR", ...table(["EXIT", "DETAIL"], [[exitCode, detail]])]; -} - -function compactTail(value: string): string { - const line = value.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean).at(-1) ?? ""; - return line.length > 96 ? `${line.slice(0, 93)}...` : line; -} - -function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { - return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; -} function parseApplyOptions(args: string[]): ApplyOptions { const commonArgs: string[] = []; @@ -2581,10 +1582,40 @@ function parseApplyOptions(args: string[]): ApplyOptions { } } } - if (confirm && dryRun) throw new Error("gitea apply accepts only one of --confirm or --dry-run"); + if (confirm && dryRun) throw new CliInputError("Gitea apply accepts only one of --confirm or --dry-run", { + code: "mutually-exclusive-options", + argument: "--confirm --dry-run", + usage: ["bun scripts/cli.ts platform-infra gitea apply --dry-run", "bun scripts/cli.ts platform-infra gitea apply --confirm"], + }); return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait }; } +function parseMirrorWebhookStatusOptions(args: string[]): MirrorWebhookStatusOptions { + const mirrorArgs: string[] = []; + let deliveryId: string | null = null; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg !== "--delivery-id") { + mirrorArgs.push(arg); + continue; + } + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new CliInputError("--delivery-id requires a value", { + code: "missing-option-value", + argument: "--delivery-id", + usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target [--repo ] --delivery-id ", + }); + if (!/^[A-Za-z0-9._:-]{1,160}$/u.test(value)) throw new CliInputError("--delivery-id must be a bounded GitHub delivery identifier", { + code: "invalid-option-value", + argument: value, + usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target [--repo ] --delivery-id ", + }); + deliveryId = value; + index += 1; + } + return { ...parseMirrorOptions(mirrorArgs), deliveryId }; +} + function parseMirrorOptions(args: string[]): MirrorOptions { const commonArgs: string[] = []; let confirm = false; @@ -2595,8 +1626,16 @@ function parseMirrorOptions(args: string[]): MirrorOptions { confirm = true; } else if (arg === "--repo") { const value = args[index + 1]; - if (value === undefined || value.startsWith("--")) throw new Error("--repo requires a value"); - if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--repo must be a simple repo key"); + if (value === undefined || value.startsWith("--")) throw new CliInputError("--repo requires a value", { + code: "missing-option-value", + argument: "--repo", + usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target --repo ", + }); + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new CliInputError("--repo must be a simple repository key", { + code: "invalid-option-value", + argument: value, + usage: "bun scripts/cli.ts platform-infra gitea mirror webhook status --target --repo ", + }); repoKey = value; index += 1; } else { @@ -2618,8 +1657,16 @@ function parseCommonOptions(args: string[]): CommonOptions { const arg = args[index]; if (arg === "--target" || arg === "--node") { const value = args[index + 1]; - if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); - if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${arg} must be a simple target id`); + if (value === undefined || value.startsWith("--")) throw new CliInputError(`${arg} requires a value`, { + code: "missing-option-value", + argument: arg, + usage: "bun scripts/cli.ts platform-infra gitea --target ", + }); + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new CliInputError(`${arg} must be a simple target id`, { + code: "invalid-option-value", + argument: value, + usage: "bun scripts/cli.ts platform-infra gitea --target ", + }); targetId = value; index += 1; } else if (arg === "--full") { @@ -2628,7 +1675,12 @@ function parseCommonOptions(args: string[]): CommonOptions { raw = true; full = true; } else { - throw new Error(`unsupported gitea option: ${arg}`); + throw new CliInputError(`Unsupported Gitea option: ${arg}`, { + code: "unsupported-option", + argument: arg, + supported: ["--target", "--node", "--repo", "--delivery-id", "--full", "--raw"], + usage: "bun scripts/cli.ts platform-infra gitea help", + }); } } return { targetId, full, raw }; @@ -2664,95 +1716,6 @@ function serviceDnsFromObjects(app: Record, target: Record, key: string, path: string): number { - const value = y.integerField(obj, key, path); - if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`); - return value; -} - -function boundedTimeout(obj: Record, key: string, path: string): number { - const value = positiveInteger(obj, key, path); - if (value > 55) throw new Error(`${configLabel}.${path}.${key} must fit the 60s trans short-connection budget`); - return value; -} - -function literalField(obj: Record, key: string, path: string, allowed: T[]): T { - const value = y.stringField(obj, key, path); - if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`); - return value as T; -} - -function optionalLiteralField(obj: Record, key: string, path: string, allowed: readonly T[]): T | null { - if (obj[key] === undefined || obj[key] === null) return null; - const value = y.stringField(obj, key, path); - if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`); - return value as T; -} - -function quantity(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - if (!/^[0-9]+(?:m|Ki|Mi|Gi|Ti)?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes quantity`); - return value; -} - -function secretSourceRefField(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - if (value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a source ref path without ..`); - return value; -} - -function repositoryField(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be owner/repo`); - return value; -} - -function giteaRepoNameField(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - if (!/^[A-Za-z0-9_.-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a Gitea repository name`); - return value; -} - -function gitCloneUrlField(obj: Record, key: string, path: string): string { - const value = urlField(obj, key, path); - if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be an official GitHub HTTPS clone URL`); - return value; -} - -function refPrefixField(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(value) || value.includes("..")) throw new Error(`${configLabel}.${path}.${key} must be a git ref prefix`); - return value.replace(/\/+$/u, ""); -} - -function urlField(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - const parsed = new URL(value); - if (!["http:", "https:"].includes(parsed.protocol) || parsed.search || parsed.hash) throw new Error(`${configLabel}.${path}.${key} must be an http(s) URL without query or hash`); - return value; -} - -function gitBranchField(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - if (!/^[A-Za-z0-9._/-]+$/u.test(value) || value.startsWith("/") || value.endsWith("/") || value.includes("..") || value.includes("//")) { - throw new Error(`${configLabel}.${path}.${key} must be a safe Git branch name`); - } - return value; -} - -function safeRelativePathField(obj: Record, key: string, path: string): string { - const value = y.stringField(obj, key, path); - if (value.startsWith("/") || value.endsWith("/") || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) { - throw new Error(`${configLabel}.${path}.${key} must be a safe relative path`); - } - return value; -} - -function httpsUrlField(obj: Record, key: string, path: string): string { - const value = urlField(obj, key, path); - if (new URL(value).protocol !== "https:") throw new Error(`${configLabel}.${path}.${key} must be an https URL`); - return value; -} function yamlQuote(value: string): string { return JSON.stringify(value); diff --git a/scripts/src/ssh-help.test.ts b/scripts/src/ssh-help.test.ts index 7c7000f3..2234358c 100644 --- a/scripts/src/ssh-help.test.ts +++ b/scripts/src/ssh-help.test.ts @@ -3,23 +3,7 @@ import { describe, expect, test } from "bun:test"; import { repoRoot } from "./config"; import { sshHelp, sshHelpScope } from "./help"; -type RenderedHelp = { - ok: boolean; - command: string; - data: { - command?: string; - scope?: string; - runtime?: { repoRoot: string }; - routeSyntax?: Record | string; - operationGroups?: Record; - scopedHelp?: { availableOperationCount: number }; - usage?: string[]; - outputTruncated?: boolean; - dump?: unknown; - }; -}; - -function runTransHelp(...args: string[]): { stdout: string; rendered: RenderedHelp } { +function runTransHelp(...args: string[]): string { const result = spawnSync("bun", ["scripts/ssh-cli.ts", ...args], { cwd: repoRoot, env: { @@ -31,7 +15,7 @@ function runTransHelp(...args: string[]): { stdout: string; rendered: RenderedHe }); expect(result.status).toBe(0); expect(result.stderr).toBe(""); - return { stdout: result.stdout, rendered: JSON.parse(result.stdout) as RenderedHelp }; + return result.stdout; } describe("ssh bounded progressive help", () => { @@ -101,28 +85,41 @@ describe("ssh bounded progressive help", () => { test("renders compact top-level and scoped help without dump fallback", () => { const top = runTransHelp("--help"); - expect(Buffer.byteLength(top.stdout, "utf8")).toBeLessThan(6_144); - expect(top.rendered.ok).toBe(true); - expect(top.rendered.command).toBe("trans --help"); - expect(top.rendered.data.outputTruncated).toBeUndefined(); - expect(top.rendered.data.dump).toBeUndefined(); - expect(top.rendered.data.operationGroups?.transfer).toContain("download"); + expect(Buffer.byteLength(top, "utf8")).toBeLessThan(2_048); + expect(top.trim().split("\n")).toHaveLength(10); + expect(top).toContain("usage: trans "); + expect(top).toContain("transfer: upload | download"); + expect(top).toContain("scoped help: trans --help "); + expect(top).not.toContain("outputTruncated"); + expect(top).not.toContain("dump"); const routeTop = runTransHelp("NC01:k3s", "--help"); - expect(routeTop.rendered.command).toBe("trans NC01:k3s --help"); - expect(routeTop.rendered.data.outputTruncated).toBeUndefined(); - expect(routeTop.rendered.data.operationGroups).toEqual(top.rendered.data.operationGroups); + expect(routeTop).toBe(top); const applyPatch = runTransHelp("--help", "apply-patch"); - expect(applyPatch.rendered.command).toBe("trans --help apply-patch"); - expect(applyPatch.rendered.data.scope).toBe("apply-patch"); - expect(applyPatch.rendered.data.usage?.[0]).toContain("apply-patch"); - expect(applyPatch.rendered.data.outputTruncated).toBeUndefined(); + expect(applyPatch.trim().split("\n").length).toBeLessThanOrEqual(8); + expect(applyPatch).toContain("TRANS HELP apply-patch"); + expect(applyPatch).toContain("usage: trans apply-patch"); + expect(applyPatch).not.toContain("outputTruncated"); const download = runTransHelp("--help", "download"); - expect(download.rendered.command).toBe("trans --help download"); - expect(download.rendered.data.scope).toBe("download"); - expect(download.rendered.data.runtime?.repoRoot).toBe(repoRoot); - expect(download.rendered.data.outputTruncated).toBeUndefined(); + expect(download.trim().split("\n").length).toBeLessThanOrEqual(8); + expect(download).toContain("TRANS HELP download"); + expect(download).toContain("download "); + expect(download).not.toContain("outputTruncated"); + }); + + test("returns compact no-stack input errors for unknown help scopes", () => { + const result = spawnSync("bun", ["scripts/ssh-cli.ts", "--help", "unknown-operation"], { + cwd: repoRoot, + env: { ...process.env, UNIDESK_SSH_ENTRYPOINT: "trans", UNIDESK_TRANS_REPO_ROOT: repoRoot }, + encoding: "utf8", + }); + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + expect(result.stdout.trim().split("\n").length).toBeLessThanOrEqual(8); + expect(result.stdout).toContain("ERROR cli-input/unknown-help-scope"); + expect(result.stdout).not.toContain("stack"); + expect(result.stdout).not.toContain(" at "); }); }); diff --git a/scripts/ssh-cli.ts b/scripts/ssh-cli.ts index 0afce9e0..897b59ab 100644 --- a/scripts/ssh-cli.ts +++ b/scripts/ssh-cli.ts @@ -1,6 +1,6 @@ import { readConfig } from "./src/config"; -import { isHelpToken, sshHelp, sshHelpScope } from "./src/help"; -import { emitError, emitJson } from "./src/output"; +import { isHelpToken, renderSshHelpText, sshHelpScope } from "./src/help"; +import { CliInputError, emitError, emitText } from "./src/output"; import { extractRemoteCliOptions } from "./src/remote-options"; import { runRemoteSshCli } from "./src/remote-ssh"; import { runSsh } from "./src/ssh"; @@ -29,8 +29,27 @@ async function main(): Promise { const [top, sub, third] = args; if (top !== "ssh") throw new Error(`ssh-cli supports only the ssh command, got: ${top || ""}`); - if (sub === undefined || isHelpToken(sub) || (isHelpToken(third) && args.length === 3)) { - emitJson(commandName, sshHelp(sshHelpScope(args))); + if (sub === undefined) { + emitText(renderSshHelpText(), commandName); + return; + } + + if (isHelpToken(sub)) { + const scope = sshHelpScope(args); + if (args.length > 2 && scope === undefined) { + throw new CliInputError(`Unknown trans help scope: ${third ?? ""}`, { + code: "unknown-help-scope", + argument: third ?? "", + usage: ["trans --help", "trans --help "], + hint: "Use trans --help to list the bounded operation groups before opening scoped help.", + }); + } + emitText(renderSshHelpText(scope), commandName); + return; + } + + if (isHelpToken(third) && args.length === 3) { + emitText(renderSshHelpText(), commandName); return; }