← back to Secrets Manager

cli.js

687 lines

#!/usr/bin/env node
// Secrets Manager CLI — single source of truth for Steve's API tokens.
//
// Usage:
//   node cli.js list
//   node cli.js add <KEY> <VALUE>
//   node cli.js import-paste              # reads KEY=VALUE block from stdin
//   node cli.js check <KEY>
//   node cli.js verify-all               # verify configured keys; write data/latest.json
//   node cli.js sync                      # re-fan master → all destinations
//   node cli.js audit                     # scan home for leaked secrets
//
// No deps. Pure node, https module, fs, child_process for grep.
const fs = require('fs');
const path = require('path');
const os = require('os');
const https = require('https');
const crypto = require('crypto');
const { execSync } = require('child_process');

const { spawnSync } = require('child_process');

const ROOT = path.dirname(__filename);
const HOME = os.homedir();
const ROUTES = JSON.parse(fs.readFileSync(path.join(ROOT, 'routes.json'), 'utf8'));
// routes.json carries some routes under .services AND some at top-level (legacy).
// cli.js historically read ONLY .services[key], so ~22 top-level routes
// (PG_DW_ADMIN_PASSWORD, SHOPIFY_ORDERS_TOKEN, TWILIO_*, GOOGLE_DRIVE_*, the
// ANTHROPIC_* keys…) were INVISIBLE to the fan — fanning them wrote only
// master+desktop and silently skipped every destination. routeFor() falls back
// to the top-level entry so all routes fan. (Root cause of the 2026-06-03
// dw_admin rotation half-fire.) Prefer .services on name clash.
function routeFor(key) { return (ROUTES.services && ROUTES.services[key]) || ROUTES[key] || null; }
// DB-password keys whose consumers connect via a full DSN, not a bare var — the
// fan must rewrite the password INSIDE that DSN in each env_file dest.
const DSN_REWRITE = {
  // Superset fix (TK-10045, 2026-07-30): the master .env uses DW_ADMIN_PG_PASSWORD /
  // DW_ADMIN_DB_PASSWORD, NOT PG_DW_ADMIN_PASSWORD — so a DB rotation pasted under the
  // real key names never rewrote the downstream DATABASE_URL DSN (the 2026-06-03
  // half-fire). Map ALL three names → so whichever var Steve fills also rewrites the DSN.
  PG_DW_ADMIN_PASSWORD: { dsn: 'DATABASE_URL', user: 'dw_admin' }, // legacy name (kept; harmless if unset)
  DW_ADMIN_PG_PASSWORD: { dsn: 'DATABASE_URL', user: 'dw_admin' },
  DW_ADMIN_DB_PASSWORD: { dsn: 'DATABASE_URL', user: 'dw_admin' },
};
const MASTER_ENV = path.join(ROOT, '.env');
// DESKTOP_ENV (~/Desktop/site-factory.env) retired 2026-06-11 (DTD verdict A) —
// no longer a fan-out destination. See fanOut() for rationale.
const REGISTRY = path.join(ROOT, 'registry.json');

// ─── helpers ────────────────────────────────────────────────────────────
function expand(p) { return p.replace(/^~/, HOME); }
function digest(v) { return v.slice(-4) + ':' + crypto.createHash('sha256').update(v).digest('hex').slice(0, 8); }
function loadRegistry() { return fs.existsSync(REGISTRY) ? JSON.parse(fs.readFileSync(REGISTRY, 'utf8')) : { secrets: {} }; }
function saveRegistry(r) { fs.writeFileSync(REGISTRY, JSON.stringify(r, null, 2)); }

// ─── least-privilege manifests (TK-10045) ────────────────────────────────
// manifests.json (values-free, built by derive-manifests.js) maps each project
// dir → the master-secret keys its CODE references. Used by `regen` to write a
// scoped .env, and by the manifest-aware fanOut guard to stop a rotation from
// silently re-broadening a scoped .env (the silent-decay trap).
const MANIFESTS_PATH = path.join(ROOT, 'manifests.json');
function loadManifests() {
  if (!fs.existsSync(MANIFESTS_PATH)) return { base_keys: [], projects: {} };
  return JSON.parse(fs.readFileSync(MANIFESTS_PATH, 'utf8'));
}
// Look up the manifest entry that owns a given .env file path (by its dir).
function manifestForEnvPath(man, absEnvPath) {
  const dirTilde = path.dirname(absEnvPath).replace(HOME, '~');
  return man.projects[dirTilde] || null;
}
// Fan-out guard: should we SKIP writing `key` to the .env at `absPath`?
// Only active when SCOPED_FANOUT=1 (default OFF → behavior unchanged until Steve
// enables scoping fleet-wide). NEVER skips a review_required/dynamic project (its
// manifest is known-incomplete) or an unknown path (fail-open — fan everything).
// Skips only for a narrowable project whose manifest does not list the key.
let _MAN_CACHE = null;
function scopedSkip(absPath, key) {
  if (process.env.SCOPED_FANOUT !== '1') return false;
  // NEVER skip a key that drives a DSN (e.g. PG_DW_ADMIN_PASSWORD → DATABASE_URL):
  // consumers read the DSN, not the bare var, so the manifest won't list the key —
  // skipping would drop the DSN password rewrite and crash the app on next restart
  // (contrarian Hole 2, reproduced; the exact 2026-06-03 half-fire shape).
  if (DSN_REWRITE[key]) return false;
  if (!_MAN_CACHE) _MAN_CACHE = loadManifests();
  const e = manifestForEnvPath(_MAN_CACHE, absPath);
  if (!e || e.review_required || e.missing_dir) return false;   // fail-open
  const allow = new Set([...(e.derived || []), ...(_MAN_CACHE.base_keys || [])]);
  if (allow.has(key)) return false;
  console.error(`  ⚠ scoped-skip ${key} → ${absPath.replace(HOME,'~')} (not in manifest)`);
  return true;
}
function loadEnvFile(p) {
  if (!fs.existsSync(p)) return {};
  const out = {};
  for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
    const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
    if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
  }
  return out;
}
// dotenv treats `#` as a comment delimiter unless the value is quoted, and
// chokes on space-edge values + literal newlines. Auto-quote anything that
// would parse short. Bug: previously a sync stripped quotes off
// MAILING_ADDRESS="…#102…" → dotenv parsed only "Designer Wallcoverings…Bl "
// and the CAN-SPAM compliance gate fail-closed in production.
function envEscape(v) {
  const s = String(v);
  if (s === '') return '';
  // Any whitespace requires quoting for shell-source consumers. Without this, an Authorization
  // value such as `Basic abc...` is parsed by zsh as IG_AGENT_AUTH=Basic plus a command named
  // `abc...`, silently exporting only "Basic" while Node's dotenv parser still appears healthy.
  if (/[#"'\n\r\s]/.test(s)) return '"' + s.replace(/(["\\])/g, '\\$1') + '"';
  return s;
}
function writeEnvFile(p, kv, preserveComments = true) {
  fs.mkdirSync(path.dirname(p), { recursive: true });
  let body = '';
  if (preserveComments && fs.existsSync(p)) {
    // Preserve existing lines, replacing matching keys
    const seen = new Set();
    for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
      const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
      if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
      else body += line + '\n';
    }
    for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
    body = body.replace(/\n+$/, '\n');
  } else {
    for (const [k, v] of Object.entries(kv)) body += `${k}=${envEscape(v)}\n`;
  }
  // Backup-before-overwrite guard (2026-08-11): never destroy a prior secret value in
  // place. If the file exists and its content is ACTUALLY changing, snapshot it to a
  // timestamped .bak first (0600), then prune to the last 5 for this file. Born from the
  // ZENDESK_CHAT_CLIENT_SECRET incident — an `add` overwrote a value with no recovery path.
  if (fs.existsSync(p)) {
    const prev = fs.readFileSync(p, 'utf8');
    if (prev !== body) {
      const bak = `${p}.bak.${Date.now()}`;
      try {
        fs.copyFileSync(p, bak); fs.chmodSync(bak, 0o600);
        const dir = path.dirname(p), base = path.basename(p) + '.bak.';
        const olds = fs.readdirSync(dir).filter(f => f.startsWith(base)).sort();
        for (const o of olds.slice(0, -5)) { try { fs.unlinkSync(path.join(dir, o)); } catch {} }
      } catch {}
    }
  }
  fs.writeFileSync(p, body);
  try { fs.chmodSync(p, 0o600); } catch {}
}
// Rewrite the password segment of a DSN var (e.g. DATABASE_URL=postgres://user:PW@host)
// inside an env-file body, for the given DB user. The new pw is URL-encoded so base64
// chars (+ / =) don't corrupt the URL userinfo. Returns {body, changed}.
//
// WHY this exists: dw_admin consumers connect via a full DATABASE_URL DSN, NOT a bare
// *_PASSWORD var. A fan that only sets the bare var leaves DATABASE_URL holding the OLD
// pw → restart → FATAL auth. That was the 2026-06-03 rotation half-fire. The pg-rotation
// route now carries `dsn: "DATABASE_URL"` so the fan rewrites the live DSN too.
function rewriteDsnPassword(text, dsnVar, dbUser, newPw) {
  if (!text) return { body: text, changed: 0 };
  const enc = encodeURIComponent(newPw);
  let changed = 0;
  const re = new RegExp('^(\\s*' + dsnVar + '\\s*=\\s*["\']?)(postgres(?:ql)?:\\/\\/' + dbUser + ':)([^@]*)(@)', 'i');
  const body = text.split('\n').map((line) => {
    const m = line.match(re);
    if (m) { changed++; return m[1] + m[2] + enc + m[4] + line.slice(m[0].length); }
    return line;
  }).join('\n');
  return { body, changed };
}

// Remote env via SSH. Hosts must have SSH key auth set up; we never prompt
// for a password (BatchMode=yes). Connect timeout caps stalls at 8s.
function readRemoteEnvFile(user, host, p) {
  const r = spawnSync('ssh', [
    '-o', 'BatchMode=yes',
    '-o', 'ConnectTimeout=8',
    '-o', 'StrictHostKeyChecking=accept-new',
    `${user}@${host}`,
    `cat ${p} 2>/dev/null || true`,
  ], { encoding: 'utf8' });
  return r.status === 0 ? (r.stdout || '') : '';
}
function parseEnvString(s) {
  const out = {};
  for (const line of s.split('\n')) {
    const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
    if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
  }
  return out;
}
function serializeEnvBody(existingText, kv) {
  // Preserve comments + ordering, replace matching keys, append new ones.
  // envEscape() handles `#`/quote/space-edge values so dotenv parses them whole.
  let body = '';
  const seen = new Set();
  if (existingText) {
    for (const line of existingText.split('\n')) {
      const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
      if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
      else body += line + '\n';
    }
  }
  for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
  return body.replace(/\n+$/, '\n');
}
function writeRemoteEnvFile(user, host, p, body) {
  // Use ssh + tee with stdin so we never pass the secret on the command line.
  // Remote dir is ensured first; permissions tightened to 600 after write.
  const dir = p.replace(/\/[^/]+$/, '') || '.';
  const remoteCmd = `mkdir -p '${dir}' && cat > '${p}' && chmod 600 '${p}'`;
  const r = spawnSync('ssh', [
    '-o', 'BatchMode=yes',
    '-o', 'ConnectTimeout=8',
    '-o', 'StrictHostKeyChecking=accept-new',
    `${user}@${host}`,
    remoteCmd,
  ], { input: body, encoding: 'utf8' });
  if (r.status !== 0) {
    throw new Error(`ssh write failed (${user}@${host}:${p}): ${(r.stderr || '').slice(0, 200)}`);
  }
}

function httpsReq(url, opts = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(url);
    const req = https.request({
      hostname: u.hostname, path: u.pathname + u.search, method: opts.method || 'GET',
      headers: opts.headers || {},
    }, res => {
      let body = '';
      res.on('data', c => body += c);
      res.on('end', () => resolve({ status: res.statusCode, body }));
    });
    req.on('error', reject);
    if (opts.body) req.write(opts.body);
    req.end();
  });
}

// ─── verify ─────────────────────────────────────────────────────────────
async function verifyToken(key, value) {
  const cfg = routeFor(key)?.verify;
  if (!cfg) return { ok: true, skipped: true };
  const headers = {};
  if (cfg.auth === 'bearer') headers['Authorization'] = `Bearer ${value}`;
  if (cfg.auth === 'basic-sk') headers['Authorization'] = 'Basic ' + Buffer.from(value + ':').toString('base64');
  if (cfg.auth === 'x-api-key') { headers['x-api-key'] = value; headers['anthropic-version'] = '2023-06-01'; }
  // generic 'header' mode: caller specifies cfg.headerName (e.g. 'xi-api-key' for ElevenLabs)
  if (cfg.auth === 'header' && cfg.headerName) headers[cfg.headerName] = value;
  if (cfg.body) headers['Content-Type'] = 'application/json';
  // 'url' / 'query' auth: substitute {value} in the URL (for APIs that take the key as a query param)
  const url = (cfg.auth === 'url' || cfg.auth === 'query')
    ? cfg.url.replace('{value}', encodeURIComponent(value))
    : cfg.url;
  try {
    const r = await httpsReq(url, { method: cfg.method, headers, body: cfg.body });
    // Anthropic-specific: 400 with "credit balance" means the key auth'd but the account is out of credits.
    // That's still a valid key; persist but flag with status.
    const creditOk = r.status === 400 && /credit balance|insufficient/i.test(r.body);
    // Some providers auth the key but reject the probe's minimal body with a
    // schema/validation status (e.g. typesafe.ai returns 422 "Field required"
    // on an empty body while a bad key would 401). A route may whitelist such
    // statuses via cfg.okStatuses, but okStatuses now REQUIRES a paired
    // cfg.bodyMustContain to take effect — a whitelisted non-2xx status can
    // never alone certify a key; the positive body proof must also match.
    const okStatus = Array.isArray(cfg.okStatuses) && cfg.okStatuses.includes(r.status) && !!cfg.bodyMustContain;
    let httpOk = (r.status >= 200 && r.status < 300) || creditOk || okStatus;
    // Body-content guards (e.g. Purelymail returns HTTP 200 + {"type":"error"} on bad token).
    // Cap the probe at 4096 bytes to bound regex worst-case.
    const probe = (r.body || '').slice(0, 4096);
    if (httpOk && cfg.bodyMustNotContain && new RegExp(cfg.bodyMustNotContain, 'i').test(probe)) httpOk = false;
    if (httpOk && cfg.bodyMustContain && !new RegExp(cfg.bodyMustContain, 'i').test(probe)) httpOk = false;
    return { ok: httpOk, status: r.status, body: r.body.slice(0, 200) };
  } catch (e) {
    return { ok: false, error: e.message };
  }
}

function configuredSecretKeys(routes = ROUTES) {
  const isSecretKey = (key) => /^[A-Z][A-Z0-9_]*$/.test(key);
  const keys = new Set(Object.keys(routes.services || {}).filter(isSecretKey));
  for (const [key, value] of Object.entries(routes)) {
    if (isSecretKey(key) && value && typeof value === 'object') keys.add(key);
  }
  return [...keys].sort();
}

async function buildVerifyAllReport({ master, routes = ROUTES, verifier = verifyToken, now = new Date(), timeoutMs = 10000 }) {
  const checks = [];
  const lookup = (key) => (routes.services && routes.services[key]) || routes[key] || null;
  for (const key of configuredSecretKeys(routes)) {
    const cfg = lookup(key);
    const value = master[key];
    if (typeof value !== 'string' || !value.trim()) {
      checks.push({ key, outcome: cfg?.verify ? 'FAIL' : 'WARN', reason: 'missing-from-master' });
      continue;
    }
    if (!cfg?.verify) {
      checks.push({ key, outcome: 'WARN', reason: 'no-verify-endpoint' });
      continue;
    }
    const result = await boundedVerify(verifier, key, value, timeoutMs);
    checks.push(result.ok
      ? { key, outcome: 'PASS', http_status: result.status || null }
      : { key, outcome: 'FAIL', http_status: result.status || null, reason: result.reason || 'provider-rejected' });
  }
  const counts = checks.reduce((acc, check) => { acc[check.outcome]++; return acc; }, { PASS: 0, WARN: 0, FAIL: 0 });
  return {
    schema_version: 1,
    generated_at: now.toISOString(),
    status: counts.FAIL ? 'FAIL' : counts.WARN ? 'WARN' : 'PASS',
    summary: { total: checks.length, ...counts },
    checks,
  };
}

async function boundedVerify(verifier, key, value, timeoutMs = 10000) {
  const safeTimeoutMs = Number.isSafeInteger(timeoutMs) && timeoutMs >= 1 && timeoutMs <= 60000
    ? timeoutMs
    : 10000;
  let timer;
  const timeout = new Promise((resolve) => {
    timer = setTimeout(() => resolve({ ok: false, reason: 'timeout' }), safeTimeoutMs);
  });
  try {
    const result = await Promise.race([
      Promise.resolve().then(() => verifier(key, value)).catch(() => ({ ok: false, reason: 'verifier-error' })),
      timeout,
    ]);
    if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
      return { ok: false, reason: 'malformed-result' };
    }
    const status = Number.isInteger(result.status) && result.status >= 100 && result.status <= 599
      ? result.status
      : null;
    if (result.ok) return { ok: true, status };
    const reason = result.reason === 'timeout' || result.reason === 'verifier-error'
      ? result.reason
      : result.error ? 'network-error' : 'provider-rejected';
    return { ok: false, status, reason };
  } finally {
    clearTimeout(timer);
  }
}

async function cmdVerifyAll(options = {}) {
  const report = await buildVerifyAllReport({
    master: options.master || loadEnvFile(MASTER_ENV),
    routes: options.routes || ROUTES,
    verifier: options.verifier || verifyToken,
    now: options.now || new Date(),
    timeoutMs: options.timeoutMs,
  });
  const outputPath = options.outputPath || path.join(ROOT, 'data', 'latest.json');
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
  const tempPath = `${outputPath}.tmp-${process.pid}`;
  fs.writeFileSync(tempPath, JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
  fs.chmodSync(tempPath, 0o600);
  fs.renameSync(tempPath, outputPath);
  fs.chmodSync(outputPath, 0o600);
  console.log(`verify-all ${report.status} — PASS=${report.summary.PASS} WARN=${report.summary.WARN} FAIL=${report.summary.FAIL}`);
  console.log(`report: ${outputPath}`);
  return report;
}

// ─── fan-out ────────────────────────────────────────────────────────────
function fanOut(key, value) {
  const destinations = routeFor(key)?.destinations || [];
  const written = [];

  // Master always
  const master = loadEnvFile(MASTER_ENV); master[key] = value;
  writeEnvFile(MASTER_ENV, master, false); written.push(MASTER_ENV);

  // Desktop master mirror REMOVED 2026-06-11 (DTD verdict A): the Desktop is a
  // high-exposure location (Time Machine / iCloud Desktop-sync / screenshots),
  // chmod 600 gives no protection against code running as the user (the
  // prompt-injection threat that prompted this), and the mirror was pure
  // redundancy with MASTER_ENV. Canonical master at ~/Projects/secrets-manager/.env
  // remains the single source of truth. Do NOT reintroduce a Desktop copy.

  for (const d of destinations) {
    if (d.type === 'project') {
      // Local project (no host) — original behavior.
      // Remote project (d.host set) — push via SSH; failures log + skip,
      // they don't blow up the rest of the fan-out.
      if (d.host) {
        const user = d.user || 'root';
        try {
          const existing = readRemoteEnvFile(user, d.host, d.path);
          const cur = parseEnvString(existing); cur[key] = value;
          const body = serializeEnvBody(existing, cur);
          writeRemoteEnvFile(user, d.host, d.path, body);
          written.push(`ssh:${user}@${d.host}:${d.path}`);
        } catch (e) {
          console.error(`  ⚠ remote skip ${user}@${d.host}:${d.path} — ${e.message}`);
        }
      } else {
        const p = expand(d.path);
        if (scopedSkip(p, key)) continue;
        const cur = loadEnvFile(p); cur[key] = value;
        writeEnvFile(p, cur, true); written.push(p);
      }
    } else if (d.type === 'env_file') {
      // Local env file. Sets the bare key, AND — when this key feeds a DSN
      // (per-dest `d.dsn`, or the code-driven DSN_REWRITE map) — surgically
      // rewrites the password inside that DSN var (e.g. DATABASE_URL) for the
      // DB user. The dw_admin rotation path NEEDS this: consumers read
      // DATABASE_URL, not the bare *_PASSWORD var. (Pre-2026-06-09 the fan had
      // no env_file branch at all → these dests were silently skipped.)
      const p = expand(d.path);
      if (!fs.existsSync(p)) { console.error(`  ⚠ env_file skip (missing): ${p}`); continue; }
      if (!d.dsn && scopedSkip(p, key)) continue;   // never skip a per-dest DSN rewrite
      let text = fs.readFileSync(p, 'utf8');
      let note = '';
      const dsnSpec = d.dsn ? { dsn: d.dsn, user: d.dsnUser || 'dw_admin' } : DSN_REWRITE[key];
      if (dsnSpec) {
        const { body, changed } = rewriteDsnPassword(text, dsnSpec.dsn, dsnSpec.user, value);
        text = body;
        note = changed ? ` [DSN ${dsnSpec.dsn}×${changed}]` : ` [⚠ DSN ${dsnSpec.dsn} NOT FOUND]`;
      }
      // serializeEnvBody only rewrites keys present in the kv map, so the
      // already-rewritten DSN line is preserved verbatim (not re-parsed).
      const outKey = d.key || key; // honor per-destination key remap (e.g. SHOPIFY_ADMIN_TOKEN -> SHOPIFY_ADMIN_ACCESS_TOKEN)
      const finalBody = serializeEnvBody(text, d.dsnOnly ? {} : { [outKey]: value });
      fs.mkdirSync(path.dirname(p), { recursive: true });
      fs.writeFileSync(p, finalBody);
      try { fs.chmodSync(p, 0o600); } catch {}
      written.push(p + note);
    } else if (d.type === 'skill') {
      const p = path.join(HOME, '.claude/skills', d.name, '.env');
      if (scopedSkip(p, key)) continue;
      const cur = loadEnvFile(p); cur[key] = value;
      writeEnvFile(p, cur, true); written.push(p);
    } else if (d.type === 'site_local') {
      for (const dom of d.domains) {
        const p = expand(`~/Projects/site-factory/sites/${dom}/app/.env.local`);
        if (scopedSkip(p, key)) continue;
        const cur = loadEnvFile(p); cur[key] = value;
        writeEnvFile(p, cur, true); written.push(p);
      }
    } else if (d.type === 'mcp') {
      const cfgPath = path.join(HOME, '.claude.json');
      if (!fs.existsSync(cfgPath)) continue;
      const bak = `${cfgPath}.bak.${Date.now()}`;
      fs.copyFileSync(cfgPath, bak);
      // Prune stale secret-bearing backups — keep only the last 5. Each is a full
      // copy of ~/.claude.json (all MCP env secrets); 135 had accumulated in $HOME,
      // outside any audit/gitignore scope (contrarian Hole 3, reproduced).
      try {
        const baks = fs.readdirSync(HOME).filter(f => f.startsWith('.claude.json.bak.'))
          .sort().reverse();
        for (const old of baks.slice(5)) { try { fs.unlinkSync(path.join(HOME, old)); } catch {} }
      } catch {}
      const j = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
      const sv = (j.mcpServers && j.mcpServers[d.server]) || null;
      if (sv) {
        sv.env = sv.env || {};
        sv.env[d.key || key] = value;
        fs.writeFileSync(cfgPath, JSON.stringify(j, null, 2));
        written.push(`${cfgPath}#mcpServers.${d.server}.env.${d.key || key}`);
      }
    }
  }
  return written;
}

// ─── commands ───────────────────────────────────────────────────────────
async function cmdAdd(key, value) {
  if (!key || !value) { console.error('usage: add <KEY> <VALUE>'); process.exit(2); }
  const v = await verifyToken(key, value);
  if (!v.ok) {
    console.error(`✗ verify failed for ${key}: ${v.status || v.error || ''}`);
    if (v.body) console.error('  ' + v.body);
    process.exit(1);
  }
  const written = fanOut(key, value);
  const r = loadRegistry();
  r.secrets[key] = {
    digest: digest(value),
    label: routeFor(key)?.label || key,
    last_updated: new Date().toISOString(),
    validated: !v.skipped,
    verify_status: v.status || null,
    written_to: written,
  };
  saveRegistry(r);
  console.log(`[secrets-manager]`);
  console.log(`  service: ${key}`);
  console.log(`  action: add`);
  console.log(`  validated: ${v.skipped ? 'skipped (no verify endpoint)' : 'yes'}`);
  console.log(`  digest: …${r.secrets[key].digest}`);
  console.log(`  written_to: ${written.length} destinations`);
  for (const w of written) console.log(`    - ${w}`);
}

async function cmdImportPaste() {
  const stdin = fs.readFileSync(0, 'utf8');
  const lines = stdin.split('\n').map(l => l.trim()).filter(Boolean);
  const adds = [];
  for (const line of lines) {
    const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
    if (m && m[2] && !m[2].startsWith('#')) adds.push([m[1], m[2].replace(/^["']|["']$/g, '')]);
  }
  if (!adds.length) { console.error('no KEY=value lines found in stdin'); process.exit(2); }
  console.log(`importing ${adds.length} key(s): ${adds.map(([k]) => k).join(', ')}`);
  for (const [k, v] of adds) {
    try { await cmdAdd(k, v); } catch (e) { console.error(`  ${k}: ${e.message}`); }
  }
}

function cmdList() {
  const r = loadRegistry();
  console.log('Known services:');
  for (const k of Object.keys(ROUTES.services)) {
    const e = r.secrets[k];
    if (e) console.log(`  ✓ ${k.padEnd(28)} ${e.label.padEnd(28)} …${e.digest}  ${e.last_updated.slice(0,10)}`);
    else   console.log(`  ✗ ${k.padEnd(28)} ${ROUTES.services[k].label.padEnd(28)} <not set>`);
  }
}

async function cmdCheck(key) {
  if (!key) { console.error('usage: check <KEY>'); process.exit(2); }
  const master = loadEnvFile(MASTER_ENV);
  const v = master[key];
  if (!v) { console.error(`${key}: not in master`); process.exit(1); }
  const r = await verifyToken(key, v);
  console.log(`${key}: ${r.ok ? 'VALID' : 'INVALID'}  status=${r.status || r.error || 'n/a'}`);
}

function cmdSync() {
  const master = loadEnvFile(MASTER_ENV);
  let total = 0;
  // Iterate BOTH .services AND legacy top-level routed keys — cmdSync previously
  // only walked .services, so ~47 top-level routes (SHOPIFY_ORDERS_TOKEN, SPOONFLOWER_*,
  // GOOGLE_DRIVE_*, TWILIO_*, the OPENAI/ELEVENLABS legacy entries…) never re-fanned
  // on `sync` — the same .services-only blind spot that caused the 2026-06-03 half-fire.
  const META = new Set(['_comment', 'services', 'leak_patterns']);
  const topLevel = Object.keys(ROUTES).filter(k => !META.has(k) && ROUTES[k] && ROUTES[k].destinations);
  const allKeys = [...new Set([...Object.keys(ROUTES.services), ...topLevel])];
  for (const k of allKeys) {
    if (master[k]) total += fanOut(k, master[k]).length;
  }
  console.log(`sync: re-wrote ${total} destination entries from master`);
  // REMOTE fan-out is a SEPARATE, EXPLICIT step (never an automatic side-effect of sync):
  //   node scripts/push-remote.js        # SSH remote-routed secrets to their server(s) + pm2 reload
  // Kept opt-in on purpose — a routine local sync must not silently write to a prod host.
  if (fs.existsSync(path.join(ROOT, 'remote-routes.json'))) {
    console.log('sync: remote destinations exist — run `node scripts/push-remote.js` to push them to prod.');
  }
}

function cmdAudit() {
  const patterns = ROUTES.leak_patterns || [];
  const roots = [
    path.join(HOME, '.claude/skills'),
    path.join(HOME, '.claude/agents'),
    path.join(HOME, 'Projects'),
    path.join(HOME, 'cncp-starter'),
  ];
  const exts = '\\.(md|json|js|ts|tsx|sh|py|env|local|toml|yaml|yml)$';
  const findings = [];
  for (const root of roots) {
    if (!fs.existsSync(root)) continue;
    let files;
    try {
      files = execSync(
        `find "${root}" -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/dist/*" -not -path "*/.next/*" 2>/dev/null | grep -E '${exts}'`,
        { encoding: 'utf8', maxBuffer: 1024 * 1024 * 50 }
      ).split('\n').filter(Boolean);
    } catch { continue; }
    for (const f of files) {
      let text;
      try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
      // Skip our own .env files (they SHOULD have secrets, that's their job)
      if (f.endsWith('/.env') || f.endsWith('/.env.local')) continue;
      // Skip the master + registry (the manager itself)
      if (f === MASTER_ENV || f === REGISTRY || f === path.join(ROOT, 'routes.json')) continue;
      for (const p of patterns) {
        const re = new RegExp(p.regex, 'g');
        let m;
        while ((m = re.exec(text)) !== null) {
          const captured = m[1] || m[0];
          const line = text.slice(0, m.index).split('\n').length;
          findings.push({ file: f, line, pattern: p.name, last4: captured.slice(-4) });
        }
      }
    }
  }
  if (!findings.length) { console.log('audit: no leaked secrets found'); return; }
  console.log(`audit: ${findings.length} finding(s):`);
  for (const f of findings) console.log(`  ${f.pattern.padEnd(24)} ${f.file}:${f.line}  …${f.last4}`);
}

// ─── regen (least-privilege scoping, TK-10045) ───────────────────────────
// Writes each project a scoped .env = manifest ∩ master. Default --dry-run just
// reports the diff. --apply backs up every .env, REMOVES only master-secret keys
// the project's code doesn't reference (app-local non-secret vars are never
// touched — they're not in master), and REFUSES review_required/dynamic projects
// (their manifest is known-incomplete). --project=<~/Projects/x> scopes to one.
function cmdRegen(args) {
  const apply = args.includes('--apply');
  const addMissing = args.includes('--add-missing');   // opt-in: provision keys the code refs but .env lacks
  const projFilter = (args.find(a => a.startsWith('--project=')) || '').split('=')[1];
  const man = loadManifests();
  if (!Object.keys(man.projects || {}).length) { console.error('no manifests.json — run: node derive-manifests.js'); process.exit(2); }
  const master = loadEnvFile(MASTER_ENV);
  const masterKeys = new Set(Object.keys(master));
  const base = new Set(man.base_keys || []);
  const reg = loadRegistry(); reg.scoped = reg.scoped || {};
  const t = { projects: 0, remove: 0, add: 0, applied: 0, gatedReview: 0 };
  const rows = [];
  for (const [name, e] of Object.entries(man.projects)) {
    if (projFilter && name !== projFilter) continue;
    if (e.missing_dir) continue;
    const p = expand(e.env_path);
    if (p === MASTER_ENV) continue;                     // NEVER scope the master .env (would strip all 196)
    const current = loadEnvFile(p);
    const curKeys = new Set(Object.keys(current));
    const allow = new Set([...(e.derived || []), ...base]);
    // REMOVE = the blast-radius win: master keys in the .env the code never references.
    const removed = [...curKeys].filter(k => masterKeys.has(k) && !allow.has(k)).sort();
    // MISSING = advisory: code refs these master keys but the .env lacks them (service maybe
    // broken, or gets them elsewhere). NOT added unless --add-missing, because provisioning a
    // live secret into a new .env EXPANDS blast radius — the opposite of this pass's purpose.
    const missing = (e.derived || []).filter(k => masterKeys.has(k) && !curKeys.has(k)).sort();
    const added = addMissing ? missing : [];
    if (!removed.length && !added.length) continue;
    rows.push({ name, review: e.review_required, removed, missing, added });
    t.projects++; t.remove += removed.length; t.add += added.length;
    if (apply) {
      if (e.review_required) { t.gatedReview++; continue; }
      if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.pre-scope.${Date.now()}`);
      const removeSet = new Set(removed);
      let body = (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '').split('\n')
        .filter(l => { const m = l.match(/^([A-Z_][A-Z0-9_]*)=/); return !(m && removeSet.has(m[1])); })
        .join('\n');
      if (!body.endsWith('\n')) body += '\n';
      for (const k of added) body += `${k}=${envEscape(master[k])}\n`;
      fs.mkdirSync(path.dirname(p), { recursive: true });
      fs.writeFileSync(p, body); try { fs.chmodSync(p, 0o600); } catch {}
      const scopedKeys = [...allow].filter(k => masterKeys.has(k)).sort();
      reg.scoped[name] = { env_path: e.env_path, scoped_digest: crypto.createHash('sha256').update(scopedKeys.join(',')).digest('hex').slice(0, 12), key_count: scopedKeys.length, applied: new Date().toISOString() };
      t.applied++;
    }
  }
  rows.sort((a, b) => b.removed.length - a.removed.length);
  console.log(`regen ${apply ? 'APPLY' : 'DRY-RUN'}${addMissing ? ' +add-missing' : ' (remove-only)'} — ${t.projects} project(s), ${t.remove} over-shared keys removed${addMissing ? `, ${t.add} added` : ''}`);
  for (const r of rows.slice(0, 40)) {
    console.log(`  ${r.review ? '⟲REVIEW ' : '        '}${r.name}`);
    if (r.removed.length) console.log(`      − ${r.removed.length} over-shared: ${r.removed.slice(0, 12).join(', ')}${r.removed.length > 12 ? ' …' : ''}`);
    if (r.added.length) console.log(`      + ${r.added.length} added: ${r.added.join(', ')}`);
    else if (r.missing.length) console.log(`      ? ${r.missing.length} code-refs-but-.env-lacks (advisory): ${r.missing.slice(0, 8).join(', ')}${r.missing.length > 8 ? ' …' : ''}`);
  }
  if (rows.length > 40) console.log(`  … +${rows.length - 40} more`);
  if (apply) { saveRegistry(reg); console.log(`applied to ${t.applied} project(s); ${t.gatedReview} review_required SKIPPED (gated — hand-review + migration runbook).`); }
  else console.log(`\ndry-run only. ⟲REVIEW = dynamic env access, SKIPPED by --apply. '?' rows are advisory (not auto-added). Back up + reload + /healthz-verify per project before applying customer-facing ones.`);
}

// ─── dispatch ───────────────────────────────────────────────────────────
async function main(argv = process.argv.slice(2), options = {}) {
  const [cmd, ...rest] = argv;
  switch (cmd) {
    case 'add':           await cmdAdd(rest[0], rest[1]); break;
    case 'import-paste':  await cmdImportPaste(); break;
    case 'list':          cmdList(); break;
    case 'check':         await cmdCheck(rest[0]); break;
    case 'verify-all': {
      const report = await cmdVerifyAll(options.verifyAllOptions);
      return report.status === 'FAIL' ? 1 : 0;
    }
    case 'sync':          cmdSync(); break;
    case 'audit':         cmdAudit(); break;
    case 'regen':         cmdRegen(rest); break;
    default:
      console.error('usage: cli.js <add|import-paste|list|check|verify-all|sync|audit|regen> [args]');
      process.exit(2);
  }
  return 0;
}

if (require.main === module) main()
  .then((code) => { process.exitCode = code; })
  .catch(e => { console.error('ERR:', e.message); process.exitCode = 1; });

module.exports = { boundedVerify, buildVerifyAllReport, cmdVerifyAll, configuredSecretKeys, main };