From 29dee15903513139f02ee45659542b9c5f11b686 Mon Sep 17 00:00:00 2001 From: Railly Date: Sat, 2 May 2026 00:58:05 -0500 Subject: [PATCH] feat: [P3] Keychain integration for secrets (macOS keychain, Linux secret-service) (closes #21) --- packages/cli/bin/sunat.ts | 2 + packages/cli/skills/sunat-cli/SKILL.md | 23 ++++- packages/cli/src/commands/cpe/index.ts | 16 ++-- packages/cli/src/commands/keychain.ts | 97 ++++++++++++++++++++ packages/cli/src/commands/login.ts | 5 +- packages/cli/src/commands/sire/index.ts | 9 +- packages/cli/src/cpe/config.ts | 9 +- packages/cli/src/cpe/oauth-config.ts | 5 +- packages/cli/src/data/config.ts | 9 +- packages/cli/src/data/keychain.ts | 113 ++++++++++++++++++++++++ 10 files changed, 265 insertions(+), 23 deletions(-) create mode 100644 packages/cli/src/commands/keychain.ts create mode 100644 packages/cli/src/data/keychain.ts diff --git a/packages/cli/bin/sunat.ts b/packages/cli/bin/sunat.ts index f69e4d0..b69f16b 100755 --- a/packages/cli/bin/sunat.ts +++ b/packages/cli/bin/sunat.ts @@ -4,6 +4,7 @@ import { createApiCommand } from "../src/commands/api/index.ts"; import { createAuditCommand } from "../src/commands/audit.ts"; import { createCpeCommand } from "../src/commands/cpe/index.ts"; import { createF616Command } from "../src/commands/f616/index.ts"; +import { createKeychainCommand } from "../src/commands/keychain.ts"; import { createLoginCommand } from "../src/commands/login.ts"; import { createLukeaCommand } from "../src/commands/lukea/index.ts"; import { createPadronCommand } from "../src/commands/padron/index.ts"; @@ -32,6 +33,7 @@ program.addCommand(createWhoamiCommand()); program.addCommand(createSchemaCommand()); program.addCommand(createRheCommand()); program.addCommand(createF616Command()); +program.addCommand(createKeychainCommand()); program.addCommand(createApiCommand()); program.addCommand(createLukeaCommand()); program.addCommand(createCpeCommand()); diff --git a/packages/cli/skills/sunat-cli/SKILL.md b/packages/cli/skills/sunat-cli/SKILL.md index 6192a79..4aefe63 100644 --- a/packages/cli/skills/sunat-cli/SKILL.md +++ b/packages/cli/skills/sunat-cli/SKILL.md @@ -15,7 +15,8 @@ Three ways to provide credentials (priority order): 1. **Inline flags** (agent-friendly): `sunat-cli login --ruc 10XXXXXXXXX --user XXXXXXXX --password XXXXXX` 2. **Env vars**: `SUNAT_RUC`, `SUNAT_USER`, `SUNAT_PASSWORD` -3. **Interactive prompts**: just run `sunat-cli login` and it asks step by step +3. **OS keychain**: `sunat keychain set SUNAT_PASSWORD --value '...'` +4. **Interactive prompts**: just run `sunat-cli login` and it asks step by step ```bash sunat-cli login --ruc 10123456789 --user MYUSER --password MYPASS @@ -25,6 +26,20 @@ sunat-cli whoami RUC and usuario are saved to `~/.sunat/config.json` after first login. Password is never stored. +Secrets resolve as env var → OS keychain → clear error. Env vars always win, which keeps CI predictable. + +```bash +sunat keychain set CPE_CERT_PASSWORD --value 'your-pfx-password' +sunat keychain set CPE_SOL_PASSWORD --value 'your-clave-sol' +sunat keychain set SUNAT_API_CLIENT_SECRET --value 'your-client-secret' +sunat keychain get CPE_CERT_PASSWORD +sunat keychain list +sunat keychain clear CPE_CERT_PASSWORD +``` + +macOS stores secrets through `security add-generic-password -s sunat-cli -a -w `. +Linux stores secrets through `secret-tool` / libsecret. + ### RHE (Recibo por Honorarios) ```bash @@ -171,11 +186,15 @@ Returns `cdrCode=0` (Aceptado) end-to-end. sunat cpe profile set --name beta --ruc 20131312955 --razon-social "ACME SAC" \ --mode beta --cert-path /abs/path/to/cert.pfx --sol-usuario MODATOS1 --default -# 2. Set sensitive vars (NEVER commit) +# 2. Set sensitive vars or keychain secrets (NEVER commit) export CPE_PROFILE=beta export CPE_CERT_PASSWORD='your-pfx-password' export CPE_SOL_PASSWORD='your-clave-sol' +# Keychain alternative for local machines +sunat keychain set CPE_CERT_PASSWORD --value 'your-pfx-password' +sunat keychain set CPE_SOL_PASSWORD --value 'your-clave-sol' + # 3. Verify sunat cpe --driver sunat-direct doctor # Checks: config_resolved, cert_file_exists, cert_loaded (validUntil), diff --git a/packages/cli/src/commands/cpe/index.ts b/packages/cli/src/commands/cpe/index.ts index 93ca98b..6782af6 100644 --- a/packages/cli/src/commands/cpe/index.ts +++ b/packages/cli/src/commands/cpe/index.ts @@ -1,12 +1,14 @@ import { Command } from "commander"; import { audit } from "../../data/audit.ts"; import { clearQueueForEmisor, enqueueBoleta, listQueueDates, readQueue } from "../../cpe/boleta-queue.ts"; +import { buildCatalogCoverageReport, hasCatalogWarnings } from "../../cpe/catalogos/index.ts"; import { resolveCpeContext } from "../../cpe/config.ts"; import { getDriver } from "../../cpe/drivers/index.ts"; import type { CpeDriverName } from "../../cpe/drivers/types.ts"; import { parseFacturaInput, parseNotaInput } from "../../cpe/parsers.ts"; import { loadCpeConfig, saveCpeConfig } from "../../cpe/config.ts"; import { boletaRequiresIndividualSubmission } from "../../cpe/ubl/boleta.ts"; +import { resolveSecret } from "../../data/keychain.ts"; import { output, outputError } from "../../utils/output.ts"; type Format = "json" | "table" | "auto"; @@ -85,6 +87,8 @@ export function createCpeCommand(): Command { const input = parseFacturaInput(opts.params); const driver = getDriver(getDriverName(cmd)); const result = await driver.previewFactura(input); + const catalogCoverage = buildCatalogCoverageReport(input); + if (hasCatalogWarnings(catalogCoverage)) result.catalogCoverage = catalogCoverage; audit({ command: "cpe factura preview", args: input as unknown as Record, result: "dry-run", details: { hash: result.hash } }); output(format, { json: { dryRun: true, ...result } }); } catch (err) { @@ -106,6 +110,8 @@ export function createCpeCommand(): Command { if (opts.dryRun) { const preview = await driver.previewFactura(input); + const catalogCoverage = buildCatalogCoverageReport(input); + if (hasCatalogWarnings(catalogCoverage)) preview.catalogCoverage = catalogCoverage; audit({ command: "cpe factura emit", args: input as unknown as Record, result: "dry-run" }); output(format, { json: { dryRun: true, ...preview } }); return; @@ -403,16 +409,16 @@ export function createCpeCommand(): Command { // Need OAuth credentials (client_id/secret + RUC + SOL pwd) const clientId = process.env.SUNAT_GRE_CLIENT_ID || process.env.SUNAT_API_CLIENT_ID; - const clientSecret = process.env.SUNAT_GRE_CLIENT_SECRET || process.env.SUNAT_API_CLIENT_SECRET; + const clientSecret = resolveSecret(["SUNAT_GRE_CLIENT_SECRET", "SUNAT_API_CLIENT_SECRET"]); if (!clientId || !clientSecret) { outputError( - "GRE needs SUNAT_GRE_CLIENT_ID + SUNAT_GRE_CLIENT_SECRET (or SUNAT_API_*) env vars. Get from SOL → Credenciales API SUNAT, URI = 'GRE Emisión de Comprobantes'.", + "GRE needs SUNAT_GRE_CLIENT_ID + SUNAT_GRE_CLIENT_SECRET (or SUNAT_API_*) env vars/keychain secrets. Get from SOL → Credenciales API SUNAT, URI = 'GRE Emisión de Comprobantes'.", format, ); return; } if (!ctx.solUsuario || !ctx.solPassword) { - outputError("GRE needs SOL usuario + password (CPE_SOL_USUARIO/PASSWORD env vars).", format); + outputError("GRE needs SOL usuario + password (CPE_SOL_USUARIO env var plus CPE_SOL_PASSWORD/SUNAT_PASSWORD env var or keychain secret).", format); return; } const greCreds = greCredentials({ @@ -479,9 +485,9 @@ export function createCpeCommand(): Command { const { greCredentials, consultarGreTicket, pollGreTicket } = await import("../../sunat-rest/gre.ts"); const ctx = resolveCpeContext(); const clientId = process.env.SUNAT_GRE_CLIENT_ID || process.env.SUNAT_API_CLIENT_ID; - const clientSecret = process.env.SUNAT_GRE_CLIENT_SECRET || process.env.SUNAT_API_CLIENT_SECRET; + const clientSecret = resolveSecret(["SUNAT_GRE_CLIENT_SECRET", "SUNAT_API_CLIENT_SECRET"]); if (!clientId || !clientSecret) { - outputError("Missing SUNAT_GRE_CLIENT_ID/SECRET env vars.", format); + outputError("Missing SUNAT_GRE_CLIENT_ID env var and SUNAT_GRE_CLIENT_SECRET/SUNAT_API_CLIENT_SECRET env var or keychain secret.", format); return; } const greCreds = greCredentials({ diff --git a/packages/cli/src/commands/keychain.ts b/packages/cli/src/commands/keychain.ts new file mode 100644 index 0000000..2a84af4 --- /dev/null +++ b/packages/cli/src/commands/keychain.ts @@ -0,0 +1,97 @@ +import { Command } from "commander"; +import { clearKeychainSecret, getKeychainSecret, listKeychainSecrets, setKeychainSecret } from "../data/keychain.ts"; +import { output, outputError } from "../utils/output.ts"; + +type Format = "json" | "table" | "auto"; + +function getFormat(cmd: Command): Format { + let parent: Command | null = cmd; + while (parent) { + const opts = parent.opts(); + if (opts.output) return opts.output as Format; + parent = parent.parent; + } + return "auto"; +} + +export function createKeychainCommand(): Command { + const keychain = new Command("keychain").description("Manage OS keychain secrets used by sunat-cli."); + + keychain + .command("set") + .description("Store a secret in the OS keychain.") + .argument("", "Secret env var name, e.g. CPE_CERT_PASSWORD") + .requiredOption("--value ", "Secret value") + .action((key, opts, cmd) => { + const format = getFormat(cmd); + try { + setKeychainSecret(key, opts.value); + output(format, { + json: { success: true, key }, + table: { headers: ["Key", "Status"], rows: [[key, "stored"]] }, + }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + keychain + .command("get") + .description("Read a secret from the OS keychain.") + .argument("", "Secret env var name, e.g. CPE_CERT_PASSWORD") + .action((key, _opts, cmd) => { + const format = getFormat(cmd); + try { + const value = getKeychainSecret(key); + if (!value) { + outputError(`${key} not found in keychain.`, format); + return; + } + if (format === "json") { + output(format, { json: { key, value } }); + } else { + console.log(value); + } + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + keychain + .command("list") + .description("List supported secret keys and whether they exist in the OS keychain.") + .action((_, cmd) => { + const format = getFormat(cmd); + try { + const entries = listKeychainSecrets(); + output(format, { + json: entries, + table: { + headers: ["Key", "Status"], + rows: entries.map((entry) => [entry.key, entry.exists ? "stored" : "missing"]), + }, + }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + keychain + .command("clear") + .description("Remove a secret from the OS keychain.") + .argument("", "Secret env var name, e.g. CPE_CERT_PASSWORD") + .action((key, _opts, cmd) => { + const format = getFormat(cmd); + try { + const cleared = clearKeychainSecret(key); + output(format, { + json: { success: true, key, cleared }, + table: { headers: ["Key", "Status"], rows: [[key, cleared ? "cleared" : "missing"]] }, + }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + return keychain; +} diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 217b821..24ff4bf 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -3,6 +3,7 @@ import { loadConfig, saveConfig, ensureDirs } from "../data/config.ts"; import { loginSOL, loginNuevaPlataforma } from "../browser/auth.ts"; import { outputSuccess, outputError } from "../utils/output.ts"; import { audit } from "../data/audit.ts"; +import { resolveSecret } from "../data/keychain.ts"; import { isSkillInstalled, installSkill } from "../utils/skill.ts"; import * as p from "@clack/prompts"; @@ -17,14 +18,14 @@ async function getOrPromptCredentials(opts: LoginOpts, isTTY: boolean): Promise< const config = loadConfig(); let ruc = opts.ruc || process.env.SUNAT_RUC || config.ruc; let usuario = opts.user || process.env.SUNAT_USER || config.usuario; - let password = opts.password || process.env.SUNAT_PASSWORD; + let password = opts.password || resolveSecret(["SUNAT_PASSWORD"]); if (ruc && usuario && password) { return { ruc, usuario, password }; } if (!isTTY) { - throw new Error("Missing credentials. Pass --ruc, --user, --password flags or set SUNAT_RUC, SUNAT_USER, SUNAT_PASSWORD env vars"); + throw new Error("Missing credentials. Pass --ruc, --user, --password flags, set SUNAT_RUC, SUNAT_USER, SUNAT_PASSWORD env vars, or store SUNAT_PASSWORD with sunat keychain set"); } p.intro("sunat login -- first time setup"); diff --git a/packages/cli/src/commands/sire/index.ts b/packages/cli/src/commands/sire/index.ts index c48893f..18116d5 100644 --- a/packages/cli/src/commands/sire/index.ts +++ b/packages/cli/src/commands/sire/index.ts @@ -3,6 +3,7 @@ import { writeFileSync } from "fs"; import { audit } from "../../data/audit.ts"; import { readFileSync as readFile, statSync as fileStat } from "fs"; import { basename } from "path"; +import { missingSecretMessage, resolveSecret } from "../../data/keychain.ts"; import { COD_LIBRO, type CodLibro, @@ -33,15 +34,15 @@ function getFormat(cmd: Command): Format { function resolveSireCreds(): ReturnType { const clientId = process.env.SUNAT_API_CLIENT_ID; - const clientSecret = process.env.SUNAT_API_CLIENT_SECRET; + const clientSecret = resolveSecret(["SUNAT_API_CLIENT_SECRET"]); const ruc = process.env.SUNAT_RUC || process.env.CPE_EMISOR_RUC; const solUsuario = process.env.SUNAT_USER || process.env.CPE_SOL_USUARIO; - const solPassword = process.env.SUNAT_PASSWORD || process.env.CPE_SOL_PASSWORD; + const solPassword = resolveSecret(["SUNAT_PASSWORD", "CPE_SOL_PASSWORD"]); if (!clientId) throw new Error("SUNAT_API_CLIENT_ID env var missing (from SOL → Credenciales API SUNAT, MIGE RCE y RVIE - SIRE)"); - if (!clientSecret) throw new Error("SUNAT_API_CLIENT_SECRET env var missing"); + if (!clientSecret) throw new Error(missingSecretMessage(["SUNAT_API_CLIENT_SECRET"], "SUNAT_API_CLIENT_SECRET")); if (!ruc) throw new Error("SUNAT_RUC env var missing"); if (!solUsuario) throw new Error("SUNAT_USER env var missing (SOL usuario, NOT the password)"); - if (!solPassword) throw new Error("SUNAT_PASSWORD env var missing (Clave SOL)"); + if (!solPassword) throw new Error(missingSecretMessage(["SUNAT_PASSWORD", "CPE_SOL_PASSWORD"], "Clave SOL")); return sireCredentials({ clientId, clientSecret, ruc, solUsuario, solPassword }); } diff --git a/packages/cli/src/cpe/config.ts b/packages/cli/src/cpe/config.ts index ba1eaf3..9329d62 100644 --- a/packages/cli/src/cpe/config.ts +++ b/packages/cli/src/cpe/config.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; import { paths } from "../data/config.ts"; +import { missingSecretMessage, resolveSecret } from "../data/keychain.ts"; export interface CpeEmisor { ruc: string; @@ -66,14 +67,14 @@ export function resolveCpeContext(profileName?: string): ResolvedCpeContext { const mode = (process.env.CPE_MODE || profile?.mode || "beta") as "beta" | "prod"; const certPath = process.env.CPE_CERT_PATH || profile?.certPath; - const certPassword = process.env.CPE_CERT_PASSWORD; + const certPassword = resolveSecret(["CPE_CERT_PASSWORD"]); const solUsuario = process.env.CPE_SOL_USUARIO || process.env.SUNAT_USER || profile?.solUsuario; - const solPassword = process.env.CPE_SOL_PASSWORD || process.env.SUNAT_PASSWORD; + const solPassword = resolveSecret(["CPE_SOL_PASSWORD", "SUNAT_PASSWORD"]); if (!certPath) throw new Error("Certificate not configured. Set CPE_CERT_PATH env var (path to .pfx)."); - if (!certPassword) throw new Error("Certificate password missing. Set CPE_CERT_PASSWORD env var."); + if (!certPassword) throw new Error(missingSecretMessage(["CPE_CERT_PASSWORD"], "Certificate password")); if (!solUsuario) throw new Error("SOL user missing. Set CPE_SOL_USUARIO or SUNAT_USER env var."); - if (!solPassword) throw new Error("SOL password missing. Set CPE_SOL_PASSWORD or SUNAT_PASSWORD env var."); + if (!solPassword) throw new Error(missingSecretMessage(["CPE_SOL_PASSWORD", "SUNAT_PASSWORD"], "SOL password")); return { emisor: { diff --git a/packages/cli/src/cpe/oauth-config.ts b/packages/cli/src/cpe/oauth-config.ts index 197c07e..3470881 100644 --- a/packages/cli/src/cpe/oauth-config.ts +++ b/packages/cli/src/cpe/oauth-config.ts @@ -7,11 +7,12 @@ */ import type { OAuthCredentials } from "../sunat-rest/oauth.ts"; +import { missingSecretMessage, resolveSecret } from "../data/keychain.ts"; export function resolveOAuthCredentials(): OAuthCredentials { const clientId = process.env.SUNAT_API_CLIENT_ID; - const clientSecret = process.env.SUNAT_API_CLIENT_SECRET; + const clientSecret = resolveSecret(["SUNAT_API_CLIENT_SECRET"]); if (!clientId) throw new Error("SUNAT_API_CLIENT_ID env var missing. Get from SOL → Mi RUC → Credenciales API."); - if (!clientSecret) throw new Error("SUNAT_API_CLIENT_SECRET env var missing."); + if (!clientSecret) throw new Error(missingSecretMessage(["SUNAT_API_CLIENT_SECRET"], "SUNAT_API_CLIENT_SECRET")); return { clientId, clientSecret }; } diff --git a/packages/cli/src/data/config.ts b/packages/cli/src/data/config.ts index 153605e..f437ce2 100644 --- a/packages/cli/src/data/config.ts +++ b/packages/cli/src/data/config.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; +import { missingSecretMessage, resolveSecret } from "./keychain.ts"; const SUNAT_DIR = join(process.env.HOME || "", ".sunat"); const CONFIG_FILE = join(SUNAT_DIR, "config.json"); @@ -35,11 +36,11 @@ export function getCredentials(): { ruc: string; usuario: string; password: stri const config = loadConfig(); const ruc = process.env.SUNAT_RUC || config.ruc; const usuario = process.env.SUNAT_USER || config.usuario; - const password = process.env.SUNAT_PASSWORD; + const password = resolveSecret(["SUNAT_PASSWORD"]); if (!ruc) throw new Error("RUC not configured. Set SUNAT_RUC env var or run: sunat config set ruc "); if (!usuario) throw new Error("Usuario not configured. Set SUNAT_USER env var or run: sunat config set usuario "); - if (!password) throw new Error("Password not configured. Set SUNAT_PASSWORD env var"); + if (!password) throw new Error(missingSecretMessage(["SUNAT_PASSWORD"], "Password")); return { ruc, usuario, password }; } @@ -47,10 +48,10 @@ export function getCredentials(): { ruc: string; usuario: string; password: stri export function getApiCredentials(): { clientId: string; clientSecret: string } { const config = loadConfig(); const clientId = process.env.SUNAT_API_CLIENT_ID || config.apiClientId; - const clientSecret = process.env.SUNAT_API_CLIENT_SECRET || config.apiClientSecret; + const clientSecret = resolveSecret(["SUNAT_API_CLIENT_SECRET"]); if (!clientId || !clientSecret) { - throw new Error("API credentials not configured. Set SUNAT_API_CLIENT_ID and SUNAT_API_CLIENT_SECRET env vars"); + throw new Error("API credentials not configured. Set SUNAT_API_CLIENT_ID env var and SUNAT_API_CLIENT_SECRET env var or keychain secret"); } return { clientId, clientSecret }; diff --git a/packages/cli/src/data/keychain.ts b/packages/cli/src/data/keychain.ts new file mode 100644 index 0000000..9607bca --- /dev/null +++ b/packages/cli/src/data/keychain.ts @@ -0,0 +1,113 @@ +import { execFileSync } from "node:child_process"; + +export const KEYCHAIN_SERVICE = "sunat-cli"; + +export const SUPPORTED_SECRET_KEYS = [ + "CPE_CERT_PASSWORD", + "CPE_SOL_PASSWORD", + "SUNAT_PASSWORD", + "SUNAT_API_CLIENT_SECRET", + "SUNAT_GRE_CLIENT_SECRET", +] as const; + +export interface KeychainEntry { + key: string; + exists: boolean; +} + +function assertSecretKey(key: string): void { + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) throw new Error(`Invalid secret key "${key}". Use an env-var-style name.`); +} + +function commandUnavailable(err: unknown): boolean { + const code = typeof err === "object" && err && "status" in err ? (err as { status?: number }).status : undefined; + return code === 1 || code === 44 || code === 45; +} + +function run(command: string, args: string[], input?: string): string { + return execFileSync(command, args, { + encoding: "utf-8", + input, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function platformName(): string { + if (process.platform === "darwin") return "macOS Keychain"; + if (process.platform === "linux") return "Linux Secret Service"; + if (process.platform === "win32") return "Windows credential storage"; + return process.platform; +} + +export function keychainBackend(): "macos" | "linux" | "unsupported" { + if (process.platform === "darwin") return "macos"; + if (process.platform === "linux") return "linux"; + return "unsupported"; +} + +export function setKeychainSecret(key: string, value: string): void { + assertSecretKey(key); + if (!value) throw new Error("Secret value cannot be empty."); + const backend = keychainBackend(); + if (backend === "macos") { + run("security", ["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", key, "-w", value]); + return; + } + if (backend === "linux") { + run("secret-tool", ["store", "--label", `${KEYCHAIN_SERVICE} ${key}`, "service", KEYCHAIN_SERVICE, "account", key], value); + return; + } + throw new Error(`${platformName()} is not supported yet.`); +} + +export function getKeychainSecret(key: string): string | undefined { + assertSecretKey(key); + const backend = keychainBackend(); + try { + if (backend === "macos") return run("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", key, "-w"]) || undefined; + if (backend === "linux") return run("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", key]) || undefined; + return undefined; + } catch (err) { + if (commandUnavailable(err)) return undefined; + throw new Error(`Could not read ${key} from ${platformName()}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export function clearKeychainSecret(key: string): boolean { + assertSecretKey(key); + const backend = keychainBackend(); + try { + if (backend === "macos") { + run("security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", key]); + return true; + } + if (backend === "linux") { + run("secret-tool", ["clear", "service", KEYCHAIN_SERVICE, "account", key]); + return true; + } + throw new Error(`${platformName()} is not supported yet.`); + } catch (err) { + if (commandUnavailable(err)) return false; + throw new Error(`Could not clear ${key} from ${platformName()}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export function listKeychainSecrets(keys: readonly string[] = SUPPORTED_SECRET_KEYS): KeychainEntry[] { + return keys.map((key) => ({ key, exists: getKeychainSecret(key) !== undefined })); +} + +export function resolveSecret(envNames: readonly string[]): string | undefined { + for (const name of envNames) { + const value = process.env[name]; + if (value) return value; + } + for (const name of envNames) { + const value = getKeychainSecret(name); + if (value) return value; + } + return undefined; +} + +export function missingSecretMessage(envNames: readonly string[], label = "Secret"): string { + return `${label} missing. Set ${envNames.join(" or ")} env var, or store it with: sunat keychain set ${envNames[0]} --value `; +}