import fs from "node:fs"; import { confirm } from "@clack/prompts"; import type { Command } from "commander"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; import { runSecretsApply } from "../secrets/apply.js"; import { resolveSecretsAuditExitCode, runSecretsAudit } from "../secrets/audit.js"; import { runSecretsConfigureInteractive } from "../secrets/configure.js"; import { isSecretsApplyPlan, type SecretsApplyPlan } from "../secrets/plan.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; type SecretsReloadOptions = GatewayRpcOpts & { json?: boolean }; type SecretsAuditOptions = { check?: boolean; json?: boolean; }; type SecretsConfigureOptions = { apply?: boolean; yes?: boolean; planOut?: string; providersOnly?: boolean; skipProviderSetup?: boolean; agent?: string; json?: boolean; }; type SecretsApplyOptions = { from: string; dryRun?: boolean; json?: boolean; }; function readPlanFile(pathname: string): SecretsApplyPlan { const raw = fs.readFileSync(pathname, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!isSecretsApplyPlan(parsed)) { throw new Error(`Invalid secrets plan file: ${pathname}`); } return parsed; } export function registerSecretsCli(program: Command) { const secrets = program .command("secrets") .description("Secrets runtime controls") .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/gateway/security", "docs.openclaw.ai/gateway/security")}\n`, ); addGatewayClientOptions( secrets .command("reload") .description("Re-resolve secret references and atomically swap runtime snapshot") .option("--json", "Output JSON", false), ).action(async (opts: SecretsReloadOptions) => { try { const result = await callGatewayFromCli("secrets.reload", opts, undefined, { expectFinal: false, }); if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } const warningCount = Number( (result as { warningCount?: unknown } | undefined)?.warningCount ?? 0, ); if (Number.isFinite(warningCount) && warningCount > 0) { defaultRuntime.log(`Secrets reloaded with ${warningCount} warning(s).`); return; } defaultRuntime.log("Secrets reloaded."); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); secrets .command("audit") .description("Audit plaintext secrets, unresolved refs, and precedence drift") .option("--check", "Exit non-zero when findings are present", false) .option("--json", "Output JSON", false) .action(async (opts: SecretsAuditOptions) => { try { const report = await runSecretsAudit(); if (opts.json) { defaultRuntime.log(JSON.stringify(report, null, 2)); } else { defaultRuntime.log( `Secrets audit: ${report.status}. plaintext=${report.summary.plaintextCount}, unresolved=${report.summary.unresolvedRefCount}, shadowed=${report.summary.shadowedRefCount}, legacy=${report.summary.legacyResidueCount}.`, ); if (report.findings.length > 0) { for (const finding of report.findings.slice(0, 20)) { defaultRuntime.log( `- [${finding.code}] ${finding.file}:${finding.jsonPath} ${finding.message}`, ); } if (report.findings.length > 20) { defaultRuntime.log(`... ${report.findings.length - 20} more finding(s).`); } } } const exitCode = resolveSecretsAuditExitCode(report, Boolean(opts.check)); if (exitCode !== 0) { defaultRuntime.exit(exitCode); } } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(2); } }); secrets .command("configure") .description("Interactive secrets helper (provider setup + SecretRef mapping + preflight)") .option("--apply", "Apply changes immediately after preflight", false) .option("--yes", "Skip apply confirmation prompt", false) .option("--providers-only", "Configure secrets.providers only, skip credential mapping", false) .option( "--skip-provider-setup", "Skip provider setup and only map credential fields to existing providers", false, ) .option( "--agent ", "Agent id for auth-profiles targets (default: configured default agent)", ) .option("--plan-out ", "Write generated plan JSON to a file") .option("--json", "Output JSON", false) .action(async (opts: SecretsConfigureOptions) => { try { const configured = await runSecretsConfigureInteractive({ providersOnly: Boolean(opts.providersOnly), skipProviderSetup: Boolean(opts.skipProviderSetup), agentId: typeof opts.agent === "string" ? opts.agent : undefined, }); if (opts.planOut) { fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8"); } if (opts.json) { defaultRuntime.log( JSON.stringify( { plan: configured.plan, preflight: configured.preflight, }, null, 2, ), ); } else { defaultRuntime.log( `Preflight: changed=${configured.preflight.changed}, files=${configured.preflight.changedFiles.length}, warnings=${configured.preflight.warningCount}.`, ); if (configured.preflight.warningCount > 0) { for (const warning of configured.preflight.warnings) { defaultRuntime.log(`- warning: ${warning}`); } } const providerUpserts = Object.keys(configured.plan.providerUpserts ?? {}).length; const providerDeletes = configured.plan.providerDeletes?.length ?? 0; defaultRuntime.log( `Plan: targets=${configured.plan.targets.length}, providerUpserts=${providerUpserts}, providerDeletes=${providerDeletes}.`, ); if (opts.planOut) { defaultRuntime.log(`Plan written to ${opts.planOut}`); } } let shouldApply = Boolean(opts.apply); if (!shouldApply && !opts.json) { const approved = await confirm({ message: "Apply this plan now?", initialValue: true, }); if (typeof approved === "boolean") { shouldApply = approved; } } if (shouldApply) { const needsIrreversiblePrompt = Boolean(opts.apply); if (needsIrreversiblePrompt && !opts.yes && !opts.json) { const confirmed = await confirm({ message: "This migration is one-way for migrated plaintext values. Continue with apply?", initialValue: true, }); if (confirmed !== true) { defaultRuntime.log("Apply cancelled."); return; } } const result = await runSecretsApply({ plan: configured.plan, write: true, }); if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } defaultRuntime.log( result.changed ? `Secrets applied. Updated ${result.changedFiles.length} file(s).` : "Secrets apply: no changes.", ); } } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); secrets .command("apply") .description("Apply a previously generated secrets plan") .requiredOption("--from ", "Path to plan JSON") .option("--dry-run", "Validate/preflight only", false) .option("--json", "Output JSON", false) .action(async (opts: SecretsApplyOptions) => { try { const plan = readPlanFile(opts.from); const result = await runSecretsApply({ plan, write: !opts.dryRun, }); if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } if (opts.dryRun) { defaultRuntime.log( result.changed ? `Secrets apply dry run: ${result.changedFiles.length} file(s) would change.` : "Secrets apply dry run: no changes.", ); return; } defaultRuntime.log( result.changed ? `Secrets applied. Updated ${result.changedFiles.length} file(s).` : "Secrets apply: no changes.", ); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); } }); }