Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/bin/sunat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand Down
23 changes: 21 additions & 2 deletions packages/cli/skills/sunat-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <KEY> -w <VALUE>`.
Linux stores secrets through `secret-tool` / libsecret.

### RHE (Recibo por Honorarios)

```bash
Expand Down Expand Up @@ -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),
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/src/commands/cpe/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>, result: "dry-run", details: { hash: result.hash } });
output(format, { json: { dryRun: true, ...result } });
} catch (err) {
Expand All @@ -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<string, unknown>, result: "dry-run" });
output(format, { json: { dryRun: true, ...preview } });
return;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/commands/keychain.ts
Original file line number Diff line number Diff line change
@@ -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("<key>", "Secret env var name, e.g. CPE_CERT_PASSWORD")
.requiredOption("--value <secret>", "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("<key>", "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("<key>", "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;
}
5 changes: 3 additions & 2 deletions packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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");
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/sire/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -33,15 +34,15 @@ function getFormat(cmd: Command): Format {

function resolveSireCreds(): ReturnType<typeof sireCredentials> {
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 });
}

Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/cpe/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/cpe/oauth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
9 changes: 5 additions & 4 deletions packages/cli/src/data/config.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -35,22 +36,22 @@ 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 <value>");
if (!usuario) throw new Error("Usuario not configured. Set SUNAT_USER env var or run: sunat config set usuario <value>");
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 };
}

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 };
Expand Down
Loading
Loading