// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/deploy.ts. // SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets. // Moved mechanically from scripts/src/deploy.ts:213-535 for #903. import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { runCommand } from "../command"; import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "../config"; import { ensureGithubSshIdentityForProvider } from "../deploy-ssh-identity"; import { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText, type RuntimeSecretContract, runArtifactRegistryCommand } from "../artifact-registry"; import { startJob } from "../jobs"; import { coreInternalFetch } from "../microservices"; import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "../code-queue-source-guard"; import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard"; import { composeRuntimeEnvValue } from "../runtime-env"; import { compareDeployJsonExecutorMirrors, deployJsonCommitImage, deployJsonDriftResult, deployJsonSourceOfTruth, encodeDeployJsonServiceContract, hasDeployJsonExecutorContract, k3sManifestExecutorMirror, parseDeployJsonServiceContract, type DeployJsonExecutorMirror, type DeployJsonServiceContract, } from "../deploy-json-contract"; import { resolveArtifactRegistryTarget } from "../ops/targets"; import type { DeployEnvironment, DeployManifest, DeployManifestService, DeployOptions } from "./types"; import { step } from "./remote"; import { defaultDeployFile, defaultTimeoutMs, k8sKubeconfig, resolveFetchTimeout, unideskRepoUrl } from "./types"; export function isHelpArg(value: string | undefined): boolean { return value === "help" || value === "--help" || value === "-h"; } export function deployHelp(action: string | undefined = undefined): Record { const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply|guard" : `deploy ${action}`; return { ok: true, command, usage: { check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]", plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]", apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force] [--timeout-ms N] [--target D601|local] [--run-now]", guard: "bun scripts/cli.ts deploy guard code-queue-source [--root /home/ubuntu/cq-deploy]", }, actions: { check: "Validate desired repo+commit state against live service health and commit markers.", plan: "Show desired/live drift, or with --env show the environment-ref dry-run plan without touching runtime resources.", apply: "Start an async deploy/reconcile job unless --run-now is explicitly present.", guard: "Run local deployment guards without mutating runtime resources.", }, releaseV1Frontend: { producer: "bun scripts/cli.ts ci publish-user-service --service frontend --commit --wait-ms 1200000", devConsumer: "bun scripts/cli.ts deploy apply --env dev --service frontend --commit ", prodConsumer: "bun scripts/cli.ts deploy apply --env prod --service frontend --commit ", boundary: "--commit selects an existing commit-pinned registry artifact for one reviewed artifact consumer; it does not read a dirty local manifest or make the server rebuild a release path.", }, options: [ { name: "--file ", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local D601 maintenance apply is limited to approved direct exceptions; Code Queue direct rollout is disabled." }, { name: "--env ", description: "Read the named environment from origin/master:deploy.json. Dev apply supports reviewed artifact consumers for backend-core, frontend, baidu-netdisk, decision-center, mdtodo, claudeqq, dev-only code-queue, project-manager, oa-event-flow, code-queue-mgr, todo-note, findjob, pipeline, and met-nonlinear. Prod apply uses reviewed D601 registry artifact consumers; code-queue has no prod target and gated/incomplete services return structured unsupported or dry-run-only output." }, { name: "--service ", description: "Limit reconcile to one service from the manifest." }, { name: "--commit ", description: "Artifact consumer override for one selected --env dev|prod service; the commit-pinned image must already exist in the D601 registry. This is the supported release/v1 frontend validation and rollback shape without editing a local manifest." }, { name: "--dry-run", description: "Prepare and validate without mutating the target service." }, { name: "--force", description: "Redeploy even when the live commit appears up to date." }, { name: "--timeout-ms ", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." }, { name: "--target ", default: "config/artifact-registry.yaml#defaults.targetId", description: "Artifact-registry consumer target. --provider-id is accepted as a legacy alias." }, { name: "--run-now", description: "Run apply in the foreground worker process; omit it for fire-and-forget async job mode." }, { name: "guard code-queue-source --root ", description: "Validate Code Queue hostPath source relative imports before any scheduler rollout; failures report degradedReason and missing import targets." }, ], }; } export function nowIso(): string { return new Date().toISOString(); } export function elapsedMs(startedAt: number): number { return Math.max(0, Date.now() - startedAt); } export function shellQuote(value: string): string { return `'${value.replace(/'/gu, `'\\''`)}'`; } export function d601K3sGuardScript(): string { return d601K3sGuardShellLines(k8sKubeconfig).join("\n"); } export function compactTail(text: string, maxChars = 1600): string { return text.length > maxChars ? text.slice(text.length - maxChars) : text; } export function progressLine(step: string, message: string, detail?: unknown): void { const payload = detail === undefined ? { at: nowIso(), step, message } : { at: nowIso(), step, message, detail }; process.stderr.write(`${JSON.stringify(payload)}\n`); } export function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } export function asString(value: unknown): string { return typeof value === "string" ? value : ""; } export function parseJsonObjectFromText(text: string): unknown | null { const start = text.indexOf("{"); if (start < 0) return null; let depth = 0; let inString = false; let escaped = false; for (let index = start; index < text.length; index += 1) { const char = text[index]; if (inString) { if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === "\"") { inString = false; } continue; } if (char === "\"") { inString = true; continue; } if (char === "{") { depth += 1; continue; } if (char === "}") { depth -= 1; if (depth === 0) return JSON.parse(text.slice(start, index + 1)) as unknown; } } return null; } export function parseFullCommit(value: string): string { const match = value.match(/\b[0-9a-f]{40}\b/iu); return match?.[0]?.toLowerCase() ?? ""; } export function safeId(value: string): string { const sanitized = value.replace(/[^A-Za-z0-9_.-]/gu, "-"); if (sanitized.length === 0) throw new Error(`invalid service id: ${value}`); return sanitized; } export function repoResolveCacheDir(repo: string): string { return rootPath(".state", "deploy", "resolve", createHash("sha256").update(repo).digest("hex").slice(0, 16)); } export function repoSlug(repo: string): string | null { const trimmed = repo.trim().replace(/\.git$/u, ""); const https = trimmed.match(/^https:\/\/([^/]+)\/(.+)$/u); if (https !== null) return `${https[1]?.toLowerCase()}/${https[2]}`; const ssh = trimmed.match(/^git@([^:]+):(.+)$/u); if (ssh !== null) return `${ssh[1]?.toLowerCase()}/${ssh[2]}`; return null; } export function isFullGitSha(value: string): boolean { return /^[0-9a-f]{40}$/iu.test(value); } export function isDeployEnvironment(value: string): value is DeployEnvironment { return value === "dev" || value === "prod"; } export function isUnideskRepo(repo: string): boolean { const desiredSlug = repoSlug(repo); const unideskSlug = repoSlug(unideskRepoUrl); return desiredSlug !== null && desiredSlug === unideskSlug; } export function sshUrlForSlug(slug: string | null): string | null { if (slug === null) return null; const [host = "", ...pathParts] = slug.split("/"); const path = pathParts.join("/"); if (host.length === 0 || path.length === 0) return null; if (host !== "github.com" && host !== "gitee.com") return null; return `git@${host}:${path}.git`; } export function candidateRepoUrls(repo: string): string[] { const desiredSlug = repoSlug(repo); const urls = [repo]; const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot); const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : ""; if (localOriginUrl.length > 0 && repoSlug(localOriginUrl) === desiredSlug) urls.push(localOriginUrl); const sshUrl = sshUrlForSlug(desiredSlug); if (sshUrl !== null) urls.push(sshUrl); return [...new Set(urls)]; } export function providerSourceRepoUrl(repo: string): string { const slug = repoSlug(repo); if (slug === null || (!slug.startsWith("github.com/") && !slug.startsWith("gitee.com/"))) return repo; return sshUrlForSlug(slug) ?? repo; } export function localResolvedCommitForDesired(desired: DeployManifestService): string | null { const desiredSlug = repoSlug(desired.repo); if (desiredSlug === null || !isFullGitSha(desired.commitId)) return null; const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot); const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : ""; if (localOriginUrl.length === 0 || repoSlug(localOriginUrl) !== desiredSlug) return null; const resolved = runCommand(["git", "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot); const fullCommit = parseFullCommit(resolved.stdout); return resolved.exitCode === 0 && fullCommit === desired.commitId.toLowerCase() ? fullCommit : null; } export function commandFailure(result: ReturnType, maxChars = 1200): string { const detail = [result.stderr, result.stdout].filter(Boolean).join("\n"); return compactTail(detail, maxChars) || `exit ${result.exitCode}`; } export function runGitOrThrow(args: string[], cwd: string, message: string): ReturnType { const result = runCommand(["git", ...args], cwd); if (result.exitCode !== 0) throw new Error(`${message}: ${commandFailure(result)}`); return result; } export function runResolveGit(args: string[], cwd: string): ReturnType { return runCommand(["timeout", resolveFetchTimeout, "git", ...args], cwd); } export function resolveDesiredCommit(desired: DeployManifestService): DeployManifestService { const localCommit = localResolvedCommitForDesired(desired); if (localCommit !== null) return { ...desired, commitId: localCommit }; if (isFullGitSha(desired.commitId) && !isUnideskRepo(desired.repo)) return { ...desired, commitId: desired.commitId.toLowerCase() }; const cacheDir = repoResolveCacheDir(desired.repo); mkdirSync(cacheDir, { recursive: true }); if (!existsSync(join(cacheDir, ".git", "config"))) { const init = runCommand(["git", "init", cacheDir], repoRoot); if (init.exitCode !== 0 && !existsSync(join(cacheDir, ".git", "config"))) { throw new Error(`failed to initialize deploy commit resolver for ${desired.repo}: ${commandFailure(init)}`); } } const fetchErrors: string[] = []; for (const repoUrl of candidateRepoUrls(desired.repo)) { runCommand(["git", "-C", cacheDir, "remote", "remove", "origin"], repoRoot); const addRemote = runCommand(["git", "-C", cacheDir, "remote", "add", "origin", repoUrl], repoRoot); if (addRemote.exitCode !== 0) { fetchErrors.push(`${repoUrl}: ${commandFailure(addRemote)}`); continue; } const fetches = desired.commitId.length === 40 ? [ runResolveGit(["-C", cacheDir, "fetch", "--no-tags", "--depth=1", "origin", desired.commitId], repoRoot), runResolveGit([ "-C", cacheDir, "fetch", "--no-tags", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*", ], repoRoot), ] : [ runResolveGit([ "-C", cacheDir, "fetch", "--no-tags", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*", ], repoRoot), ]; const fetch = fetches.find((result) => result.exitCode === 0) ?? fetches[fetches.length - 1]; if (fetch.exitCode === 0) { fetchErrors.length = 0; break; } fetchErrors.push(`${repoUrl}: ${commandFailure(fetch)}`); } if (fetchErrors.length > 0) { throw new Error(`deploy manifest service ${desired.id} cannot fetch ${desired.repo} before deploy: ${compactTail(fetchErrors.join("\n"), 1600)}`); } const resolved = runCommand(["git", "-C", cacheDir, "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot); const fullCommit = parseFullCommit(resolved.stdout); if (resolved.exitCode !== 0 || fullCommit.length !== 40) { throw new Error(`deploy manifest service ${desired.id} commitId ${desired.commitId} cannot be resolved in ${desired.repo}; use a pushed unique 7-40 char SHA. ${commandFailure(resolved)}`); } return { ...desired, commitId: fullCommit }; } export function resolveManifestCommits(manifest: DeployManifest, serviceId: string | null): DeployManifest { return { schemaVersion: manifest.schemaVersion, environment: manifest.environment, services: manifest.services.map((service) => (serviceId === null || service.id === serviceId ? resolveDesiredCommit(service) : service)), }; } export function optionValue(args: string[], names: string[]): string | undefined { for (const name of names) { const index = args.indexOf(name); if (index === -1) continue; const raw = args[index + 1]; if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`); return raw; } return undefined; } export function positiveIntegerOption(args: string[], names: string[], defaultValue: number): number { const raw = optionValue(args, names); if (raw === undefined) return defaultValue; const parsed = Number(raw); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${names[0]} must be a positive integer`); return parsed; } export function environmentOption(args: string[]): DeployEnvironment | null { const raw = optionValue(args, ["--env", "--environment"]); if (raw === undefined) return null; if (!isDeployEnvironment(raw)) throw new Error("--env must be dev or prod"); return raw; } export function parseOptions(args: string[]): DeployOptions { const environment = environmentOption(args); if (environment !== null && args.includes("--file")) throw new Error("deploy --env reads deploy.json from the fixed Git ref and cannot be combined with --file"); const commitOverride = optionValue(args, ["--commit"]); const serviceId = optionValue(args, ["--service", "--service-id"]) ?? null; if (commitOverride !== undefined && environment === null) throw new Error("deploy --commit is only supported for --env dev|prod artifact consumer apply"); if (commitOverride !== undefined && !isFullGitSha(commitOverride)) throw new Error("deploy --commit must be a full 40-character commit SHA"); if (commitOverride !== undefined && serviceId === null) throw new Error("deploy --commit requires --service so artifact consumer apply is unambiguous"); const artifactTarget = resolveArtifactRegistryTarget(optionValue(args, ["--target", "--provider-id", "--provider"]) ?? null); return { file: optionValue(args, ["--file"]) ?? defaultDeployFile, environment, serviceId, commitOverride: commitOverride?.toLowerCase() ?? null, providerId: artifactTarget.providerId, runNow: args.includes("--run-now"), dryRun: args.includes("--dry-run"), force: args.includes("--force"), timeoutMs: positiveIntegerOption(args, ["--timeout-ms"], defaultTimeoutMs), }; }