pqcheck 0.7.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +96 -27
  2. package/bin/pqcheck.js +1659 -56
  3. package/package.json +5 -5
package/bin/pqcheck.js CHANGED
@@ -2,12 +2,48 @@
2
2
  // =============================================================================
3
3
  // pqcheck CLI — npx pqcheck <domain>
4
4
  // =============================================================================
5
- // Tiny wrapper around the public scan API at quantapact.com.
5
+ // Tiny wrapper around the public scan API at cipherwake.io.
6
6
  // Zero deps (uses node:fetch). Works under `npx pqcheck` without installation.
7
7
  // =============================================================================
8
8
 
9
- const API_BASE = process.env.PQCHECK_API_BASE || "https://quantapact.com";
10
- const VERSION = "0.7.9";
9
+ const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
10
+ const VERSION = "0.12.0";
11
+
12
+ // API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
13
+ // per-account monthly quotas instead of the per-IP rate limit. Set via:
14
+ // export CIPHERWAKE_API_KEY=qpk_<32-hex>
15
+ // Anonymous CLI use still works (no env var → falls back to IP rate limit).
16
+ //
17
+ // QUANTAPACT_API_KEY is honored as a deprecated fallback for existing users
18
+ // (rebrand 2026-05-15). Will be removed in v1.0; in the meantime no break.
19
+ const QP_API_KEY = (process.env.CIPHERWAKE_API_KEY || process.env.QUANTAPACT_API_KEY || "").trim();
20
+
21
+ // Builds headers with optional Authorization. Use for every CLI → API call
22
+ // so a single env-var toggle authenticates every endpoint at once.
23
+ function apiHeaders(extra = {}) {
24
+ const h = { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}`, ...extra };
25
+ if (QP_API_KEY) h.authorization = `Bearer ${QP_API_KEY}`;
26
+ return h;
27
+ }
28
+
29
+ // Helpful messaging when the server tells us auth/quota failed.
30
+ async function handleAuthError(resp) {
31
+ if (resp.status === 401) {
32
+ const body = await safeJSON(resp);
33
+ if (body?.error === "invalid_api_key") {
34
+ console.error(color("red", "CIPHERWAKE_API_KEY is invalid or revoked. Check https://cipherwake.io/account to rotate."));
35
+ return true;
36
+ }
37
+ }
38
+ if (resp.status === 429) {
39
+ const body = await safeJSON(resp);
40
+ if (body?.error === "monthly_quota_exceeded") {
41
+ console.error(color("red", `Monthly quota exceeded${body.detail ? ` — ${body.detail}` : ""}. Upgrade tier at https://cipherwake.io/pricing or wait until the 1st.`));
42
+ return true;
43
+ }
44
+ }
45
+ return false;
46
+ }
11
47
 
12
48
  const ANSI = {
13
49
  reset: "\x1b[0m",
@@ -25,9 +61,17 @@ const color = (c, s) => (supportsColor ? `${ANSI[c]}${s}${ANSI.reset}` : s);
25
61
  async function main() {
26
62
  const args = process.argv.slice(2);
27
63
 
28
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
64
+ // No-args entry: show a layman's quick-start before the full usage block.
65
+ // Behavior: argv.length === 0 prints the friendly intro then usage and exits 1.
66
+ // --help / -h prints only usage (no intro) and exits 0.
67
+ if (args.length === 0) {
68
+ printQuickStart();
69
+ printUsage();
70
+ process.exit(1);
71
+ }
72
+ if (args.includes("--help") || args.includes("-h")) {
29
73
  printUsage();
30
- process.exit(args.length === 0 ? 1 : 0);
74
+ process.exit(0);
31
75
  }
32
76
  if (args.includes("--version") || args.includes("-v")) {
33
77
  console.log(`pqcheck ${VERSION}`);
@@ -44,12 +88,39 @@ async function main() {
44
88
  if (args[0] === "diff") {
45
89
  return runDiffCommand(args.slice(1));
46
90
  }
91
+ if (args[0] === "trust-diff") {
92
+ // CLI v0.11.0 (locked 2026-05-16): new subcommand that calls /api/trust-diff
93
+ // and outputs verdict in selected format. Designed for CI use via the
94
+ // cipherwakelabs/pqcheck Action mode: trust-diff.
95
+ return runTrustDiffCommand(args.slice(1));
96
+ }
47
97
  if (args[0] === "history") {
48
98
  return runHistoryCommand(args.slice(1));
49
99
  }
100
+ if (args[0] === "changes") {
101
+ return runChangesCommand(args.slice(1));
102
+ }
50
103
  if (args[0] === "cert") {
51
104
  return runCertCommand(args.slice(1));
52
105
  }
106
+ if (args[0] === "watch") {
107
+ return runWatchCommand(args.slice(1));
108
+ }
109
+ if (args[0] === "release-checklist") {
110
+ return runReleaseChecklistCommand(args.slice(1));
111
+ }
112
+ if (args[0] === "init") {
113
+ return runInitCommand(args.slice(1));
114
+ }
115
+ if (args[0] === "deploy-check") {
116
+ return runDeployCheckCommand(args.slice(1));
117
+ }
118
+ if (args[0] === "vendors") {
119
+ return runVendorsCommand(args.slice(1));
120
+ }
121
+ if (args[0] === "onboard") {
122
+ return runOnboardCommand(args.slice(1));
123
+ }
53
124
 
54
125
  // Multi-domain support: positional args are domains.
55
126
  // --file reads additional domains from a newline-delimited file.
@@ -133,7 +204,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
133
204
  const qs = fresh ? `?domain=${encodeURIComponent(domain)}&force=1` : `?domain=${encodeURIComponent(domain)}`;
134
205
  const resp = await fetch(`${API_BASE}/api/scan${qs}`, {
135
206
  method: "GET",
136
- headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (scan)` },
207
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (scan)` }),
137
208
  });
138
209
  if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
139
210
  if (!resp.ok) {
@@ -232,7 +303,7 @@ async function runWatch({ domains, format, quiet, threshold, webhookUrl, interva
232
303
  try {
233
304
  const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
234
305
  method: "GET",
235
- headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (watch)` },
306
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (watch)` }),
236
307
  });
237
308
  if (!resp.ok) continue;
238
309
  const report = await resp.json();
@@ -357,7 +428,14 @@ function printCsvRow(r) {
357
428
  console.log(cells.join(","));
358
429
  }
359
430
  function csvEscape(s) {
360
- const v = String(s ?? "");
431
+ // Spreadsheet-formula-injection defense (R2 finding): cells starting
432
+ // with =, +, -, @, TAB, or CR get treated as formulas by Excel/Sheets.
433
+ // Prefix with a literal-string ' to neutralize. Defense-in-depth even
434
+ // though domains are shape-validated upstream.
435
+ let v = String(s ?? "");
436
+ if (/^[=+\-@\t\r\n]/.test(v)) {
437
+ v = "'" + v;
438
+ }
361
439
  if (v.includes(",") || v.includes('"') || v.includes("\n")) {
362
440
  return '"' + v.replace(/"/g, '""') + '"';
363
441
  }
@@ -393,6 +471,13 @@ function printMarkdown(r, multi) {
393
471
  lines.push(`> ⚠ Public surface only. Internal Blast Radius is typically 12–40× this score.`);
394
472
  lines.push("");
395
473
  lines.push(`Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)} · Share: ${API_BASE}/r/${encodeURIComponent(r.domain)}`);
474
+ // Conversion CTA (generalbusiness.md principle #15): one-line activation
475
+ // path on every value-delivery moment. Pointed at /watch/<domain>.
476
+ // Audit-required 2026-05-14: copy matches current locked plan ($29 Starter
477
+ // not the old "watch free / weekly digest"). PR-comment context plants
478
+ // team-invitation idea for the eventual Growth tier upgrade.
479
+ lines.push(`📌 **Monitor ${r.domain} continuously?** ${API_BASE}/watch/${encodeURIComponent(r.domain)}`);
480
+ lines.push(`Cipherwake Starter $29/mo · 5 domains · daily scans + email alerts on cert/script/posture changes. Invite your team on Growth ($79/mo) when ready.`);
396
481
  if (multi) lines.push("\n---\n");
397
482
  console.log(lines.join("\n"));
398
483
  }
@@ -506,7 +591,25 @@ function printReport(r) {
506
591
  console.log("");
507
592
  }
508
593
 
509
- console.log(color("violet", ` → Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)}`));
594
+ // Provenance pill — "Tracked by Cipherwake since X · N observations". Trust
595
+ // signal that this isn't a one-shot probe but a historical record. Only
596
+ // renders if we actually have prior observations for the domain.
597
+ if (r.trackedSince) {
598
+ const trackedDate = String(r.trackedSince).slice(0, 10);
599
+ const obs = typeof r.observations === "number" && r.observations > 0 ? r.observations : null;
600
+ const obsLine = obs ? ` · ${obs} observation${obs === 1 ? "" : "s"}` : "";
601
+ console.log(color("dim", ` Tracked by Cipherwake since ${trackedDate}${obsLine}`));
602
+ console.log("");
603
+ }
604
+
605
+ // Conversion CTA (generalbusiness.md principle #15): every value-delivery
606
+ // moment surfaces the same /watch/<domain> activation path. Audit-required
607
+ // 2026-05-14: copy must match current locked revenue plan ($29 Starter,
608
+ // not the old "free 1-domain weekly digest").
609
+ console.log(color("violet", ` 📌 Monitor ${r.domain} daily: ${API_BASE}/watch/${encodeURIComponent(r.domain)}`));
610
+ console.log(color("dim", ` Cipherwake Starter $29/mo · 5 watched domains · email alerts · cancel anytime`));
611
+ console.log("");
612
+ console.log(color("dim", ` → Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)}`));
510
613
  console.log(color("dim", ` → Share this: ${API_BASE}/r/${encodeURIComponent(r.domain)}`));
511
614
  console.log(color("dim", ` → Compare two: ${API_BASE}/compare?a=${encodeURIComponent(r.domain)}&b=`));
512
615
  console.log("");
@@ -531,6 +634,31 @@ async function safeJSON(resp) {
531
634
  try { return await resp.json(); } catch { return null; }
532
635
  }
533
636
 
637
+ // Friendly first-time intro shown when the CLI is invoked with no args.
638
+ // Goal: tell a brand-new user what this tool does + give them ONE
639
+ // command to copy-paste, before the full usage block. Modeled on
640
+ // `curl`/`gh`/`vercel` first-run output (concise, action-oriented).
641
+ function printQuickStart() {
642
+ console.log(`
643
+ ${color("bold", "👋 Welcome to pqcheck")} ${color("dim", `(v${VERSION})`)}
644
+
645
+ Cipherwake's CLI grades any website's quantum-decryption risk —
646
+ the chance that a harvest-now-decrypt-later attack could read its
647
+ TLS traffic once quantum decryption arrives.
648
+
649
+ ${color("bold", "Try it:")}
650
+ ${color("dim", "$")} npx pqcheck chase.com
651
+
652
+ ${color("bold", "What you'll see:")} a single letter grade (A–F), the score components
653
+ (cipher class, cert lifetime, key rotation history, subdomain exposure),
654
+ top findings, and a link to the full interactive report.
655
+
656
+ ${color("bold", "Free + open methodology.")} No account needed for single-domain scans.
657
+ Add ${color("dim", "QUANTAPACT_API_KEY")} env var for higher rate limits + private results
658
+ (create one at ${color("dim", "https://cipherwake.io/signin")}).
659
+ `);
660
+ }
661
+
534
662
  function printUsage() {
535
663
  console.log(`
536
664
  ${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
@@ -539,11 +667,20 @@ Public Surface Blast Radius — quantum-decryption risk for any domain.
539
667
 
540
668
  ${color("bold", "Commands:")}
541
669
  npx pqcheck <domain> Scan + print human-readable report
542
- npx pqcheck lock <domain> Generate quantapact.lock (QXM) committable manifest
670
+ npx pqcheck lock <domain> Generate cipherwake.lock (QXM) committable manifest
543
671
  npx pqcheck deps <domain> Scan all third-party origins on the page (supply-chain HNDL)
544
672
  npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2 on regression
545
673
  npx pqcheck history <domain> Show 90-day score history (sparkline + samples)
674
+ npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
546
675
  npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
676
+ npx pqcheck watch <domain> Add a domain to your watched-domain list (requires CIPHERWAKE_API_KEY)
677
+ npx pqcheck onboard <domain> One-command setup wizard (scan + init + vendors + checklist + open browser)
678
+ npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
679
+ npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing)
680
+ npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
681
+ npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
682
+ npx pqcheck vendors check <domain> Compare current scan to lockfile; exit 4 on new origins (Free CI gate)
683
+ npx pqcheck vendors sync <domain> Pull approved-vendor list from your account (Starter+)
547
684
 
548
685
  ${color("bold", "Multi-domain:")}
549
686
  npx pqcheck a.com b.com c.com Multi-domain scan (positional)
@@ -568,7 +705,7 @@ ${color("bold", "Common flags:")}
568
705
 
569
706
  ${color("bold", "Subcommand-specific:")}
570
707
  pqcheck deps:
571
- --lock Also write quantapact-deps.lock + .md
708
+ --lock Also write cipherwake-deps.lock + .md
572
709
  -o <dir> Output directory for --lock files
573
710
  --max=<N> Max third parties to scan (default 20)
574
711
  --allowlist <file> Exit 3 if any third-party not in allowlist (CI gate)
@@ -592,19 +729,20 @@ ${color("bold", "Exit codes:")}
592
729
  ${color("bold", "Examples:")}
593
730
  npx pqcheck chase.com
594
731
  npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
732
+ npx pqcheck mybank.com --watch 600 ${color("dim", "# poll locally every 10 min, log on change (no API key required)")}
595
733
  npx pqcheck deps stripe.com --lock
596
734
  npx pqcheck deps acme.com --allowlist allowed-vendors.txt ${color("dim", "# CI vendor-risk gate")}
597
735
  npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --write-baseline ${color("dim", "# capture initial state")}
598
736
  npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --fail-on-new ${color("dim", "# fail PR on new third party")}
599
737
  npx pqcheck diff main.lock pr.lock ${color("dim", "# regression detection in PR")}
600
- npx pqcheck history quantapact.com
738
+ npx pqcheck history cipherwake.io
601
739
  npx pqcheck cert ./mycert.pem ${color("dim", "# offline cert analysis")}
602
740
  npx pqcheck --file domains.txt --format json > scans.ndjson
603
741
  npx pqcheck mybank.com --format sarif > pqcheck.sarif ${color("dim", "# upload to Code Scanning")}
604
742
  npx pqcheck mybank.com --gh-action ${color("dim", "# inline PR annotations")}
605
743
 
606
744
  Backed by the patented Decryption Blast Radius methodology.
607
- ${color("violet", "https://quantapact.com")}
745
+ ${color("violet", "https://cipherwake.io")}
608
746
  `);
609
747
  }
610
748
 
@@ -612,20 +750,46 @@ ${color("violet", "https://quantapact.com")}
612
750
  // `pqcheck lock` — QXM (Quantum Exposure Manifest) generator
613
751
  // =============================================================================
614
752
  // Generates two files committable to a git repo:
615
- // quantapact.lock — stable JSON manifest (machine-readable)
616
- // quantapact-report.md — human-readable summary (renders on GitHub)
753
+ // cipherwake.lock — stable JSON manifest (machine-readable)
754
+ // cipherwake-report.md — human-readable summary (renders on GitHub)
617
755
  //
618
756
  // Like SBOM / package-lock.json / cargo audit / snyk test outputs — devs commit
619
757
  // these to track quantum exposure as a first-class technical concern.
620
758
  //
759
+ // Filename history: this tool was previously named Quantapact, and earlier
760
+ // versions wrote `quantapact.lock` / `quantapact-report.md`. We permanently
761
+ // support reading EITHER filename; existing repos with the old name keep
762
+ // working forever. When re-locking in a directory that has the legacy file,
763
+ // we overwrite it in place rather than silently creating a new file alongside.
764
+ // New repos (no existing lockfile) get the new default `cipherwake.lock`.
765
+ //
621
766
  // Usage:
622
- // npx pqcheck lock <domain> Write to ./quantapact.lock + .md
767
+ // npx pqcheck lock <domain> Write to ./cipherwake.lock + .md
768
+ // (or preserves ./quantapact.lock if present)
623
769
  // npx pqcheck lock <domain> -o dir/ Write into a specific directory
624
770
  // npx pqcheck lock <domain> --stdout Print JSON to stdout (no files)
625
771
  // npx pqcheck lock Read domain from existing
626
- // quantapact.lock if present, else error
772
+ // cipherwake.lock OR quantapact.lock, else error
627
773
  // =============================================================================
628
774
 
775
+ // Discover an existing lockfile in `dir`, preferring the new name but
776
+ // accepting the legacy name. Returns { lockPath, mdPath, isLegacy } if found,
777
+ // or null if neither exists. Read-anywhere, write-back-to-same-name policy.
778
+ async function discoverExistingLockfile(fs, path, dir) {
779
+ const candidates = [
780
+ { lockName: "cipherwake.lock", mdName: "cipherwake-report.md", isLegacy: false },
781
+ { lockName: "quantapact.lock", mdName: "quantapact-report.md", isLegacy: true },
782
+ ];
783
+ for (const c of candidates) {
784
+ const lockPath = path.join(dir, c.lockName);
785
+ try {
786
+ await fs.access(lockPath);
787
+ return { lockPath, mdPath: path.join(dir, c.mdName), isLegacy: c.isLegacy };
788
+ } catch { /* try next */ }
789
+ }
790
+ return null;
791
+ }
792
+
629
793
  async function runLockCommand(args) {
630
794
  const fs = await import("node:fs/promises");
631
795
  const path = await import("node:path");
@@ -639,16 +803,26 @@ async function runLockCommand(args) {
639
803
  const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
640
804
  let domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
641
805
 
806
+ // Discover any existing lockfile (new or legacy). Used both for re-lock
807
+ // domain auto-detection AND to preserve the filename on write.
808
+ const existing = await discoverExistingLockfile(fs, path, outDir);
809
+
642
810
  if (!domain) {
643
- try {
644
- const existing = await fs.readFile(path.join(outDir, "quantapact.lock"), "utf8");
645
- const parsed = JSON.parse(existing);
646
- domain = parsed.domain;
647
- if (!stdout) {
648
- console.error(color("dim", `Re-locking from existing quantapact.lock (domain: ${domain})`));
811
+ if (existing) {
812
+ try {
813
+ const content = await fs.readFile(existing.lockPath, "utf8");
814
+ const parsed = JSON.parse(content);
815
+ domain = parsed.domain;
816
+ if (!stdout) {
817
+ const baseName = path.basename(existing.lockPath);
818
+ console.error(color("dim", `Re-locking from existing ${baseName} (domain: ${domain})`));
819
+ }
820
+ } catch {
821
+ console.error(color("red", `error: could not parse existing ${path.basename(existing.lockPath)}`));
822
+ process.exit(1);
649
823
  }
650
- } catch {
651
- console.error(color("red", "error: no domain provided and no existing quantapact.lock found"));
824
+ } else {
825
+ console.error(color("red", "error: no domain provided and no existing cipherwake.lock found"));
652
826
  console.error(color("dim", "Usage: npx pqcheck lock <domain>"));
653
827
  process.exit(1);
654
828
  }
@@ -665,7 +839,7 @@ async function runLockCommand(args) {
665
839
  try {
666
840
  const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
667
841
  method: "GET",
668
- headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (lock)` },
842
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (lock)` }),
669
843
  });
670
844
  if (!stdout) process.stderr.write("\r\x1b[K");
671
845
  if (!resp.ok) {
@@ -688,9 +862,16 @@ async function runLockCommand(args) {
688
862
  return;
689
863
  }
690
864
 
691
- // Write both files
692
- const lockPath = path.join(outDir, "quantapact.lock");
693
- const mdPath = path.join(outDir, "quantapact-report.md");
865
+ // Write both files. Filename policy: if a legacy quantapact.lock already
866
+ // exists in this directory, overwrite it in place (preserve user's
867
+ // committed filename — no surprise renames in their repo). Otherwise
868
+ // default to the new cipherwake.lock.
869
+ const lockPath = existing
870
+ ? existing.lockPath
871
+ : path.join(outDir, "cipherwake.lock");
872
+ const mdPath = existing
873
+ ? existing.mdPath
874
+ : path.join(outDir, "cipherwake-report.md");
694
875
  const md = renderQxmMarkdown(manifest);
695
876
 
696
877
  try {
@@ -736,7 +917,7 @@ function buildQxmManifest(report, crypto) {
736
917
  );
737
918
 
738
919
  return {
739
- schema: "https://quantapact.com/schemas/qxm/v1",
920
+ schema: "https://cipherwake.io/schemas/qxm/v1",
740
921
  schemaVersion: 1,
741
922
  generator: `pqcheck-cli/${VERSION}`,
742
923
  generatedAt: report.generatedAt || new Date().toISOString(),
@@ -756,13 +937,13 @@ function buildQxmManifest(report, crypto) {
756
937
  components: report.components || null,
757
938
  evidence: {
758
939
  evidenceHash,
759
- methodology: "https://quantapact.com/methodology",
760
- shareableReport: `https://quantapact.com/r/${encodeURIComponent(report.domain)}`,
761
- badge: `https://quantapact.com/badge/${encodeURIComponent(report.domain)}.svg`,
940
+ methodology: "https://cipherwake.io/methodology",
941
+ shareableReport: `https://cipherwake.io/r/${encodeURIComponent(report.domain)}`,
942
+ badge: `https://cipherwake.io/badge/${encodeURIComponent(report.domain)}.svg`,
762
943
  },
763
944
  remediation: {
764
945
  tessera: tesseraNeeded ? "join-waitlist" : "not-needed",
765
- tesseraWaitlist: "https://quantapact.com/feedback?source=qxm-tessera-interest",
946
+ tesseraWaitlist: "https://cipherwake.io/feedback?source=qxm-tessera-interest",
766
947
  notes: tesseraNeeded
767
948
  ? "Findings include cryptographic exposure that Tessera SDK is being designed to remediate. Tessera is in development; join the waitlist to be notified when ready."
768
949
  : "No quantum-decryption-relevant findings requiring Tessera remediation at this time.",
@@ -775,7 +956,7 @@ function renderQxmMarkdown(m) {
775
956
  lines.push(`# Quantum Exposure Manifest — \`${m.domain}\``);
776
957
  lines.push("");
777
958
  lines.push(`> **Decryption Blast Radius:** ${m.score} / 10 (Grade ${m.grade}, ${m.scoreLabel})`);
778
- lines.push(`> Generated by [pqcheck](https://quantapact.com) at ${m.generatedAt}`);
959
+ lines.push(`> Generated by [pqcheck](https://cipherwake.io) at ${m.generatedAt}`);
779
960
  lines.push("");
780
961
  if (!m.reachable) {
781
962
  lines.push(`*${m.domain} was not reachable at scan time.*`);
@@ -852,7 +1033,8 @@ function renderQxmMarkdown(m) {
852
1033
  // Fetches the public HTML of the target domain, extracts third-party origins
853
1034
  // referenced via <script src>, <iframe src>, <link href>, <img src>, then runs
854
1035
  // /api/scan against each unique third party. Outputs a sorted summary + an
855
- // optional committable lockfile (quantapact-deps.lock).
1036
+ // optional committable lockfile (cipherwake-deps.lock; legacy quantapact-deps.lock
1037
+ // is overwritten in place if present, see write-path comments below).
856
1038
  //
857
1039
  // Parallel to the browser extension's Dependencies tab, exposed as a CLI for
858
1040
  // CI integration: gate PR builds on third-party crypto posture.
@@ -860,7 +1042,7 @@ function renderQxmMarkdown(m) {
860
1042
  // Usage:
861
1043
  // npx pqcheck deps <domain> Scan + print summary table
862
1044
  // npx pqcheck deps <domain> --json JSON output (pipe to jq, etc.)
863
- // npx pqcheck deps <domain> --lock Also write quantapact-deps.lock + .md
1045
+ // npx pqcheck deps <domain> --lock Also write cipherwake-deps.lock + .md
864
1046
  // npx pqcheck deps <domain> -o dir/ Output directory for --lock files
865
1047
  // npx pqcheck deps <domain> --max=20 Cap on third parties scanned (default 20)
866
1048
  // =============================================================================
@@ -980,7 +1162,7 @@ async function runDepsCommand(args) {
980
1162
  batch.map(async (h) => {
981
1163
  try {
982
1164
  const r = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(h.host)}&source=cli-deps`, {
983
- headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (deps)` },
1165
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (deps)` }),
984
1166
  });
985
1167
  if (!r.ok) return { ...h, types: Array.from(h.types), scan: null, error: `${r.status}` };
986
1168
  const body = await r.json();
@@ -1034,7 +1216,7 @@ async function runDepsCommand(args) {
1034
1216
 
1035
1217
  // Build manifest
1036
1218
  const manifest = {
1037
- $schema: "https://quantapact.com/schemas/deps/v1",
1219
+ $schema: "https://cipherwake.io/schemas/deps/v1",
1038
1220
  schemaVersion: "1.2", // bumped for CSP + vendor classification fields
1039
1221
  domain,
1040
1222
  scannedAt: new Date().toISOString(),
@@ -1074,7 +1256,7 @@ async function runDepsCommand(args) {
1074
1256
  try {
1075
1257
  const fs2 = await import("node:fs/promises");
1076
1258
  const baselinePayload = {
1077
- $schema: "https://quantapact.com/schemas/deps-baseline/v1",
1259
+ $schema: "https://cipherwake.io/schemas/deps-baseline/v1",
1078
1260
  domain,
1079
1261
  capturedAt: new Date().toISOString(),
1080
1262
  toolVersion: VERSION,
@@ -1143,10 +1325,19 @@ async function runDepsCommand(args) {
1143
1325
  const types = r.types.join(",");
1144
1326
  // Vendor classification — "(New Relic · errors)" beats "bam.nr-data.net"
1145
1327
  // for comprehension. Padded to 22 chars so the table columns stay aligned.
1328
+ // Heuristic matches return `name: null` and just a category — render as
1329
+ // "(cdn — inferred)" to signal we're guessing without claiming certainty.
1146
1330
  const vendor = classifyVendor(r.host);
1147
- const vendorStrRaw = vendor ? `${vendor.name} (${vendor.category})` : "—";
1331
+ let vendorStrRaw;
1332
+ if (vendor?.name) {
1333
+ vendorStrRaw = `${vendor.name} (${vendor.category})`;
1334
+ } else if (vendor?.category) {
1335
+ vendorStrRaw = `(${vendor.category} — inferred)`;
1336
+ } else {
1337
+ vendorStrRaw = "—";
1338
+ }
1148
1339
  const vendorTruncated = vendorStrRaw.length > 22 ? vendorStrRaw.slice(0, 21) + "…" : vendorStrRaw.padEnd(22, " ");
1149
- const vendorColored = vendor ? color("dim", vendorTruncated) : color("dim", vendorTruncated);
1340
+ const vendorColored = color("dim", vendorTruncated);
1150
1341
  console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${vendorColored} ${pqc} ${sriCell} ${color("dim", types)}`);
1151
1342
  }
1152
1343
  console.log("");
@@ -1158,8 +1349,19 @@ async function runDepsCommand(args) {
1158
1349
  console.log("");
1159
1350
 
1160
1351
  if (lock) {
1161
- const lockPath = path.join(outDir, "quantapact-deps.lock");
1162
- const mdPath = path.join(outDir, "quantapact-deps-report.md");
1352
+ // Filename policy mirrors `pqcheck lock`: prefer the new cipherwake-deps.lock,
1353
+ // but if a legacy quantapact-deps.lock exists in this dir, overwrite that one
1354
+ // in place so the user's repo doesn't suddenly grow a second lockfile.
1355
+ const fsSync = await import("node:fs");
1356
+ const legacyDepsLock = path.join(outDir, "quantapact-deps.lock");
1357
+ const legacyDepsMd = path.join(outDir, "quantapact-deps-report.md");
1358
+ const hasLegacy = fsSync.existsSync(legacyDepsLock);
1359
+ const lockPath = hasLegacy
1360
+ ? legacyDepsLock
1361
+ : path.join(outDir, "cipherwake-deps.lock");
1362
+ const mdPath = hasLegacy
1363
+ ? legacyDepsMd
1364
+ : path.join(outDir, "cipherwake-deps-report.md");
1163
1365
  try {
1164
1366
  await fs.mkdir(outDir, { recursive: true });
1165
1367
  await fs.writeFile(lockPath, JSON.stringify(manifest, null, 2));
@@ -1199,7 +1401,7 @@ async function fetchPageHTML(domain) {
1199
1401
  method: "GET",
1200
1402
  redirect: "follow",
1201
1403
  signal: ctrl.signal,
1202
- headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://quantapact.com)` },
1404
+ headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://cipherwake.io)` },
1203
1405
  });
1204
1406
  clearTimeout(t);
1205
1407
  if (!resp.ok) return null;
@@ -1303,6 +1505,39 @@ const SERVICE_CATALOG = {
1303
1505
  "tiktok.com": { name: "TikTok", category: "social" },
1304
1506
  };
1305
1507
 
1508
+ // Conservative heuristic patterns — same as lib/serviceCatalog.ts + popup.js.
1509
+ // Used when a host isn't in the explicit catalog. Doesn't name the vendor
1510
+ // (we don't know), but assigns a high-confidence category like "cdn" or
1511
+ // "ads" so unknown hosts aren't blank.
1512
+ const HEURISTIC_PATTERNS = [
1513
+ // CDN
1514
+ { re: /^cdn[.-]/, category: "cdn" },
1515
+ { re: /^static\./, category: "cdn" },
1516
+ { re: /^assets\./, category: "cdn" },
1517
+ { re: /\.cloudfront\.net$/, category: "cdn" },
1518
+ { re: /\.akamai(?:edge|hd|ized)?\.net$/, category: "cdn" },
1519
+ { re: /\.fastly\.net$/, category: "cdn" },
1520
+ { re: /\.azureedge\.net$/, category: "cdn" },
1521
+ // Analytics / RUM
1522
+ { re: /^analytics?\./, category: "analytics" },
1523
+ { re: /^metrics?\./, category: "analytics" },
1524
+ { re: /^telemetry\./, category: "analytics" },
1525
+ { re: /^rum\./, category: "analytics" },
1526
+ // Ads
1527
+ { re: /^ads?[.-]/, category: "ads" },
1528
+ { re: /^adserver\./, category: "ads" },
1529
+ { re: /^pubads\./, category: "ads" },
1530
+ { re: /\.advertising\./, category: "ads" },
1531
+ // Consent / cookies
1532
+ { re: /^consent\./, category: "consent" },
1533
+ { re: /^cookies?\./, category: "consent" },
1534
+ { re: /^gdpr\./, category: "consent" },
1535
+ // Fonts
1536
+ { re: /^fonts?\./, category: "fonts" },
1537
+ // Errors / monitoring
1538
+ { re: /^sentry[.-]/, category: "errors" },
1539
+ ];
1540
+
1306
1541
  function classifyVendor(host) {
1307
1542
  if (!host) return null;
1308
1543
  const lower = host.toLowerCase();
@@ -1312,6 +1547,14 @@ function classifyVendor(host) {
1312
1547
  return SERVICE_CATALOG[pattern];
1313
1548
  }
1314
1549
  }
1550
+ for (const { re, category } of HEURISTIC_PATTERNS) {
1551
+ if (re.test(lower)) {
1552
+ // Inferred — no vendor name, just the category. CLI consumers (the
1553
+ // pretty table + JSON output) check `name === null` to distinguish
1554
+ // from explicit catalog matches.
1555
+ return { name: null, category };
1556
+ }
1557
+ }
1315
1558
  return null;
1316
1559
  }
1317
1560
 
@@ -1451,6 +1694,67 @@ function depsManifestToMarkdown(m) {
1451
1694
  // SARIF + GitHub Action output formats
1452
1695
  // =============================================================================
1453
1696
 
1697
+ // Derive a SARIF-stable rule ID from a finding. Prefer the registry's stable
1698
+ // `id` (e.g., "tls.rsa_kex_fallback") so the same finding gets the same
1699
+ // ruleId every run — without it, GitHub Code Scanning treats a reordered
1700
+ // finding list as a fresh batch of new findings, blowing up the triage UX.
1701
+ // Short non-crypto hash for SARIF rule-ID disambiguation. djb2-style.
1702
+ // Pure JS so it works in both Node and the CLI bundle. 8-char hex output
1703
+ // is enough entropy to disambiguate distinct legacy findings without
1704
+ // becoming churn-prone — same input string always produces the same hash.
1705
+ function shortHash(input) {
1706
+ let h = 5381;
1707
+ for (let i = 0; i < input.length; i++) {
1708
+ h = ((h << 5) + h + input.charCodeAt(i)) >>> 0;
1709
+ }
1710
+ return h.toString(16).padStart(8, "0");
1711
+ }
1712
+
1713
+ function stableRuleId(f) {
1714
+ if (f && typeof f.id === "string" && f.id.length > 0) {
1715
+ // Normalize: if a registry ID already carries the `pqcheck.` prefix
1716
+ // (which happened before this helper existed), don't double-prefix
1717
+ // it into `pqcheck.pqcheck.tls...`. Strip and reattach.
1718
+ const id = f.id.replace(/^pqcheck\./i, "");
1719
+ return `pqcheck.${id}`;
1720
+ }
1721
+ // Fallback for legacy findings emitted without the registry — slug the
1722
+ // title. Still stable across runs (same input → same output).
1723
+ const title = f?.title || "finding";
1724
+ const detail = f?.detail || "";
1725
+ const slug = title
1726
+ .toLowerCase()
1727
+ .replace(/[^a-z0-9]+/g, "_")
1728
+ .replace(/^_+|_+$/g, "")
1729
+ .slice(0, 48);
1730
+ // GPT adversarial review 2026-05-12: legacy findings with similar
1731
+ // titles ("No HSTS header" vs "No HSTS Header!") both slug to
1732
+ // "no_hsts_header" and would collide in SARIF, causing GitHub Code
1733
+ // Scanning to merge unrelated findings. Append a short hash of the
1734
+ // full (title + detail) so semantically-different findings get
1735
+ // distinct rule IDs even when their slugged titles match.
1736
+ const disambiguator = shortHash(`${title}|${detail}`);
1737
+ if (slug.length === 0) {
1738
+ return `pqcheck.legacy.unnamed.${disambiguator}`;
1739
+ }
1740
+ return `pqcheck.legacy.${slug}.${disambiguator}`;
1741
+ }
1742
+
1743
+ // Deduplicate the `rules` array — multiple findings can share the same
1744
+ // underlying rule (e.g., two cert findings both pinned to `cert.expired`).
1745
+ // SARIF's rule list must contain each rule once.
1746
+ function dedupeRules(findings) {
1747
+ const seen = new Set();
1748
+ const out = [];
1749
+ for (const f of findings) {
1750
+ const id = stableRuleId(f);
1751
+ if (seen.has(id)) continue;
1752
+ seen.add(id);
1753
+ out.push(f);
1754
+ }
1755
+ return out;
1756
+ }
1757
+
1454
1758
  function reportToSarif(report) {
1455
1759
  // SARIF 2.1.0 minimal schema for security findings.
1456
1760
  // Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
@@ -1464,9 +1768,15 @@ function reportToSarif(report) {
1464
1768
  driver: {
1465
1769
  name: "pqcheck",
1466
1770
  version: VERSION,
1467
- informationUri: "https://quantapact.com",
1468
- rules: findings.map((f, i) => ({
1469
- id: `pqcheck-${i + 1}`,
1771
+ informationUri: "https://cipherwake.io",
1772
+ // Stable rule IDs — anchored to the finding's registry id (e.g.
1773
+ // "tls.rsa_kex_fallback") when present, otherwise a slug derived
1774
+ // from the title. Previously used positional pqcheck-${i+1} which
1775
+ // made GitHub Code Scanning see every reorder as a "new" finding,
1776
+ // poisoning the dedup/triage UX. Stable IDs let Code Scanning
1777
+ // recognize the same rule across runs.
1778
+ rules: dedupeRules(findings).map((f) => ({
1779
+ id: stableRuleId(f),
1470
1780
  name: (f.title || "finding").replace(/[^A-Za-z0-9]/g, "_"),
1471
1781
  shortDescription: { text: f.title || "finding" },
1472
1782
  fullDescription: { text: f.detail || f.title || "finding" },
@@ -1474,8 +1784,8 @@ function reportToSarif(report) {
1474
1784
  })),
1475
1785
  },
1476
1786
  },
1477
- results: findings.map((f, i) => ({
1478
- ruleId: `pqcheck-${i + 1}`,
1787
+ results: findings.map((f) => ({
1788
+ ruleId: stableRuleId(f),
1479
1789
  level: sevMap[f.severity] || "note",
1480
1790
  message: { text: `${f.title || "finding"}${f.detail ? ` — ${f.detail}` : ""}` },
1481
1791
  // GitHub Code Scanning requires file: scheme (or relative path) for
@@ -1483,7 +1793,7 @@ function reportToSarif(report) {
1483
1793
  // relative path so findings show up cleanly in the Security tab.
1484
1794
  locations: [{
1485
1795
  physicalLocation: {
1486
- artifactLocation: { uri: `quantapact-scan/${report.domain || "unknown"}.txt` },
1796
+ artifactLocation: { uri: `cipherwake-scan/${report.domain || "unknown"}.txt` },
1487
1797
  region: { startLine: 1, startColumn: 1 },
1488
1798
  },
1489
1799
  }],
@@ -1492,7 +1802,7 @@ function reportToSarif(report) {
1492
1802
  score: report.score,
1493
1803
  grade: report.grade,
1494
1804
  severity: f.severity,
1495
- reportUrl: `https://www.quantapact.com/r/${report.domain || ""}`,
1805
+ reportUrl: `https://www.cipherwake.io/r/${report.domain || ""}`,
1496
1806
  },
1497
1807
  })),
1498
1808
  properties: {
@@ -1513,10 +1823,10 @@ function printGitHubActionAnnotations(report) {
1513
1823
  if (report._meta?.degraded) {
1514
1824
  const reason = String(report._meta.degradedReason || "live probe failed").replace(/[\r\n]/g, " ").replace(/::/g, ":");
1515
1825
  const since = String(report._meta.lastUpdated || "unknown");
1516
- console.log(`::warning title=Quantapact: cached score (live probe failed)::Showing last known-good score from ${since}. Reason: ${reason}. Re-run shortly for a fresh probe.`);
1826
+ console.log(`::warning title=Cipherwake: cached score (live probe failed)::Showing last known-good score from ${since}. Reason: ${reason}. Re-run shortly for a fresh probe.`);
1517
1827
  }
1518
1828
  // Top-line score/grade as a notice
1519
- console.log(`::notice title=Quantapact: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
1829
+ console.log(`::notice title=Cipherwake: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
1520
1830
  for (const f of findings) {
1521
1831
  const cmd = sevMap[f.severity] || "notice";
1522
1832
  const title = (f.title || "finding").replace(/[\r\n]/g, " ");
@@ -1548,7 +1858,7 @@ async function runHistoryCommand(args) {
1548
1858
  let h;
1549
1859
  try {
1550
1860
  const r = await fetch(`${API_BASE}/api/history?domain=${encodeURIComponent(domain)}&days=${days}`, {
1551
- headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (history)` },
1861
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (history)` }),
1552
1862
  });
1553
1863
  if (!r.ok) {
1554
1864
  console.error(color("red", `error: ${r.status} ${r.statusText}`));
@@ -1608,10 +1918,257 @@ async function runHistoryCommand(args) {
1608
1918
  console.log("");
1609
1919
  }
1610
1920
 
1921
+ // =============================================================================
1922
+ // `pqcheck changes <domain>` — surface observation-table deltas for a domain
1923
+ // =============================================================================
1924
+ // Hits /api/changes-summary which aggregates the new observation tables
1925
+ // shipped 2026-05-13 (subdomain_observations, script_observations,
1926
+ // posture_snapshots, cert_observations). Returns "N attack-surface changes
1927
+ // in last 14d" + breakdown. Devs use this in CI ("did anything change since
1928
+ // yesterday?") and in PR descriptions ("attached: 3 changes detected since
1929
+ // last week").
1930
+
1931
+ async function runChangesCommand(args) {
1932
+ const json = args.includes("--json");
1933
+ const positional = args.filter((a) => !a.startsWith("-"));
1934
+ const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
1935
+ if (!domain || !isValidDomain(domain)) {
1936
+ console.error(color("red", "error: pqcheck changes requires a valid domain"));
1937
+ console.error(color("dim", "Usage: npx pqcheck changes <domain> [--json]"));
1938
+ process.exit(1);
1939
+ }
1940
+
1941
+ let summary;
1942
+ try {
1943
+ const r = await fetch(`${API_BASE}/api/changes-summary?domain=${encodeURIComponent(domain)}`, {
1944
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (changes)` }),
1945
+ });
1946
+ if (!r.ok) {
1947
+ console.error(color("red", `error: ${r.status} ${r.statusText}`));
1948
+ process.exit(1);
1949
+ }
1950
+ summary = await r.json();
1951
+ } catch (err) {
1952
+ console.error(color("red", `error: ${err.message}`));
1953
+ process.exit(1);
1954
+ }
1955
+
1956
+ if (json) {
1957
+ console.log(JSON.stringify(summary, null, 2));
1958
+ return;
1959
+ }
1960
+
1961
+ const tracked = summary.trackedSince;
1962
+ const total = summary.changes?.last14d ?? 0;
1963
+ const b = summary.breakdown ?? {};
1964
+ console.log("");
1965
+ console.log(` ${color("bold", domain)} ${color("dim", "·")} attack-surface change summary`);
1966
+
1967
+ if (!tracked) {
1968
+ console.log(color("dim", " No observation history yet. Run `npx pqcheck " + domain + "` to start accumulating."));
1969
+ console.log("");
1970
+ return;
1971
+ }
1972
+
1973
+ const trackedDate = String(tracked).slice(0, 10);
1974
+ console.log(color("dim", ` Tracking since ${trackedDate}`));
1975
+ console.log("");
1976
+
1977
+ if (total === 0) {
1978
+ console.log(` ${color("green", "✓")} No public attack-surface changes detected in the last 14 days.`);
1979
+ console.log("");
1980
+ return;
1981
+ }
1982
+
1983
+ console.log(` ${color("yellow", "⚠")} ${color("bold", total)} change${total === 1 ? "" : "s"} detected in the last 14 days:`);
1984
+ console.log("");
1985
+ if (b.newSubdomains14d) {
1986
+ console.log(` ${color("violet", "•")} ${color("bold", b.newSubdomains14d)} new subdomain${b.newSubdomains14d === 1 ? "" : "s"} observed in CT logs or live scans`);
1987
+ }
1988
+ if (b.newScripts14d) {
1989
+ console.log(` ${color("violet", "•")} ${color("bold", b.newScripts14d)} new third-party script host${b.newScripts14d === 1 ? "" : "s"} loaded`);
1990
+ }
1991
+ if (b.newCertKeys14d) {
1992
+ console.log(` ${color("violet", "•")} ${color("bold", b.newCertKeys14d)} new SPKI fingerprint${b.newCertKeys14d === 1 ? "" : "s"} (cert rotation with new key)`);
1993
+ }
1994
+ console.log("");
1995
+ console.log(color("dim", ` Full changelog: ${API_BASE}/domain/${domain}/security-changelog`));
1996
+ console.log("");
1997
+ }
1998
+
1611
1999
  // =============================================================================
1612
2000
  // `pqcheck diff` — diff two QXM lockfiles
1613
2001
  // =============================================================================
1614
2002
 
2003
+ /**
2004
+ * `pqcheck trust-diff <domain>` — compare current public trust posture vs a
2005
+ * baseline via /api/trust-diff. Phase 2 launch feature (CLI v0.11.0).
2006
+ *
2007
+ * Inputs:
2008
+ * --baseline last-week | last-month | last-scan | <ISO date> (default: last-week)
2009
+ * --fail-on any | low | medium | high | critical (default: high)
2010
+ * --format pretty | json | sarif | github (default: pretty)
2011
+ *
2012
+ * Exit codes:
2013
+ * 0 = pass — no deltas at or above fail-on severity
2014
+ * 1 = warn — deltas observed but below fail-on threshold
2015
+ * 2 = fail — deltas observed at or above fail-on threshold
2016
+ * 3 = error — auth/quota/network failure
2017
+ *
2018
+ * Requires CIPHERWAKE_API_KEY env var (Free tier: 30 calls/mo at /account#api-keys).
2019
+ */
2020
+ async function runTrustDiffCommand(args) {
2021
+ const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
2022
+ if (positional.length === 0) {
2023
+ console.error(color("red", "error: pqcheck trust-diff requires a domain"));
2024
+ console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github]"));
2025
+ process.exit(3);
2026
+ }
2027
+ const domain = normalizeDomain(positional[0]);
2028
+ if (!domain) {
2029
+ console.error(color("red", `error: invalid domain "${positional[0]}"`));
2030
+ process.exit(3);
2031
+ }
2032
+ if (!QP_API_KEY) {
2033
+ console.error(color("red", "error: pqcheck trust-diff requires CIPHERWAKE_API_KEY"));
2034
+ console.error(color("dim", "Generate a free key (30 calls/mo) at https://cipherwake.io/account#api-keys"));
2035
+ console.error(color("dim", "Then: export CIPHERWAKE_API_KEY=qpk_<32-hex>"));
2036
+ process.exit(3);
2037
+ }
2038
+
2039
+ const baseline = parseFlag(args, "--baseline") || "last-week";
2040
+ const failOn = parseFlag(args, "--fail-on") || "high";
2041
+ const format = parseFlag(args, "--format") || "pretty";
2042
+
2043
+ let resp;
2044
+ try {
2045
+ resp = await fetch(`${API_BASE}/api/trust-diff`, {
2046
+ method: "POST",
2047
+ headers: {
2048
+ "Content-Type": "application/json",
2049
+ "Authorization": `Bearer ${QP_API_KEY}`,
2050
+ "User-Agent": `pqcheck-cli/${VERSION}`,
2051
+ },
2052
+ body: JSON.stringify({ domain, baseline, fail_on: failOn }),
2053
+ });
2054
+ } catch (err) {
2055
+ console.error(color("red", `error: network failure calling /api/trust-diff: ${err.message}`));
2056
+ process.exit(3);
2057
+ }
2058
+
2059
+ if (resp.status === 401 || resp.status === 403) {
2060
+ await handleAuthError(resp);
2061
+ process.exit(3);
2062
+ }
2063
+ if (resp.status === 429) {
2064
+ const body = await safeJSON(resp);
2065
+ console.error(color("red", "error: Trust Diff API quota exceeded for this month"));
2066
+ if (body?.message) console.error(color("dim", body.message));
2067
+ process.exit(3);
2068
+ }
2069
+ if (!resp.ok) {
2070
+ const body = await safeJSON(resp);
2071
+ console.error(color("red", `error: /api/trust-diff returned ${resp.status}`));
2072
+ if (body?.message) console.error(color("dim", body.message));
2073
+ process.exit(3);
2074
+ }
2075
+
2076
+ const result = await resp.json();
2077
+ const verdict = result.verdict || "pass";
2078
+ const deltas = Array.isArray(result.deltas) ? result.deltas : [];
2079
+
2080
+ // Format output
2081
+ if (format === "json") {
2082
+ console.log(JSON.stringify(result, null, 2));
2083
+ } else if (format === "sarif") {
2084
+ console.log(JSON.stringify(trustDiffToSarif(result), null, 2));
2085
+ } else if (format === "github") {
2086
+ // GitHub Actions workflow command output
2087
+ for (const d of deltas) {
2088
+ const sev = d.severity === "critical" || d.severity === "high" ? "error" : d.severity === "medium" ? "warning" : "notice";
2089
+ const msg = `${d.title || d.type}: ${d.what_changed || ""}`.replace(/\n/g, "%0A").replace(/\r/g, "");
2090
+ console.log(`::${sev}::${msg}`);
2091
+ }
2092
+ console.log(`\nTrust Diff verdict: ${verdict.toUpperCase()} — ${deltas.length} delta${deltas.length === 1 ? "" : "s"} observed.`);
2093
+ console.log(`Quota: ${result.quota?.used_this_month || 0}/${result.quota?.monthly_limit || 0} used.`);
2094
+ } else {
2095
+ // pretty (default)
2096
+ console.log("");
2097
+ console.log(` ${color("bold", "Cipherwake Trust Diff")}`);
2098
+ console.log(` ${color("dim", `${domain} · baseline=${baseline} · fail-on=${failOn}`)}`);
2099
+ console.log("");
2100
+ if (deltas.length === 0) {
2101
+ console.log(` ${color("green", "✓ No deltas observed")}`);
2102
+ } else {
2103
+ const colorByLevel = (sev) => sev === "critical" ? "red" : sev === "high" ? "red" : sev === "medium" ? "yellow" : "dim";
2104
+ for (const d of deltas) {
2105
+ const sevTag = d.severity ? `[${d.severity.toUpperCase()}]` : "";
2106
+ console.log(` ${color(colorByLevel(d.severity), sevTag)} ${d.title || d.type}`);
2107
+ if (d.what_changed) console.log(` ${color("dim", d.what_changed)}`);
2108
+ }
2109
+ }
2110
+ console.log("");
2111
+ const verdictColor = verdict === "fail" ? "red" : verdict === "warn" ? "yellow" : "green";
2112
+ console.log(` Verdict: ${color(verdictColor, verdict.toUpperCase())}`);
2113
+ console.log(` Quota: ${result.quota?.used_this_month || 0}/${result.quota?.monthly_limit || 0} used this month`);
2114
+ if (result.upgrade_hint) {
2115
+ console.log("");
2116
+ console.log(` ${color("dim", "💡 " + result.upgrade_hint)}`);
2117
+ }
2118
+ }
2119
+
2120
+ // Exit code based on verdict
2121
+ if (verdict === "fail") process.exit(2);
2122
+ if (verdict === "warn") process.exit(1);
2123
+ process.exit(0);
2124
+ }
2125
+
2126
+ /**
2127
+ * Convert /api/trust-diff response to SARIF 2.1.0 for upload via
2128
+ * github/codeql-action/upload-sarif@v3. Each delta becomes a result with
2129
+ * level = error|warning|note based on severity.
2130
+ */
2131
+ function trustDiffToSarif(result) {
2132
+ const deltas = Array.isArray(result.deltas) ? result.deltas : [];
2133
+ const rules = [...new Set(deltas.map((d) => d.type))].map((type) => ({
2134
+ id: type,
2135
+ name: type,
2136
+ shortDescription: { text: type.replace(/_/g, " ").toLowerCase() },
2137
+ helpUri: `https://cipherwake.io/methodology/change-briefs#${type.toLowerCase()}`,
2138
+ }));
2139
+ const results = deltas.map((d) => ({
2140
+ ruleId: d.type,
2141
+ level: d.severity === "critical" || d.severity === "high" ? "error" : d.severity === "medium" ? "warning" : "note",
2142
+ message: { text: `${d.title || d.type}: ${d.what_changed || ""}` },
2143
+ locations: [{
2144
+ physicalLocation: {
2145
+ artifactLocation: { uri: `cipherwake://${result.domain || "domain"}` },
2146
+ },
2147
+ }],
2148
+ }));
2149
+ return {
2150
+ $schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
2151
+ version: "2.1.0",
2152
+ runs: [{
2153
+ tool: {
2154
+ driver: {
2155
+ name: "Cipherwake Trust Diff",
2156
+ version: VERSION,
2157
+ informationUri: "https://cipherwake.io",
2158
+ rules,
2159
+ },
2160
+ },
2161
+ results,
2162
+ }],
2163
+ };
2164
+ }
2165
+
2166
+ function parseFlag(args, name) {
2167
+ const idx = args.indexOf(name);
2168
+ if (idx === -1 || idx === args.length - 1) return null;
2169
+ return args[idx + 1];
2170
+ }
2171
+
1615
2172
  async function runDiffCommand(args) {
1616
2173
  const fs = await import("node:fs/promises");
1617
2174
  const json = args.includes("--json");
@@ -1637,7 +2194,7 @@ async function runDiffCommand(args) {
1637
2194
  }
1638
2195
 
1639
2196
  console.log("");
1640
- console.log(` ${color("bold", "Quantapact lockfile diff")}`);
2197
+ console.log(` ${color("bold", "Cipherwake lockfile diff")}`);
1641
2198
  console.log(` ${color("dim", `${positional[0]} → ${positional[1]}`)}`);
1642
2199
  console.log("");
1643
2200
  if (diff.scoreChange !== null) {
@@ -1708,6 +2265,118 @@ function computeLockDiff(oldLock, newLock) {
1708
2265
  // `pqcheck cert <pem-file>` — analyze a local cert file (offline)
1709
2266
  // =============================================================================
1710
2267
 
2268
+ // `pqcheck watch <domain>` — adds the given domain to the user's watched-
2269
+ // domain list via the authenticated /api/watched-domains POST. Requires
2270
+ // QUANTAPACT_API_KEY env var. Closes the CLI ↔ account loop: developers
2271
+ // who use the CLI can now opt into persistent monitoring from the same
2272
+ // surface without leaving the terminal.
2273
+ async function runWatchCommand(args) {
2274
+ const positional = args.filter((a) => !a.startsWith("-"));
2275
+ if (positional.length === 0) {
2276
+ console.error(color("red", "error: pqcheck watch requires a domain"));
2277
+ console.error(color("dim", "Usage: npx pqcheck watch <domain>"));
2278
+ console.error("");
2279
+ console.error(color("dim", "Example: npx pqcheck watch chase.com"));
2280
+ process.exit(1);
2281
+ }
2282
+ if (!QP_API_KEY) {
2283
+ const rawDomain = positional[0] ? String(positional[0]).trim().toLowerCase() : "";
2284
+ const looksLikeDomain = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(rawDomain);
2285
+ console.error(color("red", "error: pqcheck watch requires CIPHERWAKE_API_KEY (paid tier)"));
2286
+ console.error("");
2287
+ console.error(color("dim", "Two ways to add this domain:"));
2288
+ if (looksLikeDomain) {
2289
+ console.error(color("dim", ` 1. Sign up + click "Watch" in your browser:`));
2290
+ console.error(color("dim", ` ${API_BASE}/watch/${rawDomain}`));
2291
+ } else {
2292
+ console.error(color("dim", ` 1. Sign up + click "Watch" in your browser:`));
2293
+ console.error(color("dim", ` ${API_BASE}/watch/<your-domain>`));
2294
+ }
2295
+ console.error(color("dim", ` 2. Stay on the CLI — sign up + rotate an API key:`));
2296
+ console.error(color("dim", ` ${API_BASE}/signin`));
2297
+ console.error(color("dim", ` ${API_BASE}/account (rotate key)`));
2298
+ console.error(color("dim", ` export CIPHERWAKE_API_KEY=qpk_...`));
2299
+ console.error("");
2300
+ console.error(color("dim", `Just want to poll locally without an account? Use --watch instead:`));
2301
+ console.error(color("dim", ` npx pqcheck ${looksLikeDomain ? rawDomain : "<your-domain>"} --watch 600`));
2302
+ console.error(color("dim", ` (No API key required. Polls every N seconds, logs on score change.)`));
2303
+ process.exit(1);
2304
+ }
2305
+
2306
+ const raw = positional[0];
2307
+ const domain = normalizeDomain(raw);
2308
+ if (!isValidDomain(domain)) {
2309
+ console.error(color("red", `error: '${raw}' is not a valid domain`));
2310
+ process.exit(1);
2311
+ }
2312
+
2313
+ console.log("");
2314
+ console.log(color("violet", ` 📌 Adding ${domain} to your watched-domain list…`));
2315
+
2316
+ let resp;
2317
+ try {
2318
+ resp = await fetch(`${API_BASE}/api/watched-domains`, {
2319
+ method: "POST",
2320
+ headers: {
2321
+ "content-type": "application/json",
2322
+ "authorization": "Bearer " + QP_API_KEY,
2323
+ },
2324
+ body: JSON.stringify({ domain }),
2325
+ });
2326
+ } catch (err) {
2327
+ console.error(color("red", ` network error: ${err.message ?? err}`));
2328
+ process.exit(1);
2329
+ }
2330
+
2331
+ if (resp.status === 401 || resp.status === 403) {
2332
+ console.error(color("red", ` authentication failed (HTTP ${resp.status})`));
2333
+ console.error(color("dim", ` Your API key may be invalid or revoked. Regenerate at ${API_BASE}/account`));
2334
+ process.exit(1);
2335
+ }
2336
+
2337
+ let out = {};
2338
+ try { out = await resp.json(); } catch { /* ignore */ }
2339
+
2340
+ if (!resp.ok) {
2341
+ if (out.error === "domain_already_watched") {
2342
+ console.log(color("dim", ` ${domain} is already on your watched-domain list.`));
2343
+ console.log(color("dim", ` Manage at: ${API_BASE}/account`));
2344
+ process.exit(0);
2345
+ }
2346
+ if (out.error === "tier_cap_exceeded") {
2347
+ console.error(color("red", ` ${out.message || "Tier cap reached."}`));
2348
+ console.error(color("dim", ` See pricing: ${API_BASE}/pricing`));
2349
+ process.exit(1);
2350
+ }
2351
+ if (out.error === "invalid_domain") {
2352
+ console.error(color("red", ` ${domain} is not a valid hostname.`));
2353
+ process.exit(1);
2354
+ }
2355
+ console.error(color("red", ` add failed: ${out.message || out.error || `HTTP ${resp.status}`}`));
2356
+ process.exit(1);
2357
+ }
2358
+
2359
+ console.log("");
2360
+ console.log(color("green", ` ✓ Now watching ${domain}.`));
2361
+ console.log("");
2362
+ if (out.verificationInstructions) {
2363
+ const v = out.verificationInstructions;
2364
+ console.log(color("bold", " Next: verify ownership"));
2365
+ console.log(color("dim", ` Pick ONE method:`));
2366
+ console.log("");
2367
+ console.log(color("dim", ` DNS TXT — name: ${v.dnsTxt?.recordName}`));
2368
+ console.log(color("dim", ` value: ${v.dnsTxt?.recordValue}`));
2369
+ console.log("");
2370
+ console.log(color("dim", ` HTTP file — url: ${v.wellKnown?.url}`));
2371
+ console.log(color("dim", ` body: ${v.wellKnown?.body}`));
2372
+ console.log("");
2373
+ console.log(color("dim", ` After adding the record, click 'Verify now' at ${API_BASE}/account`));
2374
+ console.log(color("dim", ` or run: npx pqcheck watch ${domain} --verify (coming soon)`));
2375
+ }
2376
+ console.log("");
2377
+ process.exit(0);
2378
+ }
2379
+
1711
2380
  async function runCertCommand(args) {
1712
2381
  const fs = await import("node:fs/promises");
1713
2382
  const crypto = await import("node:crypto");
@@ -1793,6 +2462,940 @@ async function runCertCommand(args) {
1793
2462
  console.log("");
1794
2463
  }
1795
2464
 
2465
+ // =============================================================================
2466
+ // `pqcheck release-checklist [domain]` — pre-release trust checklist generator
2467
+ // =============================================================================
2468
+ // Outputs a markdown checklist for teams to paste into release notes or run
2469
+ // as a pre-deploy gate. Pure offline (no API call). Domain is optional —
2470
+ // when present, the checklist is interpolated; when absent, a `<your-domain>`
2471
+ // placeholder is left for the user to fill.
2472
+ //
2473
+ // Habit-loop feature locked 2026-05-16: turns Cipherwake into part of the
2474
+ // release ritual without heavy integrations. See [[cipherwake-launch-plan-2026-05]].
2475
+ // Free tier — no API quota consumed.
2476
+ // =============================================================================
2477
+
2478
+ async function runReleaseChecklistCommand(args) {
2479
+ const positional = args.filter((a) => !a.startsWith("-"));
2480
+ const raw = positional[0];
2481
+ let target = "<your-domain>";
2482
+ if (raw) {
2483
+ const normalized = normalizeDomain(raw);
2484
+ if (!isValidDomain(normalized)) {
2485
+ console.error(color("red", `error: '${raw}' is not a valid domain`));
2486
+ process.exit(1);
2487
+ }
2488
+ target = normalized;
2489
+ }
2490
+ const out = renderReleaseChecklist(target, { generator: "release-checklist" });
2491
+ console.log(out);
2492
+ process.exit(0);
2493
+ }
2494
+
2495
+ // R41 fix #3 (locked 2026-05-16): shared release-checklist helper used by
2496
+ // both `pqcheck release-checklist` (prints to stdout) and `pqcheck onboard`
2497
+ // (writes to CIPHERWAKE_CHECKLIST.md). Single source of truth for the 9
2498
+ // checklist items + verification commands + "where to look" links.
2499
+ //
2500
+ // generator: "release-checklist" or "onboard" — controls the intro paragraph
2501
+ // so the file written by `onboard` says "Generated by `pqcheck onboard`"
2502
+ // while the standalone `release-checklist` command emits the user-facing
2503
+ // "Run these in CI or paste..." intro. All other content is identical.
2504
+ function renderReleaseChecklist(domain, opts = {}) {
2505
+ const generator = opts.generator === "onboard" ? "onboard" : "release-checklist";
2506
+ const intro = generator === "onboard"
2507
+ ? `Generated by \`pqcheck onboard\` — re-run \`pqcheck release-checklist ${domain}\` anytime.`
2508
+ : `Run these in CI or paste into your release-notes template. Each item maps to a Cipherwake check; recommended commands are below. Free tier covers all of these on 1 monitored domain.`;
2509
+ return [
2510
+ `## Pre-release trust checklist for ${domain}`,
2511
+ ``,
2512
+ intro,
2513
+ ``,
2514
+ `- [ ] Trust Diff passes vs last successful deploy`,
2515
+ `- [ ] No new unapproved vendor scripts observed since last release`,
2516
+ `- [ ] HSTS still present and unchanged`,
2517
+ `- [ ] CSP still present and unchanged`,
2518
+ `- [ ] DMARC policy unchanged`,
2519
+ `- [ ] Certificate issuer expected (no surprise CA rotation)`,
2520
+ `- [ ] SPKI / key rotation matches your deploy pipeline`,
2521
+ `- [ ] HNDL Decryption Blast Radius score within target range`,
2522
+ `- [ ] Cipherwake monitoring still active (last scan within 24h)`,
2523
+ ``,
2524
+ `### How to verify`,
2525
+ ``,
2526
+ `\`\`\`bash`,
2527
+ `# Trust posture vs last successful deploy (Free: 30 calls/mo)`,
2528
+ `npx pqcheck trust-diff ${domain} --baseline last-week --fail-on high`,
2529
+ ``,
2530
+ `# Third-party origins on the page (vendor scripts)`,
2531
+ `npx pqcheck vendors check ${domain}`,
2532
+ ``,
2533
+ `# Live grade + score components`,
2534
+ `npx pqcheck ${domain}`,
2535
+ `\`\`\``,
2536
+ ``,
2537
+ `### Where to look`,
2538
+ ``,
2539
+ `- Full dashboard: https://cipherwake.io/r/${encodeURIComponent(domain)}`,
2540
+ `- Methodology + what each check means: https://cipherwake.io/methodology/`,
2541
+ `- 30-day Trust Timeline + Change Briefs: https://cipherwake.io/account`,
2542
+ ``,
2543
+ ].join("\n");
2544
+ }
2545
+
2546
+ // =============================================================================
2547
+ // `pqcheck init` — interactive workflow scaffold (habit-loop #4, locked 2026-05-16)
2548
+ // =============================================================================
2549
+ // Writes a ready-to-commit .github/workflows/cipherwake.yml that calls
2550
+ // cipherwakelabs/pqcheck@v3 in trust-diff mode. Zero copy-paste docs friction.
2551
+ //
2552
+ // Flags:
2553
+ // --domain <d> Skip the domain prompt
2554
+ // --fail-on <level> Skip the severity prompt (any|low|medium|high|critical)
2555
+ // --baseline <ref> Skip the baseline prompt (last-week|last-month|last-scan|<ISO>)
2556
+ // --yes / -y Use defaults for everything not explicitly passed
2557
+ // --force Overwrite an existing workflow file without prompting
2558
+ // --stdout Print the workflow to stdout instead of writing files
2559
+ //
2560
+ // Free tier: no API call made by init itself. The generated workflow runs
2561
+ // against the user's CIPHERWAKE_API_KEY secret (30 free Trust Diff calls/mo).
2562
+ // =============================================================================
2563
+
2564
+ const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
2565
+ const VALID_BASELINES = ["last-week", "last-month", "last-scan"];
2566
+
2567
+ async function runInitCommand(args) {
2568
+ const fs = await import("node:fs/promises");
2569
+ const path = await import("node:path");
2570
+ const useDefaults = args.includes("--yes") || args.includes("-y");
2571
+ const stdout = args.includes("--stdout");
2572
+ const force = args.includes("--force");
2573
+
2574
+ const flagDomain = readFlagValue(args, "--domain");
2575
+ const flagFailOn = readFlagValue(args, "--fail-on");
2576
+ const flagBaseline = readFlagValue(args, "--baseline");
2577
+
2578
+ console.log("");
2579
+ console.log(` ${color("bold", "pqcheck init")} ${color("dim", "— scaffold a Cipherwake GitHub Action workflow")}`);
2580
+ console.log("");
2581
+
2582
+ let domain = flagDomain ? normalizeDomain(flagDomain) : null;
2583
+ if (!domain) {
2584
+ if (useDefaults) {
2585
+ console.error(color("red", "error: --yes requires --domain (no interactive prompt to fill from)"));
2586
+ process.exit(1);
2587
+ }
2588
+ const answer = await prompt(` Domain to monitor (e.g. cipherwake.io): `);
2589
+ domain = normalizeDomain((answer || "").trim());
2590
+ }
2591
+ if (!isValidDomain(domain)) {
2592
+ console.error(color("red", ` error: '${domain}' is not a valid hostname`));
2593
+ process.exit(1);
2594
+ }
2595
+
2596
+ let failOn = flagFailOn || "high";
2597
+ if (!flagFailOn && !useDefaults) {
2598
+ const answer = await prompt(` Fail CI on severity ${color("dim", "[any|low|medium|high(default)|critical]")}: `);
2599
+ if (answer && answer.trim()) failOn = answer.trim().toLowerCase();
2600
+ }
2601
+ if (!VALID_FAIL_ON.includes(failOn)) {
2602
+ console.error(color("red", ` error: --fail-on must be one of ${VALID_FAIL_ON.join("|")}`));
2603
+ process.exit(1);
2604
+ }
2605
+
2606
+ let baseline = flagBaseline || "last-week";
2607
+ if (!flagBaseline && !useDefaults) {
2608
+ const answer = await prompt(` Baseline ${color("dim", "[last-week(default)|last-month|last-scan|<ISO date>]")}: `);
2609
+ if (answer && answer.trim()) baseline = answer.trim();
2610
+ }
2611
+ if (!isValidBaseline(baseline)) {
2612
+ console.error(color("red", ` error: --baseline must be last-week|last-month|last-scan or an ISO date (YYYY-MM-DD)`));
2613
+ process.exit(1);
2614
+ }
2615
+
2616
+ const workflow = renderTrustDiffWorkflow({ domain, failOn, baseline });
2617
+
2618
+ if (stdout) {
2619
+ console.log(workflow);
2620
+ process.exit(0);
2621
+ }
2622
+
2623
+ // Resolve target path: ./.github/workflows/cipherwake.yml in cwd
2624
+ const cwd = process.cwd();
2625
+ const workflowDir = path.join(cwd, ".github", "workflows");
2626
+ const workflowPath = path.join(workflowDir, "cipherwake.yml");
2627
+
2628
+ try {
2629
+ await fs.mkdir(workflowDir, { recursive: true });
2630
+ } catch (err) {
2631
+ console.error(color("red", ` error creating ${workflowDir}: ${err.message}`));
2632
+ process.exit(1);
2633
+ }
2634
+
2635
+ // Check existing file
2636
+ let exists = false;
2637
+ try {
2638
+ await fs.access(workflowPath);
2639
+ exists = true;
2640
+ } catch { /* doesn't exist */ }
2641
+
2642
+ if (exists && !force) {
2643
+ if (useDefaults) {
2644
+ console.error(color("red", ` error: ${workflowPath} already exists (re-run with --force to overwrite)`));
2645
+ process.exit(1);
2646
+ }
2647
+ const answer = await prompt(` ${color("yellow", workflowPath + " already exists — overwrite?")} ${color("dim", "[y/N]")}: `);
2648
+ if (!/^y(es)?$/i.test((answer || "").trim())) {
2649
+ console.error(color("dim", " cancelled — workflow not written"));
2650
+ process.exit(1);
2651
+ }
2652
+ }
2653
+
2654
+ try {
2655
+ await fs.writeFile(workflowPath, workflow, "utf8");
2656
+ } catch (err) {
2657
+ console.error(color("red", ` error writing ${workflowPath}: ${err.message}`));
2658
+ process.exit(1);
2659
+ }
2660
+
2661
+ const relPath = path.relative(cwd, workflowPath);
2662
+ console.log("");
2663
+ console.log(color("green", ` ✓ Wrote ${relPath}`));
2664
+ console.log("");
2665
+ console.log(` ${color("bold", "Next steps:")}`);
2666
+ console.log("");
2667
+ console.log(` ${color("dim", "1.")} Generate a Cipherwake API key at ${color("violet", "https://cipherwake.io/account#api-keys")}`);
2668
+ console.log(` ${color("dim", "Free tier: 30 Trust Diff calls/month")}`);
2669
+ console.log("");
2670
+ console.log(` ${color("dim", "2.")} Add it as a repo secret:`);
2671
+ console.log(` ${color("dim", "Settings → Secrets and variables → Actions → New repository secret")}`);
2672
+ console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY")}`);
2673
+ console.log("");
2674
+ console.log(` ${color("dim", "3.")} Commit + push:`);
2675
+ console.log(` ${color("dim", "$")} git add ${relPath}`);
2676
+ console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
2677
+ console.log(` ${color("dim", "$")} git push`);
2678
+ console.log("");
2679
+ console.log(` Open a PR to see the gate run.`);
2680
+ console.log("");
2681
+ process.exit(0);
2682
+ }
2683
+
2684
+ function readFlagValue(args, name) {
2685
+ const idx = args.indexOf(name);
2686
+ if (idx === -1) return null;
2687
+ const v = args[idx + 1];
2688
+ return v && !v.startsWith("-") ? v : null;
2689
+ }
2690
+
2691
+ function isValidBaseline(value) {
2692
+ if (VALID_BASELINES.includes(value)) return true;
2693
+ // ISO date YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ
2694
+ return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?Z?)?$/.test(value);
2695
+ }
2696
+
2697
+ function renderTrustDiffWorkflow({ domain, failOn, baseline }) {
2698
+ return `# Cipherwake — Trust Diff gate
2699
+ # Generated by \`pqcheck init\` (v${VERSION}).
2700
+ # Runs on every PR and pushes to main: fails the build if your public trust
2701
+ # posture regresses vs the baseline (cert / SPKI / vendor scripts / HSTS / CSP /
2702
+ # DMARC / HNDL).
2703
+ #
2704
+ # Free tier: 30 Trust Diff calls/month per CIPHERWAKE_API_KEY.
2705
+ # Methodology: https://cipherwake.io/methodology/
2706
+ # Action source: https://github.com/cipherwakelabs/pqcheck
2707
+
2708
+ name: Cipherwake Trust Diff
2709
+
2710
+ on:
2711
+ pull_request:
2712
+ branches: [main]
2713
+ push:
2714
+ branches: [main]
2715
+
2716
+ permissions:
2717
+ contents: read
2718
+ security-events: write # required for SARIF upload to Code Scanning
2719
+ pull-requests: write # required for sticky PR comment (Action v3.1+)
2720
+
2721
+ jobs:
2722
+ trust-diff:
2723
+ runs-on: ubuntu-latest
2724
+ steps:
2725
+ - name: Run Cipherwake Trust Diff
2726
+ uses: cipherwakelabs/pqcheck@v3
2727
+ with:
2728
+ mode: trust-diff
2729
+ domain: ${domain}
2730
+ baseline: ${baseline}
2731
+ fail-on: ${failOn}
2732
+ env:
2733
+ CIPHERWAKE_API_KEY: \${{ secrets.CIPHERWAKE_API_KEY }}
2734
+ `;
2735
+ }
2736
+
2737
+ // Tiny readline wrapper. We avoid pulling a CLI prompt library — this is the
2738
+ // only interactive path in pqcheck and Node's built-in readline is enough.
2739
+ async function prompt(question) {
2740
+ const readline = await import("node:readline/promises");
2741
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2742
+ try {
2743
+ const answer = await rl.question(question);
2744
+ return answer;
2745
+ } finally {
2746
+ rl.close();
2747
+ }
2748
+ }
2749
+
2750
+ // =============================================================================
2751
+ // `pqcheck deploy-check <domain>` — pre-deploy trust gate (habit-loop #5)
2752
+ // =============================================================================
2753
+ // Thin alias for `pqcheck trust-diff` with deploy-friendly framing:
2754
+ // - Default baseline: last-scan (compares vs your most recent scan, which
2755
+ // usually correlates with the previous deploy)
2756
+ // - Default fail-on: high
2757
+ // - Cleaner output for shell-script use in deploy pipelines (Vercel
2758
+ // pre-build, Netlify build commands, custom CD scripts)
2759
+ //
2760
+ // Exit codes match trust-diff: 0 pass · 1 warn · 2 fail · 3 error.
2761
+ // Consumes the same Free 30 Trust Diff calls/month quota.
2762
+ // =============================================================================
2763
+
2764
+ async function runDeployCheckCommand(args) {
2765
+ const positional = args.filter((a) => !a.startsWith("-"));
2766
+ if (positional.length === 0) {
2767
+ console.error(color("red", "error: pqcheck deploy-check requires a domain"));
2768
+ console.error(color("dim", "Usage: npx pqcheck deploy-check <domain> [--baseline last-scan|last-week|<ISO>] [--fail-on high|medium|low|any]"));
2769
+ process.exit(1);
2770
+ }
2771
+
2772
+ // Forward to trust-diff with deploy-tuned defaults if the user didn't specify.
2773
+ const forwarded = [...args];
2774
+ if (!args.includes("--baseline")) forwarded.push("--baseline", "last-scan");
2775
+ if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
2776
+
2777
+ // Pre-print a deploy-context header (only in text mode — JSON/SARIF users
2778
+ // are scripting and don't want our preamble polluting their pipe).
2779
+ const format = parseFormat(forwarded);
2780
+ if (format === "text") {
2781
+ console.log("");
2782
+ console.log(` ${color("bold", "🚀 Deploy gate")} ${color("dim", "— checking public trust posture vs last scan")}`);
2783
+ console.log("");
2784
+ }
2785
+
2786
+ return runTrustDiffCommand(forwarded);
2787
+ }
2788
+
2789
+ // =============================================================================
2790
+ // `pqcheck vendors <subcommand>` — vendor lockfile management (habit-loop #10)
2791
+ // =============================================================================
2792
+ // Free tier (Option A, locked 2026-05-16):
2793
+ // pqcheck vendors export <domain> — write cipherwake.vendors.json from
2794
+ // the current observed vendor scripts
2795
+ // (read-only snapshot, no CI enforce)
2796
+ // pqcheck vendors check <domain> — compare current scan to the lockfile,
2797
+ // exit 4 if new origins appeared
2798
+ // (free CI gate via the deps endpoint)
2799
+ //
2800
+ // Starter+ tier:
2801
+ // pqcheck vendors sync <domain> — pull approved-vendor list from
2802
+ // /api/vendor-allowlist and merge into
2803
+ // the lockfile (bidirectional)
2804
+ //
2805
+ // The lockfile is a developer artifact: commit it to the repo to track
2806
+ // vendor-surface drift in PR diffs. Like package-lock.json for third-party
2807
+ // scripts.
2808
+ //
2809
+ // Per [[quantapact-pricing-discipline]]: Free generates the lockfile and uses
2810
+ // it in CI; Starter+ adds the dashboard-sync layer + approved-vendor
2811
+ // enforcement. The dashboard CRUD UI is the Starter wall, not the lockfile.
2812
+ // =============================================================================
2813
+
2814
+ const VENDOR_LOCKFILE_NAME = "cipherwake.vendors.json";
2815
+
2816
+ async function runVendorsCommand(args) {
2817
+ const sub = args[0];
2818
+ if (!sub || sub === "--help" || sub === "-h") {
2819
+ console.log(`
2820
+ ${color("bold", "pqcheck vendors")} ${color("dim", `v${VERSION}`)}
2821
+
2822
+ Vendor lockfile management — track third-party scripts on your domain.
2823
+
2824
+ ${color("bold", "Subcommands:")}
2825
+ pqcheck vendors export <domain> Write ${VENDOR_LOCKFILE_NAME} from current observed vendors (Free)
2826
+ pqcheck vendors check <domain> Compare current scan to the lockfile; exit 4 on new origins (Free CI gate)
2827
+ pqcheck vendors sync <domain> Pull approved-vendor list from your account (Starter+, requires CIPHERWAKE_API_KEY)
2828
+
2829
+ ${color("bold", "Flags:")}
2830
+ -o <path> Output / input path (default: ./${VENDOR_LOCKFILE_NAME})
2831
+
2832
+ ${color("bold", "Examples:")}
2833
+ npx pqcheck vendors export cipherwake.io ${color("dim", "# capture initial state")}
2834
+ npx pqcheck vendors check cipherwake.io ${color("dim", "# CI gate — fails on new origins")}
2835
+ CIPHERWAKE_API_KEY=qpk_... npx pqcheck vendors sync cipherwake.io ${color("dim", "# Starter+ dashboard sync")}
2836
+
2837
+ Methodology: ${color("violet", "https://cipherwake.io/methodology/vendor-lockfile")}
2838
+ `);
2839
+ process.exit(0);
2840
+ }
2841
+
2842
+ const rest = args.slice(1);
2843
+ const positional = rest.filter((a) => !a.startsWith("-"));
2844
+ const raw = positional[0];
2845
+ if (!raw) {
2846
+ console.error(color("red", `error: pqcheck vendors ${sub} requires a domain`));
2847
+ process.exit(1);
2848
+ }
2849
+ const domain = normalizeDomain(raw);
2850
+ if (!isValidDomain(domain)) {
2851
+ console.error(color("red", `error: '${raw}' is not a valid domain`));
2852
+ process.exit(1);
2853
+ }
2854
+
2855
+ const outIdx = rest.indexOf("-o");
2856
+ const outPath = (outIdx >= 0 && rest[outIdx + 1] && !rest[outIdx + 1].startsWith("-"))
2857
+ ? rest[outIdx + 1]
2858
+ : VENDOR_LOCKFILE_NAME;
2859
+
2860
+ if (sub === "export") {
2861
+ return runVendorsExport(domain, outPath);
2862
+ }
2863
+ if (sub === "check") {
2864
+ return runVendorsCheck(domain, outPath);
2865
+ }
2866
+ if (sub === "sync") {
2867
+ return runVendorsSync(domain, outPath);
2868
+ }
2869
+ console.error(color("red", `error: unknown subcommand 'vendors ${sub}'. Try: export | check | sync`));
2870
+ process.exit(1);
2871
+ }
2872
+
2873
+ async function fetchVendorOrigins(domain) {
2874
+ // Calls /api/deps which returns the observed third-party origin list for
2875
+ // a domain. Same endpoint that powers `pqcheck deps <domain>`.
2876
+ //
2877
+ // R40 fix (Q2.6): add a 15-second timeout via AbortController. Previously
2878
+ // a hung /api/deps would block the CLI indefinitely — CI runs would
2879
+ // consume their full 6-hour budget waiting for a response that never
2880
+ // comes. 15s is generous enough for the slow tail but bounds the worst
2881
+ // case.
2882
+ const ac = new AbortController();
2883
+ const timer = setTimeout(() => ac.abort(), 15_000);
2884
+ let resp;
2885
+ try {
2886
+ resp = await fetch(`${API_BASE}/api/deps?domain=${encodeURIComponent(domain)}`, {
2887
+ method: "GET",
2888
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (vendors)` }),
2889
+ signal: ac.signal,
2890
+ });
2891
+ } catch (err) {
2892
+ if (err?.name === "AbortError") throw new Error("/api/deps timed out after 15s");
2893
+ throw err;
2894
+ } finally {
2895
+ clearTimeout(timer);
2896
+ }
2897
+ if (!resp.ok) {
2898
+ const body = await safeJSON(resp);
2899
+ throw new Error(`/api/deps returned ${resp.status} ${body?.error || resp.statusText}`);
2900
+ }
2901
+ const data = await resp.json();
2902
+ const thirds = Array.isArray(data.thirdParties) ? data.thirdParties : [];
2903
+ // R40 fix (Q2.7): preserve the observed protocol. Previously we
2904
+ // force-converted http://vendor.com into https://vendor.com which
2905
+ // mis-represented what we actually observed. Now: keep http:// when
2906
+ // the API gives an explicit http:// origin; default to https:// only
2907
+ // when the host comes without a protocol prefix.
2908
+ const origins = new Set();
2909
+ for (const t of thirds) {
2910
+ const host = typeof t === "string" ? t : (t.host ?? t.origin ?? "");
2911
+ if (!host) continue;
2912
+ const o = normalizeObservedOrigin(host);
2913
+ if (o) origins.add(o);
2914
+ }
2915
+ return Array.from(origins).sort();
2916
+ }
2917
+
2918
+ // R40 fix (Q2.7): preserve observed protocol. URL parser handles host
2919
+ // validation + canonicalization (lowercase, default-port stripping).
2920
+ function normalizeObservedOrigin(value) {
2921
+ const s = String(value).trim().toLowerCase();
2922
+ if (!s) return null;
2923
+ try {
2924
+ const u = s.startsWith("http://") || s.startsWith("https://")
2925
+ ? new URL(s)
2926
+ : new URL("https://" + s);
2927
+ return u.origin;
2928
+ } catch {
2929
+ return null;
2930
+ }
2931
+ }
2932
+
2933
+ function buildVendorLockfile(domain, origins) {
2934
+ return {
2935
+ schema_version: 1,
2936
+ generator: `pqcheck-cli/${VERSION}`,
2937
+ domain,
2938
+ generated_at: new Date().toISOString(),
2939
+ approved_script_origins: origins,
2940
+ // Soft tier marker — read by sync to know if the lockfile carries
2941
+ // dashboard-managed entries. Free-only lockfiles set this to null.
2942
+ synced_from_account: null,
2943
+ };
2944
+ }
2945
+
2946
+ async function runVendorsExport(domain, outPath) {
2947
+ const fs = await import("node:fs/promises");
2948
+ console.log("");
2949
+ console.log(` ${color("bold", "Exporting vendor lockfile")} ${color("dim", `— ${domain}`)}`);
2950
+ console.log("");
2951
+ let origins;
2952
+ try {
2953
+ origins = await fetchVendorOrigins(domain);
2954
+ } catch (err) {
2955
+ console.error(color("red", ` error fetching vendor origins: ${err.message}`));
2956
+ process.exit(1);
2957
+ }
2958
+ const lockfile = buildVendorLockfile(domain, origins);
2959
+ try {
2960
+ await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
2961
+ } catch (err) {
2962
+ console.error(color("red", ` error writing ${outPath}: ${err.message}`));
2963
+ process.exit(1);
2964
+ }
2965
+ console.log(color("green", ` ✓ Wrote ${outPath} with ${origins.length} approved script origin${origins.length === 1 ? "" : "s"}.`));
2966
+ console.log("");
2967
+ console.log(` ${color("dim", "Commit this file to your repo to track vendor-surface drift in PR diffs.")}`);
2968
+ // R40 fix (Q2.12): nested template literal — the outer backticks are the
2969
+ // template literal; the inner string passed to color() must ALSO be a
2970
+ // template literal so ${domain} interpolates. Previously this printed
2971
+ // the literal text "${domain}" because color()'s arg was a plain string.
2972
+ console.log(` ${color("dim", `Run \`pqcheck vendors check ${domain}\` in CI to fail PRs that introduce new origins.`)}`);
2973
+ console.log("");
2974
+ process.exit(0);
2975
+ }
2976
+
2977
+ async function runVendorsCheck(domain, lockPath) {
2978
+ const fs = await import("node:fs/promises");
2979
+ let lockfile;
2980
+ try {
2981
+ const raw = await fs.readFile(lockPath, "utf8");
2982
+ lockfile = JSON.parse(raw);
2983
+ } catch (err) {
2984
+ console.error(color("red", ` error reading ${lockPath}: ${err.message}`));
2985
+ console.error(color("dim", ` Run: npx pqcheck vendors export ${domain} to generate one.`));
2986
+ process.exit(1);
2987
+ }
2988
+ if (lockfile.schema_version !== 1) {
2989
+ console.error(color("red", ` error: ${lockPath} schema_version=${lockfile.schema_version}, expected 1`));
2990
+ process.exit(1);
2991
+ }
2992
+ if (lockfile.domain && lockfile.domain !== domain) {
2993
+ console.error(color("yellow", ` warning: lockfile is for ${lockfile.domain} but checking against ${domain}`));
2994
+ }
2995
+ const baseline = new Set(Array.isArray(lockfile.approved_script_origins) ? lockfile.approved_script_origins : []);
2996
+ let observed;
2997
+ try {
2998
+ observed = new Set(await fetchVendorOrigins(domain));
2999
+ } catch (err) {
3000
+ console.error(color("red", ` error fetching current vendors: ${err.message}`));
3001
+ process.exit(1);
3002
+ }
3003
+ const newOrigins = [...observed].filter((o) => !baseline.has(o));
3004
+ const removed = [...baseline].filter((o) => !observed.has(o));
3005
+
3006
+ console.log("");
3007
+ console.log(` ${color("bold", "Vendor lockfile check")} ${color("dim", `— ${domain}`)}`);
3008
+ console.log("");
3009
+ if (newOrigins.length === 0 && removed.length === 0) {
3010
+ console.log(color("green", ` ✓ Vendor surface matches lockfile (${baseline.size} origins).`));
3011
+ console.log("");
3012
+ process.exit(0);
3013
+ }
3014
+ if (newOrigins.length > 0) {
3015
+ console.log(color("red", ` ⚠ ${newOrigins.length} new origin${newOrigins.length === 1 ? "" : "s"} observed (not in lockfile):`));
3016
+ for (const o of newOrigins) console.log(` + ${o}`);
3017
+ console.log("");
3018
+ }
3019
+ if (removed.length > 0) {
3020
+ console.log(color("dim", ` - ${removed.length} origin${removed.length === 1 ? "" : "s"} no longer observed:`));
3021
+ for (const o of removed) console.log(` - ${o}`);
3022
+ console.log("");
3023
+ }
3024
+ if (newOrigins.length > 0) {
3025
+ console.log(color("dim", ` To accept the additions, re-run: npx pqcheck vendors export ${domain}`));
3026
+ console.log(color("dim", ` Then commit the updated ${lockPath} to your repo.`));
3027
+ console.log("");
3028
+ process.exit(4); // New origin(s) detected — same exit code as `deps --fail-on-new`
3029
+ }
3030
+ // Only removals (cleanup), no failure
3031
+ process.exit(0);
3032
+ }
3033
+
3034
+ async function runVendorsSync(domain, outPath) {
3035
+ const fs = await import("node:fs/promises");
3036
+ if (!QP_API_KEY) {
3037
+ console.error(color("red", " error: `vendors sync` requires CIPHERWAKE_API_KEY (Starter+ feature)"));
3038
+ console.error("");
3039
+ console.error(color("dim", " Free tier: use `vendors export` to generate a read-only lockfile."));
3040
+ console.error(color("dim", " Sign up + manage approved vendors at: " + API_BASE + "/account"));
3041
+ console.error(color("dim", " Pricing: " + API_BASE + "/pricing"));
3042
+ process.exit(1);
3043
+ }
3044
+ console.log("");
3045
+ console.log(` ${color("bold", "Syncing vendor lockfile with your account")} ${color("dim", `— ${domain}`)}`);
3046
+ console.log("");
3047
+ let resp;
3048
+ try {
3049
+ resp = await fetch(`${API_BASE}/api/vendor-allowlist?domain=${encodeURIComponent(domain)}`, {
3050
+ method: "GET",
3051
+ headers: {
3052
+ "user-agent": `pqcheck-cli/${VERSION} (vendors-sync)`,
3053
+ "authorization": "Bearer " + QP_API_KEY,
3054
+ },
3055
+ });
3056
+ } catch (err) {
3057
+ console.error(color("red", ` network error: ${err.message ?? err}`));
3058
+ process.exit(1);
3059
+ }
3060
+ if (resp.status === 401 || resp.status === 403) {
3061
+ const body = await safeJSON(resp);
3062
+ console.error(color("red", ` authentication failed (HTTP ${resp.status})`));
3063
+ if (body?.error === "starter_required") {
3064
+ console.error(color("dim", ` Approved-vendor allowlist starts at Starter ($29/mo). ${body.message ?? ""}`));
3065
+ console.error(color("dim", ` ${API_BASE}/pricing?utm_source=cli_vendors_sync`));
3066
+ }
3067
+ process.exit(1);
3068
+ }
3069
+ if (!resp.ok) {
3070
+ console.error(color("red", ` /api/vendor-allowlist returned ${resp.status}`));
3071
+ process.exit(1);
3072
+ }
3073
+ const data = await resp.json();
3074
+ const allowlist = Array.isArray(data.allowlist) ? data.allowlist : [];
3075
+ // Filter to entries for this domain + extract vendor_origin
3076
+ const dashboardOrigins = new Set();
3077
+ for (const item of allowlist) {
3078
+ if (item && item.domain === domain && typeof item.vendor_origin === "string") {
3079
+ dashboardOrigins.add(item.vendor_origin);
3080
+ }
3081
+ }
3082
+
3083
+ // Merge with currently observed (so the lockfile covers everything we see + everything the user approved)
3084
+ let observed = new Set();
3085
+ try {
3086
+ observed = new Set(await fetchVendorOrigins(domain));
3087
+ } catch (err) {
3088
+ console.error(color("yellow", ` warning: could not fetch currently observed origins (${err.message}); using dashboard-only list`));
3089
+ }
3090
+
3091
+ const merged = new Set([...observed, ...dashboardOrigins]);
3092
+ const origins = Array.from(merged).sort();
3093
+ const lockfile = buildVendorLockfile(domain, origins);
3094
+ lockfile.synced_from_account = new Date().toISOString();
3095
+
3096
+ try {
3097
+ await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
3098
+ } catch (err) {
3099
+ console.error(color("red", ` error writing ${outPath}: ${err.message}`));
3100
+ process.exit(1);
3101
+ }
3102
+ console.log(color("green", ` ✓ Synced ${outPath} — ${dashboardOrigins.size} dashboard-approved, ${observed.size} currently observed, ${origins.length} total.`));
3103
+ console.log("");
3104
+ console.log(` ${color("dim", "Commit the updated lockfile to your repo. `pqcheck vendors check` in CI will fail PRs that")}`);
3105
+ console.log(` ${color("dim", "introduce origins outside the merged set.")}`);
3106
+ console.log("");
3107
+ process.exit(0);
3108
+ }
3109
+
3110
+ // =============================================================================
3111
+ // `pqcheck onboard <domain>` — one-command setup wizard (locked 2026-05-16)
3112
+ // =============================================================================
3113
+ // Composes existing CLI subcommands into one happy-path flow:
3114
+ // 1. Quick public scan → show current grade so the user sees value first
3115
+ // 2. Scaffold the GitHub Action workflow (via runInitCommand)
3116
+ // 3. Generate a vendor lockfile snapshot (via runVendorsExport)
3117
+ // 4. Generate a release-checklist markdown
3118
+ // 5. Open the user's browser to /account#api-keys for API-key generation
3119
+ // 6. Print next-steps (secret name + commit commands + PR open)
3120
+ //
3121
+ // Design notes:
3122
+ // - Pure composition of already-reviewed subcommands; no new server endpoints.
3123
+ // - Browser-open uses platform default (`open`/`xdg-open`/`start`). When
3124
+ // headless or sandboxed, the URL is still printed so the user can copy.
3125
+ // - Each step is best-effort: a failed step prints a warning and continues
3126
+ // so a partial setup is still useful. Hard errors only stop early steps
3127
+ // where the rest can't proceed (invalid domain).
3128
+ // - --skip-scan / --skip-vendors / --skip-checklist let power users opt out.
3129
+ // - --no-open suppresses the browser launch (CI / SSH / headless friendly).
3130
+ // =============================================================================
3131
+
3132
+ async function runOnboardCommand(args) {
3133
+ const positional = args.filter((a) => !a.startsWith("-"));
3134
+ const raw = positional[0];
3135
+ if (!raw) {
3136
+ console.error(color("red", "error: pqcheck onboard requires a domain"));
3137
+ console.error(color("dim", "Usage: npx pqcheck onboard <domain> [--skip-scan] [--skip-vendors] [--skip-checklist] [--no-open] [--force] [--strict]"));
3138
+ process.exit(1);
3139
+ }
3140
+ const domain = normalizeDomain(raw);
3141
+ if (!isValidDomain(domain)) {
3142
+ console.error(color("red", `error: '${raw}' is not a valid domain`));
3143
+ process.exit(1);
3144
+ }
3145
+ const skipScan = args.includes("--skip-scan");
3146
+ const skipVendors = args.includes("--skip-vendors");
3147
+ const skipChecklist = args.includes("--skip-checklist");
3148
+ const noOpen = args.includes("--no-open");
3149
+ // R41 fix #1: --force lets users intentionally overwrite an existing
3150
+ // setup (idempotent re-runs). Without it, we abort if any of the
3151
+ // 3 output files already exists, so a user re-running onboard by
3152
+ // mistake doesn't lose hand-edited CIPHERWAKE_CHECKLIST.md / vendors
3153
+ // lockfile / workflow YAML.
3154
+ const force = args.includes("--force");
3155
+ // R41 fix #4: --strict makes onboard exit non-zero if any step fails.
3156
+ // For human-driven first-time setup, exit 0 (best-effort) is the right
3157
+ // default — printed warnings tell the user what to retry. For CI
3158
+ // automation around the wizard itself, --strict lets a build fail on
3159
+ // step errors. (Recommended usage in CI is still the individual
3160
+ // subcommands, not onboard.)
3161
+ const strict = args.includes("--strict");
3162
+
3163
+ // R41 fix #1: pre-flight overwrite check. We probe the 3 output paths
3164
+ // BEFORE running any step so an aborted run doesn't half-modify the
3165
+ // user's project. We use sync stat checks because we're not in a hot
3166
+ // path and the readability win is worth the tiny perf cost.
3167
+ if (!force) {
3168
+ const fsSync = await import("node:fs");
3169
+ const existing = [];
3170
+ try { fsSync.statSync(".github/workflows/cipherwake.yml"); existing.push(".github/workflows/cipherwake.yml"); } catch {}
3171
+ if (!skipVendors) {
3172
+ try { fsSync.statSync("cipherwake.vendors.json"); existing.push("cipherwake.vendors.json"); } catch {}
3173
+ }
3174
+ if (!skipChecklist) {
3175
+ try { fsSync.statSync("CIPHERWAKE_CHECKLIST.md"); existing.push("CIPHERWAKE_CHECKLIST.md"); } catch {}
3176
+ }
3177
+ if (existing.length > 0) {
3178
+ console.error("");
3179
+ console.error(color("red", ` error: refusing to overwrite existing files:`));
3180
+ for (const f of existing) console.error(color("dim", ` ${f}`));
3181
+ console.error("");
3182
+ console.error(color("dim", ` Re-run with --force to overwrite, or delete the files manually.`));
3183
+ console.error(color("dim", ` (--skip-vendors / --skip-checklist also bypass individual file checks.)`));
3184
+ process.exit(1);
3185
+ }
3186
+ }
3187
+
3188
+ // R41 fix #4: track step failures for --strict mode
3189
+ let anyStepFailed = false;
3190
+
3191
+ console.log("");
3192
+ console.log(` ${color("bold", "🚀 Cipherwake onboarding")} ${color("dim", `— ${domain}`)}`);
3193
+ console.log("");
3194
+ console.log(` ${color("dim", "This will write ~3 files to your project and open your browser to grab an API key.")}`);
3195
+ console.log(` ${color("dim", "All steps are best-effort; you can re-run any individual subcommand later.")}`);
3196
+ console.log("");
3197
+
3198
+ // -------------------------------------------------------------------------
3199
+ // STEP 1 — quick scan (value-first; user sees their grade before any setup)
3200
+ // -------------------------------------------------------------------------
3201
+ if (!skipScan) {
3202
+ console.log(color("violet", ` ▸ Step 1 / 4 — scanning ${domain}…`));
3203
+ try {
3204
+ const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}&source=onboard`, {
3205
+ method: "GET",
3206
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (onboard)` }),
3207
+ });
3208
+ if (resp.ok) {
3209
+ const report = await resp.json();
3210
+ const score = typeof report.score === "number" ? report.score.toFixed(1) : "?";
3211
+ const grade = report.grade || "?";
3212
+ const label = report.scoreLabel || "—";
3213
+ console.log(` ${color("bold", "Current grade:")} ${color("violet", grade)} (${score}/10 · ${label})`);
3214
+ console.log(` ${color("dim", `Full report: ${API_BASE}/r/${encodeURIComponent(domain)}`)}`);
3215
+ } else {
3216
+ console.log(color("yellow", ` skipped (scan returned HTTP ${resp.status})`));
3217
+ anyStepFailed = true;
3218
+ }
3219
+ } catch (err) {
3220
+ console.log(color("yellow", ` skipped (${err?.message ?? "scan failed"})`));
3221
+ anyStepFailed = true;
3222
+ }
3223
+ console.log("");
3224
+ }
3225
+
3226
+ // -------------------------------------------------------------------------
3227
+ // STEP 2 — workflow scaffold
3228
+ // -------------------------------------------------------------------------
3229
+ console.log(color("violet", ` ▸ Step 2 / 4 — scaffolding GitHub Action workflow…`));
3230
+ try {
3231
+ // Call runInitCommand non-interactively. The function process.exit()'s on
3232
+ // its own; to compose it here we'd have to refactor. Pragmatic approach:
3233
+ // spawn a child node invoking ourselves with `init --yes --domain ...`.
3234
+ // That keeps each step idempotent and isolated.
3235
+ const { spawn } = await import("node:child_process");
3236
+ const result = await new Promise((resolve) => {
3237
+ const p = spawn(process.execPath, [
3238
+ process.argv[1],
3239
+ "init",
3240
+ "--yes",
3241
+ "--domain", domain,
3242
+ "--force",
3243
+ ], { stdio: "inherit" });
3244
+ p.on("exit", (code) => resolve(code ?? 0));
3245
+ p.on("error", () => resolve(1));
3246
+ });
3247
+ if (result !== 0) {
3248
+ console.log(color("yellow", ` init exited ${result} — you can re-run \`pqcheck init\` later`));
3249
+ anyStepFailed = true;
3250
+ }
3251
+ } catch (err) {
3252
+ console.log(color("yellow", ` skipped init (${err?.message ?? "subprocess failed"})`));
3253
+ anyStepFailed = true;
3254
+ }
3255
+ console.log("");
3256
+
3257
+ // -------------------------------------------------------------------------
3258
+ // STEP 3 — vendor lockfile (skipped if --skip-vendors)
3259
+ // -------------------------------------------------------------------------
3260
+ if (!skipVendors) {
3261
+ console.log(color("violet", ` ▸ Step 3 / 4 — capturing vendor lockfile…`));
3262
+ try {
3263
+ const { spawn } = await import("node:child_process");
3264
+ const result = await new Promise((resolve) => {
3265
+ const p = spawn(process.execPath, [
3266
+ process.argv[1],
3267
+ "vendors",
3268
+ "export",
3269
+ domain,
3270
+ ], { stdio: "inherit" });
3271
+ p.on("exit", (code) => resolve(code ?? 0));
3272
+ p.on("error", () => resolve(1));
3273
+ });
3274
+ if (result !== 0) {
3275
+ console.log(color("yellow", ` vendors export exited ${result} — you can re-run \`pqcheck vendors export ${domain}\` later`));
3276
+ anyStepFailed = true;
3277
+ }
3278
+ } catch (err) {
3279
+ console.log(color("yellow", ` skipped vendors export (${err?.message ?? "subprocess failed"})`));
3280
+ anyStepFailed = true;
3281
+ }
3282
+ console.log("");
3283
+ }
3284
+
3285
+ // -------------------------------------------------------------------------
3286
+ // STEP 4 — release checklist (skipped if --skip-checklist)
3287
+ // -------------------------------------------------------------------------
3288
+ if (!skipChecklist) {
3289
+ console.log(color("violet", ` ▸ Step 4 / 4 — writing release checklist…`));
3290
+ try {
3291
+ const fs = await import("node:fs/promises");
3292
+ const checklist = buildReleaseChecklistMarkdown(domain);
3293
+ await fs.writeFile("CIPHERWAKE_CHECKLIST.md", checklist, "utf8");
3294
+ console.log(` ${color("green", "✓ Wrote CIPHERWAKE_CHECKLIST.md")}`);
3295
+ } catch (err) {
3296
+ console.log(color("yellow", ` skipped checklist (${err?.message ?? "write failed"})`));
3297
+ anyStepFailed = true;
3298
+ }
3299
+ console.log("");
3300
+ }
3301
+
3302
+ // -------------------------------------------------------------------------
3303
+ // Browser open + final next-steps
3304
+ // -------------------------------------------------------------------------
3305
+ // Query MUST come before fragment per RFC 3986. The previous order
3306
+ // `#api-keys?utm_source=onboard` made utm_source part of the fragment
3307
+ // (which never reaches the server), so attribution analytics never fired.
3308
+ const apiKeyUrl = `${API_BASE}/account?utm_source=onboard#api-keys`;
3309
+ console.log(color("bold", " ✓ Setup files written. Three steps remain:"));
3310
+ console.log("");
3311
+ console.log(` ${color("dim", "1.")} ${color("bold", "Get a free API key")} (30 Trust Diff calls/month)`);
3312
+ console.log(` ${color("violet", apiKeyUrl)}`);
3313
+ if (!noOpen) {
3314
+ const opened = await tryOpenBrowser(apiKeyUrl);
3315
+ if (opened) {
3316
+ console.log(` ${color("dim", "(opened in your browser — sign in / sign up there)")}`);
3317
+ } else {
3318
+ console.log(` ${color("dim", "(copy the URL above; --no-open suppresses this hint)")}`);
3319
+ }
3320
+ }
3321
+ console.log("");
3322
+ console.log(` ${color("dim", "2.")} ${color("bold", "Add the key as a GitHub repo secret")}`);
3323
+ console.log(` ${color("dim", "GitHub → Settings → Secrets and variables → Actions → New repository secret")}`);
3324
+ console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY Value: qpk_... (from step 1)")}`);
3325
+ console.log("");
3326
+ console.log(` ${color("dim", "3.")} ${color("bold", "Commit + push")}`);
3327
+ // R41 Q1.15 (locked 2026-05-16): build the git-add file list as an array
3328
+ // and join, so we don't print trailing-space args when --skip flags are
3329
+ // used. Harmless bash semantics either way; cleaner output.
3330
+ const filesToAdd = [".github/workflows/cipherwake.yml"];
3331
+ if (!skipVendors) filesToAdd.push("cipherwake.vendors.json");
3332
+ if (!skipChecklist) filesToAdd.push("CIPHERWAKE_CHECKLIST.md");
3333
+ console.log(` ${color("dim", "$")} git add ${filesToAdd.join(" ")}`);
3334
+ console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
3335
+ console.log(` ${color("dim", "$")} git push`);
3336
+ console.log("");
3337
+ console.log(` ${color("dim", "Open a PR after pushing and Cipherwake will comment inline within ~60s of the workflow firing.")}`);
3338
+ console.log("");
3339
+ // R41 fix #4: --strict makes onboard exit non-zero if any step failed.
3340
+ // Default (best-effort) exit 0 keeps the wizard friendly for first-time
3341
+ // human setup — the visible yellow warnings tell them what to retry.
3342
+ if (strict && anyStepFailed) {
3343
+ console.log(color("dim", " (--strict: one or more steps failed; exiting non-zero)"));
3344
+ process.exit(1);
3345
+ }
3346
+ process.exit(0);
3347
+ }
3348
+
3349
+ // R41 fix #3: buildReleaseChecklistMarkdown is now a thin alias to the shared
3350
+ // renderReleaseChecklist() helper defined alongside runReleaseChecklistCommand.
3351
+ // Single source of truth — when either subcommand's content changes, both
3352
+ // callers update automatically.
3353
+ function buildReleaseChecklistMarkdown(domain) {
3354
+ return renderReleaseChecklist(domain, { generator: "onboard" });
3355
+ }
3356
+
3357
+ // Cross-platform browser launcher. Returns true if a launcher binary
3358
+ // dispatched successfully; false if no launcher is available (e.g. headless
3359
+ // server, sandboxed CI, broken xdg-open config).
3360
+ //
3361
+ // R41 fix #2 (locked 2026-05-16): use exit-event detection + longer timeout
3362
+ // so we don't falsely claim "(opened in your browser)" when xdg-open is
3363
+ // installed but the launcher exits non-zero (no graphical session, no
3364
+ // MIME handler). Previously a flat 200ms timeout resolved true even when
3365
+ // the launcher exited 3 because no display was available.
3366
+ async function tryOpenBrowser(url) {
3367
+ if (process.env.CI || process.env.CIPHERWAKE_NO_BROWSER) return false;
3368
+ const { spawn } = await import("node:child_process");
3369
+ const platform = process.platform;
3370
+ let cmd, cmdArgs;
3371
+ if (platform === "darwin") {
3372
+ cmd = "open"; cmdArgs = [url];
3373
+ } else if (platform === "win32") {
3374
+ cmd = "cmd"; cmdArgs = ["/c", "start", "", url];
3375
+ } else {
3376
+ cmd = "xdg-open"; cmdArgs = [url];
3377
+ }
3378
+ return await new Promise((resolve) => {
3379
+ let settled = false;
3380
+ let p;
3381
+ try {
3382
+ p = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
3383
+ } catch {
3384
+ resolve(false);
3385
+ return;
3386
+ }
3387
+ p.on("error", () => { if (!settled) { settled = true; resolve(false); } });
3388
+ p.on("exit", (code) => { if (!settled) { settled = true; resolve(code === 0); } });
3389
+ p.unref();
3390
+ // Belt-and-suspenders: if the launcher takes >1s to exit AND no error
3391
+ // event has fired, assume it dispatched and went detached (open on
3392
+ // macOS does this — returns after AppleScript-asking Finder/Safari).
3393
+ setTimeout(() => {
3394
+ if (!settled) { settled = true; resolve(true); }
3395
+ }, 1000);
3396
+ });
3397
+ }
3398
+
1796
3399
  main().catch((err) => {
1797
3400
  console.error(color("red", `fatal: ${err.message}`));
1798
3401
  process.exit(2);