// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets. import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, normalize, relative } from "node:path"; import pathPosix from "node:path/posix"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { coreInternalFetch } from "./microservices"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; export interface OpsCommonOptions { targetId: string | null; full: boolean; raw: boolean; } export interface OpsApplyOptions extends OpsCommonOptions { confirm: boolean; dryRun: boolean; wait: boolean; } export interface OpsCommandOptionSpec { stringOptions?: string[]; flagOptions?: string[]; } export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise { return await runSshCommandCapture(config, route, args, stdin); } export function parseJsonOutput(stdout: string): Record | null { const trimmed = stdout.trim(); if (trimmed.length === 0) return null; const start = trimmed.indexOf("{"); const end = trimmed.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) return null; try { const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown; return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; } catch { return null; } } export function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record { const full = options.full ?? false; return { exitCode: result.exitCode, stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), stderrBytes: Buffer.byteLength(result.stderr, "utf8"), stdoutTail: full || result.exitCode !== 0 ? redactText(result.stdout).slice(-8000) : "", stderrTail: full || result.exitCode !== 0 ? redactText(result.stderr).slice(-4000) : "", }; } export function redactText(text: string): string { return text .replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_") .replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1@") .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") .replace(/(["']?(?:N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API[_-]?KEY|APIKEY|JWT[_-]?SECRET|DATABASE[_-]?URL)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1$2"); } export function fingerprintValues(values: Record, keys: string[]): string { const hash = createHash("sha256"); for (const key of keys.slice().sort()) { hash.update(key); hash.update("\0"); hash.update(values[key] ?? ""); hash.update("\0"); } return `sha256:${hash.digest("hex")}`; } export function sha256Hex(value: string): string { return createHash("sha256").update(value).digest("hex"); } export function sha256Fingerprint(value: string): string { return `sha256:${sha256Hex(value)}`; } export function shortSha256Fingerprint(value: string, chars = 12): string { return sha256Hex(value).slice(0, chars); } export function fingerprintEnvValues(values: Record, keys: string[]): string { const material = keys .slice() .sort() .map((key) => `${key}=${values[key] ?? ""}`) .join("\n"); return `sha256:${createHash("sha256").update(material).digest("hex")}`; } export function parseEnvFile(text: string): Record { const result: Record = {}; for (const rawLine of text.split(/\r?\n/u)) { const line = rawLine.trim(); if (line.length === 0 || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq <= 0) continue; const key = line.slice(0, eq).trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue; result[key] = unquoteEnvValue(line.slice(eq + 1).trim()); } return result; } function unquoteEnvValue(value: string): string { if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) return value.slice(1, -1); return value; } export function compactText(value: string, maxChars = 500): string { return value.replace(/\s+/gu, " ").trim().slice(0, maxChars); } export function shQuote(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } export function parseOpsCommonOptions(args: string[], spec: OpsCommandOptionSpec = {}): OpsCommonOptions & Record { const stringOptions = new Set(["--target", ...(spec.stringOptions ?? [])]); const flagOptions = new Set(["--full", "--raw", ...(spec.flagOptions ?? [])]); const values: Record = { full: false, raw: false }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (stringOptions.has(arg)) { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); if (arg === "--target") values.targetId = value; else values[optionKey(arg)] = value; index += 1; } else if (flagOptions.has(arg)) { if (arg === "--full") values.full = true; else if (arg === "--raw") { values.raw = true; values.full = true; } else { values[optionKey(arg)] = true; } } else { throw new Error(`unsupported option: ${arg}`); } } const targetId = values.targetId === undefined ? null : String(values.targetId); if (targetId !== null && !/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id"); return { ...values, targetId } as OpsCommonOptions & Record; } export function parseOpsApplyOptions(args: string[]): OpsApplyOptions { const commonArgs: string[] = []; let confirm = false; let dryRun = false; let wait = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--confirm") confirm = true; else if (arg === "--dry-run") dryRun = true; else if (arg === "--wait") wait = true; else { commonArgs.push(arg); if (arg === "--target") { commonArgs.push(args[index + 1] ?? ""); index += 1; } } } if (confirm && dryRun) throw new Error("apply accepts only one of --confirm or --dry-run"); return { ...parseOpsCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait }; } export function readYamlRecord>(path: string, expectedKind?: string): T { const parsed = Bun.YAML.parse(readFileSync(path, "utf8")) as unknown; const record = asRecord(parsed, path); if (expectedKind !== undefined && record.kind !== expectedKind) throw new Error(`${repoRelative(path)}.kind must be ${expectedKind}`); return record as T; } export function asRecord(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); return value as Record; } export function recordField(obj: Record, key: string, path: string): Record { return asRecord(obj[key], `${path}.${key}`); } export function stringField(obj: Record, key: string, path: string): string { const value = obj[key]; if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`); return value.trim(); } export function optionalStringField(obj: Record, key: string, path: string): string | undefined { const value = obj[key]; if (value === undefined || value === null) return undefined; if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`); return value.trim(); } export function booleanField(obj: Record, key: string, path: string): boolean { const value = obj[key]; if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`); return value; } export function numberField(obj: Record, key: string, path: string): number { const value = obj[key]; if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path}.${key} must be a finite number`); return value; } export function integerField(obj: Record, key: string, path: string): number { const value = obj[key]; if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`); return Number(value); } export function optionalIntegerField(obj: Record, key: string, path: string): number | undefined { const value = obj[key]; if (value === undefined || value === null) return undefined; if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer when set`); return Number(value); } export function arrayField(obj: Record, key: string, path: string): Record[] { const value = obj[key]; if (!Array.isArray(value)) throw new Error(`${path}.${key} must be an array`); return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`)); } export function stringListField(obj: Record, key: string, path: string): string[] { const value = obj[key]; if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path}.${key} must be an array of non-empty strings`); return value as string[]; } export function stringArrayField(obj: Record, key: string, path: string): string[] { const value = obj[key]; if (!Array.isArray(value)) throw new Error(`${path}.${key} must be an array`); return value.map((item, index) => { if (typeof item !== "string" || item.trim().length === 0) throw new Error(`${path}.${key}[${index}] must be a non-empty string`); return item.trim(); }); } export function yamlFieldLabel(configLabel: string, path: string, key: string): string { const prefix = path.length > 0 ? `${path}.` : ""; return `${configLabel}.${prefix}${key}`; } export function yamlSubFieldLabel(label: string, key: string): string { return label.endsWith(".") ? `${label}${key}` : `${label}.${key}`; } export function yamlRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`); return value as Record; } export function yamlObjectField(obj: Record, key: string, configLabel: string, path: string): Record { return yamlRecord(obj[key], yamlFieldLabel(configLabel, path, key)); } export function yamlArrayOfRecords(value: unknown, configLabel: string, path: string): Record[] { if (!Array.isArray(value)) throw new Error(`${configLabel}.${path} must be an array`); return value.map((item, index) => yamlRecord(item, `${configLabel}.${path}[${index}]`)); } export function yamlStringField(obj: Record, key: string, configLabel: string, path: string): string { const value = obj[key]; if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a non-empty string`); return value.trim(); } export function yamlIntegerField(obj: Record, key: string, configLabel: string, path: string): number { const value = obj[key]; if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an integer`); return value; } export function yamlBooleanField(obj: Record, key: string, configLabel: string, path: string): boolean { const value = obj[key]; if (typeof value !== "boolean") throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a boolean`); return value; } export function yamlStringArrayField(obj: Record, key: string, configLabel: string, path: string): string[] { const value = obj[key]; if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a string array`); return value.map((item) => (item as string).trim()); } export function yamlIntegerArrayField(obj: Record, key: string, configLabel: string, path: string): number[] { const value = obj[key]; if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an integer array`); return value as number[]; } export function yamlEnumField(obj: Record, key: string, configLabel: string, path: string, values: T): T[number] { const value = yamlStringField(obj, key, configLabel, path); if (!(values as readonly string[]).includes(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be one of ${values.join(", ")}`); return value as T[number]; } export function yamlKubernetesNameField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a Kubernetes name`); return value; } export function yamlSourceRefField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); if (value.startsWith("/") || value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a relative source ref without ..`); return value; } export function yamlEnvKeyField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an env key`); return value; } export function yamlPgIdentifierField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a PostgreSQL identifier`); return value; } export function yamlHostField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} has an unsupported host format`); return value; } export function yamlPortField(obj: Record, key: string, configLabel: string, path: string): number { const value = yamlIntegerField(obj, key, configLabel, path); if (value < 1 || value > 65535) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a TCP port`); return value; } export function yamlAbsolutePathField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); if (!value.startsWith("/")) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be absolute`); return value; } export function yamlApiPathField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); if (!value.startsWith("/") || value.includes("..")) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an absolute path without ..`); return value; } export function yamlHttpsUrlField(obj: Record, key: string, configLabel: string, path: string): string { const value = yamlStringField(obj, key, configLabel, path); const url = new URL(value); if (url.protocol !== "https:" || url.search || url.hash) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an https URL without query or hash`); return value.replace(/\/+$/u, ""); } export interface YamlFieldReader { asRecord(value: unknown, label: string): Record; objectField(obj: Record, key: string, path: string): Record; arrayOfRecords(value: unknown, path: string): Record[]; stringField(obj: Record, key: string, path: string): string; integerField(obj: Record, key: string, path: string): number; booleanField(obj: Record, key: string, path: string): boolean; stringArrayField(obj: Record, key: string, path: string): string[]; numberArrayField(obj: Record, key: string, path: string): number[]; enumField(obj: Record, key: string, path: string, values: T): T[number]; kubernetesNameField(obj: Record, key: string, path: string): string; sourceRefField(obj: Record, key: string, path: string): string; envKeyField(obj: Record, key: string, path: string): string; pgIdentifierField(obj: Record, key: string, path: string): string; hostField(obj: Record, key: string, path: string): string; portField(obj: Record, key: string, path: string): number; absolutePathField(obj: Record, key: string, path: string): string; apiPathField(obj: Record, key: string, path: string): string; httpsUrlField(obj: Record, key: string, path: string): string; } export function createYamlFieldReader(configLabel: string): YamlFieldReader { return { asRecord: (value, label) => yamlRecord(value, label), objectField: (obj, key, path) => yamlObjectField(obj, key, configLabel, path), arrayOfRecords: (value, path) => yamlArrayOfRecords(value, configLabel, path), stringField: (obj, key, path) => yamlStringField(obj, key, configLabel, path), integerField: (obj, key, path) => yamlIntegerField(obj, key, configLabel, path), booleanField: (obj, key, path) => yamlBooleanField(obj, key, configLabel, path), stringArrayField: (obj, key, path) => yamlStringArrayField(obj, key, configLabel, path), numberArrayField: (obj, key, path) => yamlIntegerArrayField(obj, key, configLabel, path), enumField: (obj, key, path, values) => yamlEnumField(obj, key, configLabel, path, values), kubernetesNameField: (obj, key, path) => yamlKubernetesNameField(obj, key, configLabel, path), sourceRefField: (obj, key, path) => yamlSourceRefField(obj, key, configLabel, path), envKeyField: (obj, key, path) => yamlEnvKeyField(obj, key, configLabel, path), pgIdentifierField: (obj, key, path) => yamlPgIdentifierField(obj, key, configLabel, path), hostField: (obj, key, path) => yamlHostField(obj, key, configLabel, path), portField: (obj, key, path) => yamlPortField(obj, key, configLabel, path), absolutePathField: (obj, key, path) => yamlAbsolutePathField(obj, key, configLabel, path), apiPathField: (obj, key, path) => yamlApiPathField(obj, key, configLabel, path), httpsUrlField: (obj, key, path) => yamlHttpsUrlField(obj, key, configLabel, path), }; } export function repoRelative(path: string): string { const rel = relative(rootPath(), path); return rel.startsWith("..") ? path : rel; } export function resolveRepoPath(path: string): string { if (path.startsWith("/")) throw new Error(`repo-owned path must be relative: ${path}`); if (path.includes("..")) throw new Error(`repo-owned path must not contain ..: ${path}`); return rootPath(path); } export function sanitizePathSegment(value: string, fallback = "unknown"): string { const cleaned = value.trim().replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, ""); return cleaned.length > 0 ? cleaned.slice(0, 120) : fallback; } export function dateInTimeZone(date: Date, timeZone: string): string { const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(date); const pick = (type: string) => parts.find((part) => part.type === type)?.value ?? "00"; return `${pick("year")}-${pick("month")}-${pick("day")}`; } export function sha256File(path: string): string { return createHash("sha256").update(readFileSync(path)).digest("hex"); } export function ensureFileInside(root: string, relativePath: string): string { if (relativePath.startsWith("/") || relativePath.includes("..")) throw new Error(`path must be relative inside staging: ${relativePath}`); const rootAbsolute = hostRootPath(root); const resolved = normalize(join(rootAbsolute, relativePath)); if (!resolved.startsWith(normalize(rootAbsolute + "/")) && resolved !== normalize(rootAbsolute)) throw new Error(`path escapes staging root: ${relativePath}`); mkdirSync(dirname(resolved), { recursive: true }); return resolved; } export function writeStagingText(params: { hostRoot: string; relativePath: string; content: string }): { hostPath: string; bytes: number; sha256: string } { const hostPath = ensureFileInside(params.hostRoot, params.relativePath); writeFileSync(hostPath, params.content, "utf8"); return { hostPath, bytes: Buffer.byteLength(params.content, "utf8"), sha256: sha256File(hostPath) }; } export function writeStagingBase64(params: { hostRoot: string; relativePath: string; dataBase64: string }): { hostPath: string; bytes: number; sha256: string } { const buffer = Buffer.from(params.dataBase64, "base64"); const hostPath = ensureFileInside(params.hostRoot, params.relativePath); writeFileSync(hostPath, buffer); return { hostPath, bytes: buffer.byteLength, sha256: sha256File(hostPath) }; } export function containerPathToHostPath(containerRoot: string, hostRoot: string, containerPath: string): string | null { const cleanContainerRoot = normalize(containerRoot); const cleanPath = normalize(containerPath); if (cleanPath !== cleanContainerRoot && !cleanPath.startsWith(`${cleanContainerRoot}/`)) return null; const rel = relative(cleanContainerRoot, cleanPath); return join(hostRootPath(hostRoot), rel); } export function hostRootPath(root: string): string { return normalize(root.startsWith("/") ? root : rootPath(root)); } export interface MicroserviceProxyResponse { ok: boolean; status: number | null; body: unknown; raw: Record | null; } export function microserviceProxy(serviceId: string, upstreamPath: string, init: { method?: string; body?: unknown; timeoutMs?: number; maxResponseBytes?: number } = {}): MicroserviceProxyResponse { if (!upstreamPath.startsWith("/")) throw new Error("microservice upstream path must start with /"); const response = coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${upstreamPath}`, { method: init.method, body: init.body, timeoutMs: init.timeoutMs, maxResponseBytes: init.maxResponseBytes ?? 5_000_000, }); const raw = typeof response === "object" && response !== null && !Array.isArray(response) ? response as Record : null; return { ok: raw?.ok === true, status: typeof raw?.status === "number" ? raw.status : null, body: raw !== null && "body" in raw ? raw.body : response, raw, }; } export function assertProxyOk(response: MicroserviceProxyResponse, context: string): Record { if (response.ok && typeof response.body === "object" && response.body !== null && !Array.isArray(response.body)) return response.body as Record; throw new Error(`${context} failed: ${JSON.stringify(compactProxyResponse(response)).slice(0, 1200)}`); } export function compactProxyResponse(response: MicroserviceProxyResponse): Record { return { ok: response.ok, status: response.status, body: compactUnknown(response.body), }; } export function compactUnknown(value: unknown, maxString = 400): unknown { if (typeof value === "string") return redactText(value).slice(0, maxString); if (Array.isArray(value)) return { arrayPreview: value.slice(0, 5).map((item) => compactUnknown(item, maxString)), count: value.length }; if (typeof value !== "object" || value === null) return value; const record = value as Record; const output: Record = {}; for (const key of Object.keys(record).slice(0, 20)) output[key] = redactSensitiveKey(key) ? "" : compactUnknown(record[key], maxString); if (Object.keys(record).length > 20) output.omittedKeys = Object.keys(record).length - 20; return output; } export function redactSensitiveUnknown(value: unknown): unknown { if (typeof value === "string") return redactText(value); if (Array.isArray(value)) return value.map((item) => redactSensitiveUnknown(item)); if (typeof value !== "object" || value === null) return value; const output: Record = {}; for (const [key, nested] of Object.entries(value as Record)) output[key] = redactSensitiveKey(key) ? "" : redactSensitiveUnknown(nested); return output; } export async function waitForBaiduTransfer(serviceId: string, jobId: string, options: { timeoutMs: number; pollIntervalMs: number }): Promise> { const deadline = Date.now() + options.timeoutMs; let last: Record | null = null; while (Date.now() <= deadline) { const detail = assertProxyOk(microserviceProxy(serviceId, `/api/transfers/${encodeURIComponent(jobId)}`, { timeoutMs: 30_000 }), `baidu transfer ${jobId}`); const job = asRecord(detail.job, `transfer ${jobId}.job`); last = job; const status = String(job.status || ""); if (status === "succeeded") return { ok: true, job, events: detail.events }; if (status === "failed" || status === "canceled") return { ok: false, job, events: detail.events }; await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs)); } return { ok: false, timeout: true, job: last }; } export function findBaiduFileByRemotePath(serviceId: string, remotePath: string): Record | null { const dir = pathPosix.dirname(remotePath); const name = pathPosix.basename(remotePath); const response = assertProxyOk(microserviceProxy(serviceId, `/api/files?dir=${encodeURIComponent(dir)}&limit=500&order=time&desc=1`, { timeoutMs: 60_000 }), "baidu list files"); const files = Array.isArray(response.files) ? response.files : []; for (const item of files) { if (typeof item !== "object" || item === null || Array.isArray(item)) continue; const record = item as Record; if (String(record.path || "") === remotePath || String(record.serverFilename || record.filename || record.name || "") === name) return record; } return null; } export interface N8nWorkflowSyncResult { ok: boolean; mode: "dry-run" | "confirmed"; workflow: { id: string; name: string; webhookPath: string; active: boolean }; remote?: Record; manifest?: Record; } export async function syncN8nWorkflow(config: UniDeskConfig, params: { targetRoute: string; namespace: string; deploymentName: string; workflowJson: Record; workflowId: string; workflowName: string; webhookPath: string; active: boolean; dryRun: boolean; }): Promise { if (params.dryRun) { return { ok: true, mode: "dry-run", workflow: { id: params.workflowId, name: params.workflowName, webhookPath: params.webhookPath, active: params.active }, manifest: { nodes: Array.isArray(params.workflowJson.nodes) ? params.workflowJson.nodes.length : 0, active: params.workflowJson.active, settings: params.workflowJson.settings, }, }; } const encoded = Buffer.from(JSON.stringify(params.workflowJson, null, 2), "utf8").toString("base64"); const script = ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT workflow="$tmp/wechat-archive-workflow.json" printf '%s' '${encoded}' | base64 -d >"$workflow" kubectl -n ${params.namespace} exec -i deploy/${params.deploymentName} -c n8n -- sh -lc ${shQuote(` set -u mkdir -p /tmp/unidesk-n8n cat >/tmp/unidesk-n8n/wechat-archive-workflow.json n8n import:workflow --input=/tmp/unidesk-n8n/wechat-archive-workflow.json `)} <"$workflow" >"$tmp/import.out" 2>"$tmp/import.err" import_rc=$? if [ "$import_rc" -eq 0 ] && [ "${params.active ? "1" : "0"}" = "1" ]; then kubectl -n ${params.namespace} exec deploy/${params.deploymentName} -c n8n -- n8n update:workflow --id ${shQuote(params.workflowId)} --active=true >"$tmp/activate.out" 2>"$tmp/activate.err" activate_rc=$? else : >"$tmp/activate.out" if [ "$import_rc" -eq 0 ]; then printf '%s\\n' 'workflow activation not requested by YAML' >"$tmp/activate.err" activate_rc=0 else printf '%s\\n' 'skipped because import failed' >"$tmp/activate.err" activate_rc=1 fi fi if [ "$activate_rc" -eq 0 ] && [ "${params.active ? "1" : "0"}" = "1" ]; then kubectl -n ${params.namespace} rollout restart deployment/${params.deploymentName} >"$tmp/restart.out" 2>"$tmp/restart.err" restart_rc=$? if [ "$restart_rc" -eq 0 ]; then kubectl -n ${params.namespace} rollout status deployment/${params.deploymentName} --timeout=120s >"$tmp/rollout.out" 2>"$tmp/rollout.err" rollout_rc=$? else : >"$tmp/rollout.out" printf '%s\\n' 'skipped because rollout restart failed' >"$tmp/rollout.err" rollout_rc=1 fi else : >"$tmp/restart.out" : >"$tmp/rollout.out" printf '%s\\n' 'skipped because activation failed or workflow is inactive by YAML' >"$tmp/restart.err" printf '%s\\n' 'skipped because activation failed or workflow is inactive by YAML' >"$tmp/rollout.err" restart_rc=0 rollout_rc=0 fi kubectl -n ${params.namespace} exec deploy/${params.deploymentName} -c n8n -- n8n list:workflow >"$tmp/list.out" 2>"$tmp/list.err" list_rc=$? python3 - "$import_rc" "$activate_rc" "$restart_rc" "$rollout_rc" "$list_rc" "$tmp/import.out" "$tmp/import.err" "$tmp/activate.out" "$tmp/activate.err" "$tmp/restart.out" "$tmp/restart.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/list.out" "$tmp/list.err" <<'PY' import json, sys def text(path, limit=6000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" payload = { "ok": int(sys.argv[1]) == 0 and int(sys.argv[2]) == 0 and int(sys.argv[3]) == 0 and int(sys.argv[4]) == 0, "workflow": {"id": "${params.workflowId}", "name": "${params.workflowName}", "webhookPath": "${params.webhookPath}", "active": ${params.active ? "True" : "False"}}, "steps": { "import": {"exitCode": int(sys.argv[1]), "stdout": text(sys.argv[6]), "stderr": text(sys.argv[7])}, "activate": {"exitCode": int(sys.argv[2]), "stdout": text(sys.argv[8]), "stderr": text(sys.argv[9])}, "restart": {"exitCode": int(sys.argv[3]), "stdout": text(sys.argv[10]), "stderr": text(sys.argv[11])}, "rollout": {"exitCode": int(sys.argv[4]), "stdout": text(sys.argv[12]), "stderr": text(sys.argv[13])}, "list": {"exitCode": int(sys.argv[5]), "stdout": text(sys.argv[14]), "stderr": text(sys.argv[15])}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; const result = await capture(config, params.targetRoute, ["sh"], script); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed?.ok === true, mode: "confirmed", workflow: { id: params.workflowId, name: params.workflowName, webhookPath: params.webhookPath, active: params.active }, remote: parsed ?? compactCapture(result, { full: true }), }; } export async function fetchJsonWithTimeout(url: string, body: unknown, timeoutMs: number): Promise> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: controller.signal, }); const text = await response.text(); let parsed: unknown = text; try { parsed = text.length > 0 ? JSON.parse(text) as unknown : null; } catch { parsed = text; } return { ok: response.ok, status: response.status, body: parsed }; } finally { clearTimeout(timer); } } export function renderTemplate(template: string, values: Record): string { return template.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/gu, (_match, key: string) => values[key] ?? ""); } export function normalizeRemotePath(path: string): string { const normalized = pathPosix.normalize(path); if (!normalized.startsWith("/")) return `/${normalized}`; return normalized; } export function relativeStagingPath(dir: string, filename: string): string { return pathPosix.join(dir.replace(/^\/+|\/+$/gu, ""), basename(filename)); } function optionKey(arg: string): string { return arg.replace(/^--/u, "").replace(/-([a-z])/gu, (_match, letter: string) => letter.toUpperCase()); } function redactSensitiveKey(key: string): boolean { return /(?:password|secret|token|api[_-]?key|authorization|cookie|qrcode|usercode|verificationurl|dlink|thumbs)/iu.test(key); }