fix(cli): 收敛逐级披露与输入错误
This commit is contained in:
+7
-2
@@ -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<void> {
|
||||
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")) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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} <route> <operation> [operation-args...]`,
|
||||
"routes: <provider>:/workspace | <provider>:k3s[:namespace:workload[:container]] | <provider>: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 <operation>`,
|
||||
"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<SshHelpScope, "download">): unknown {
|
||||
const entrypoint = sshEntrypoint();
|
||||
const detail = SSH_OPERATION_HELP[scope];
|
||||
|
||||
+83
-2
@@ -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<never> = { 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<redacted>")
|
||||
@@ -85,6 +137,7 @@ export function readCliOutputPolicy(): CliOutputPolicy {
|
||||
|
||||
function normalizeErrorPayload(command: string, error: unknown): Record<string, unknown> {
|
||||
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<string,
|
||||
if (error instanceof Error) {
|
||||
const structured = structuredCliErrorPayload(error, message);
|
||||
if (structured !== null) return structured;
|
||||
const debug = process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" || process.env.UNIDESK_CLI_RAW_ERROR === "1";
|
||||
const debug = cliDebugEnabled();
|
||||
return {
|
||||
name: error.name,
|
||||
message,
|
||||
@@ -105,6 +158,34 @@ function normalizeErrorPayload(command: string, error: unknown): Record<string,
|
||||
return { message };
|
||||
}
|
||||
|
||||
function cliInputErrorPayload(error: CliInputError): Record<string, unknown> {
|
||||
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<string, unknown> | null {
|
||||
const record = error as Error & Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(record, "replacementExamples") && !Object.prototype.hasOwnProperty.call(record, "migrationHint")) return null;
|
||||
|
||||
@@ -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 <NODE> [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra gitea validate --target <NODE> [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra gitea mirror status --target <NODE> [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra gitea mirror webhook status --target <NODE> [--repo <key>] [--delivery-id <id>] [--full|--raw]",
|
||||
]);
|
||||
expect(giteaUsage.join("\n")).not.toMatch(/\b(?:apply|sync|flush|trigger)\b/);
|
||||
});
|
||||
|
||||
function cli(args: string[]): { status: number | null; payload: Record<string, any> } {
|
||||
|
||||
@@ -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<Record<string, unknown>>(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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> | 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<string, unknown>, `${path}.webhookSync`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTargetWebhookSync(record: Record<string, unknown>, 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<string>();
|
||||
const webhookPorts = new Set<number>();
|
||||
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<string>();
|
||||
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<string, { publicPath: string; remotePort: number }>();
|
||||
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<string, unknown>, 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<string, unknown>, 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<T extends string>(obj: Record<string, unknown>, 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<T extends string>(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
export interface MirrorWebhookDisclosureSelection {
|
||||
repoKey: string | null;
|
||||
deliveryId: string | null;
|
||||
}
|
||||
|
||||
export function rawMirrorWebhookStatus(result: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
selection: MirrorWebhookDisclosureSelection,
|
||||
): Record<string, unknown> {
|
||||
const target = record(result.target);
|
||||
const targetId = stringValue(target.id, "<node>");
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
delivery?: Record<string, unknown>;
|
||||
logEvents: Record<string, unknown>[];
|
||||
durableInbox: Record<string, unknown>;
|
||||
bridgeLogs: Record<string, unknown>;
|
||||
mirrorStatus: Record<string, unknown>;
|
||||
}): Record<string, unknown> | 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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record<string, unknown>[]
|
||||
: [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { RenderedCliResult } from "./output";
|
||||
|
||||
export function renderMirrorPlan(result: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>, disclosure: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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, unknown>): 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<string, unknown>, command: string, lines: string[]): RenderedCliResult {
|
||||
return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function serviceDnsFromObjects(app: Record<string, unknown>, target: Record<string, unknown>): string {
|
||||
const service = record(app.service);
|
||||
return `${stringValue(app.serviceName)}.${stringValue(target.namespace)}.svc.cluster.local:${stringValue(service.httpPort)}`;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record<string, unknown>[]
|
||||
: [];
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
+109
-1146
File diff suppressed because it is too large
Load Diff
@@ -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, string> | string;
|
||||
operationGroups?: Record<string, string>;
|
||||
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 <route> <operation>");
|
||||
expect(top).toContain("transfer: upload | download");
|
||||
expect(top).toContain("scoped help: trans --help <operation>");
|
||||
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 <route> 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 <remote-file> <local-file>");
|
||||
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 ");
|
||||
});
|
||||
});
|
||||
|
||||
+23
-4
@@ -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<void> {
|
||||
const [top, sub, third] = args;
|
||||
if (top !== "ssh") throw new Error(`ssh-cli supports only the ssh command, got: ${top || "<empty>"}`);
|
||||
|
||||
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 ?? "<missing>"}`, {
|
||||
code: "unknown-help-scope",
|
||||
argument: third ?? "",
|
||||
usage: ["trans --help", "trans --help <operation>"],
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user