pqcheck 0.14.1 → 0.15.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.
- package/README.md +112 -28
- package/bin/cipherwake-chat-hook.js +90 -0
- package/bin/cipherwake-statusline.js +107 -0
- package/bin/pqcheck.js +1613 -38
- package/package.json +4 -2
package/bin/pqcheck.js
CHANGED
|
@@ -6,17 +6,34 @@
|
|
|
6
6
|
// Zero deps (uses node:fetch). Works under `npx pqcheck` without installation.
|
|
7
7
|
// =============================================================================
|
|
8
8
|
|
|
9
|
+
// R74-confirm BLOCKING #5 (GPT 2026-05-22): Node version startup guard.
|
|
10
|
+
// Native fetch requires Node 18+. Without this guard, pre-Node-18 users see
|
|
11
|
+
// "fetch is not defined" at runtime — confusing because package.json `engines`
|
|
12
|
+
// is not enforced by npm/npx. Fail loud + actionable.
|
|
13
|
+
(() => {
|
|
14
|
+
const major = Number((process.versions.node || "0").split(".")[0]);
|
|
15
|
+
if (Number.isFinite(major) && major < 18) {
|
|
16
|
+
process.stderr.write(
|
|
17
|
+
`\n ✗ pqcheck requires Node 18 or newer (you have ${process.versions.node}).\n` +
|
|
18
|
+
` Native fetch is unavailable on this version.\n` +
|
|
19
|
+
` Install Node 18+ from https://nodejs.org or use nvm: \`nvm install 18 && nvm use 18\`\n` +
|
|
20
|
+
` Then retry: \`npx pqcheck <domain>\`\n\n`
|
|
21
|
+
);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
|
|
9
26
|
const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
|
|
10
|
-
const VERSION = "0.
|
|
27
|
+
const VERSION = "0.15.0";
|
|
11
28
|
|
|
12
29
|
// API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
|
|
13
30
|
// per-account monthly quotas instead of the per-IP rate limit. Set via:
|
|
14
31
|
// export CIPHERWAKE_API_KEY=qpk_<32-hex>
|
|
15
32
|
// Anonymous CLI use still works (no env var → falls back to IP rate limit).
|
|
16
33
|
//
|
|
17
|
-
// QUANTAPACT_API_KEY
|
|
18
|
-
//
|
|
19
|
-
const QP_API_KEY = (process.env.CIPHERWAKE_API_KEY ||
|
|
34
|
+
// Removed QUANTAPACT_API_KEY backwards-compat fallback 2026-05-22.
|
|
35
|
+
// Pre-launch, no customers had it set in production — no break.
|
|
36
|
+
const QP_API_KEY = (process.env.CIPHERWAKE_API_KEY || "").trim();
|
|
20
37
|
|
|
21
38
|
// Builds headers with optional Authorization. Use for every CLI → API call
|
|
22
39
|
// so a single env-var toggle authenticates every endpoint at once.
|
|
@@ -127,6 +144,36 @@ async function main() {
|
|
|
127
144
|
if (args[0] === "onboard") {
|
|
128
145
|
return runOnboardCommand(args.slice(1));
|
|
129
146
|
}
|
|
147
|
+
if (args[0] === "guard") {
|
|
148
|
+
// CLI v0.15.0 — `pqcheck guard --domain X -- <deploy command>`.
|
|
149
|
+
// Wraps any deploy command: runs deploy-check first, conditionally
|
|
150
|
+
// executes the deploy command based on ship_decision. The strongest
|
|
151
|
+
// single artifact for terminal-first AI-coder workflows because the
|
|
152
|
+
// AI doesn't have to remember two commands.
|
|
153
|
+
return runGuardCommand(args.slice(1));
|
|
154
|
+
}
|
|
155
|
+
if (args[0] === "protocol") {
|
|
156
|
+
// CLI v0.15.0 — `pqcheck protocol install` opt-in installer with
|
|
157
|
+
// Rule 17 consent flow. Adds the AI Coder Protocol to ~/.claude/CLAUDE.md
|
|
158
|
+
// / .cursorrules (with auto/manual/no consent). Never silent.
|
|
159
|
+
return runProtocolCommand(args.slice(1));
|
|
160
|
+
}
|
|
161
|
+
if (args[0] === "setup") {
|
|
162
|
+
// CLI v0.15.0 — `pqcheck setup --auto --domain <D>` consolidated installer.
|
|
163
|
+
// One command installs everything: GitHub Action workflow, AI Coder
|
|
164
|
+
// Protocol across detected rules files, git pre-push hook, and
|
|
165
|
+
// statusline config. The launch-story command for AI-coder workflows
|
|
166
|
+
// ("one command sets you up across every AI coder").
|
|
167
|
+
return runSetupCommand(args.slice(1));
|
|
168
|
+
}
|
|
169
|
+
if (args[0] === "debug-network" || args.includes("--debug-network")) {
|
|
170
|
+
// R74-confirm SHIP #14-15 (GPT 2026-05-22): probe connectivity to every
|
|
171
|
+
// upstream Cipherwake depends on + report which ones are reachable.
|
|
172
|
+
// Customer-facing diagnostic for "the scan hung" / "command-not-found"
|
|
173
|
+
// / "corporate proxy" / "VPN-blocked" failure modes — instead of leaving
|
|
174
|
+
// them to guess, surface the actual broken hop.
|
|
175
|
+
return runDebugNetworkCommand();
|
|
176
|
+
}
|
|
130
177
|
|
|
131
178
|
// Multi-domain support: positional args are domains.
|
|
132
179
|
// --file reads additional domains from a newline-delimited file.
|
|
@@ -188,17 +235,18 @@ async function main() {
|
|
|
188
235
|
// a cert/key change you just deployed. Subject to a 20/hr per-IP cap on
|
|
189
236
|
// the server side.
|
|
190
237
|
const fresh = args.includes("--fresh") || args.includes("--force");
|
|
238
|
+
const aiMode = parseAiMode(args);
|
|
191
239
|
|
|
192
240
|
// One-shot scan(s)
|
|
193
241
|
let worstExit = 0;
|
|
194
242
|
for (const domain of domains) {
|
|
195
|
-
const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh });
|
|
243
|
+
const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh, aiMode });
|
|
196
244
|
if (exit > worstExit) worstExit = exit;
|
|
197
245
|
}
|
|
198
246
|
process.exit(worstExit);
|
|
199
247
|
}
|
|
200
248
|
|
|
201
|
-
async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh }) {
|
|
249
|
+
async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh, aiMode }) {
|
|
202
250
|
if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain}${fresh ? " (forcing fresh)" : ""} ...`));
|
|
203
251
|
let report;
|
|
204
252
|
try {
|
|
@@ -248,6 +296,71 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
248
296
|
console.error(`pqcheck: ⚠ ${domain} — using cached score (live probe failed: ${report._meta.degradedReason || "unknown"}; last verified ${report._meta.lastUpdated || "?"})`);
|
|
249
297
|
}
|
|
250
298
|
|
|
299
|
+
// AI Coder Mode — three-layer output for Claude Code / Cursor / Aider.
|
|
300
|
+
// Overrides --format when --ai is set; emits the structured block at the
|
|
301
|
+
// bottom so downstream agents can parse ship_decision deterministically.
|
|
302
|
+
if (aiMode) {
|
|
303
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
304
|
+
const maxSev = highestSeverity(findings);
|
|
305
|
+
const shipDecision = computeShipDecision({ maxSeverity: maxSev });
|
|
306
|
+
const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
|
|
307
|
+
|
|
308
|
+
const topIssue = topFinding
|
|
309
|
+
? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
|
|
310
|
+
: "No findings at or above LOW severity.";
|
|
311
|
+
const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk. Findings reflect public-surface signals only.";
|
|
312
|
+
const nextActions = shipDecision === "pass"
|
|
313
|
+
? [`Domain looks healthy. View full report: ${API_BASE}/r/${domain}`]
|
|
314
|
+
: [
|
|
315
|
+
`Review finding above and decide if it was intentional.`,
|
|
316
|
+
`View full report: ${API_BASE}/r/${domain}`,
|
|
317
|
+
`Re-scan with --fresh after fix: npx pqcheck ${domain} --fresh --ai`,
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
console.log("");
|
|
321
|
+
console.log(formatAiBanner({
|
|
322
|
+
domain,
|
|
323
|
+
kind: "scan",
|
|
324
|
+
dbr: report.score,
|
|
325
|
+
grade: report.grade,
|
|
326
|
+
maxSeverity: maxSev,
|
|
327
|
+
shipDecision,
|
|
328
|
+
}));
|
|
329
|
+
console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
|
|
330
|
+
console.log(formatAiFooterBlock({
|
|
331
|
+
status: shipDecision,
|
|
332
|
+
domain,
|
|
333
|
+
kind: "scan",
|
|
334
|
+
dbr: typeof report.score === "number" ? report.score.toFixed(1) : "",
|
|
335
|
+
grade: report.grade || "",
|
|
336
|
+
max_severity: maxSev,
|
|
337
|
+
ship_decision: shipDecision,
|
|
338
|
+
top_issue: topFinding?.id || topFinding?.title || "none",
|
|
339
|
+
findings_high: findings.filter((f) => severityRank(f.severity) === 3).length,
|
|
340
|
+
findings_critical: findings.filter((f) => severityRank(f.severity) === 4).length,
|
|
341
|
+
scanned_at: new Date().toISOString(),
|
|
342
|
+
advisory_only: "true",
|
|
343
|
+
}));
|
|
344
|
+
console.log("");
|
|
345
|
+
|
|
346
|
+
await writeLastScanFile({
|
|
347
|
+
domain,
|
|
348
|
+
kind: "scan",
|
|
349
|
+
score: typeof report.score === "number" ? report.score : null,
|
|
350
|
+
grade: report.grade || null,
|
|
351
|
+
max_severity: maxSev,
|
|
352
|
+
ship_decision: shipDecision,
|
|
353
|
+
top_issue: topFinding?.id || topFinding?.title || null,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Threshold check still applies under --ai (script-pipeable). Otherwise
|
|
357
|
+
// exit code reflects ship_decision so the caller can route on it.
|
|
358
|
+
if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
|
|
359
|
+
return 2;
|
|
360
|
+
}
|
|
361
|
+
return shipDecisionExitCode(shipDecision);
|
|
362
|
+
}
|
|
363
|
+
|
|
251
364
|
// Output dispatch
|
|
252
365
|
if (quiet) {
|
|
253
366
|
if (multi) {
|
|
@@ -405,6 +518,144 @@ function parseWebhook(args) {
|
|
|
405
518
|
return raw;
|
|
406
519
|
}
|
|
407
520
|
|
|
521
|
+
// =============================================================================
|
|
522
|
+
// AI Coder Mode — `--ai` / `--agent` output
|
|
523
|
+
// =============================================================================
|
|
524
|
+
// Three-layer output designed for AI-coder workflows (Claude Code / Cursor /
|
|
525
|
+
// Aider / etc.) where the user can't see GitHub PR comments and the chat
|
|
526
|
+
// scrollback buries verbose CLI output:
|
|
527
|
+
//
|
|
528
|
+
// Layer 1 (top banner) — un-missable one-liner with status + ship_decision
|
|
529
|
+
// Layer 2 (body) — top finding + why it matters + next action (≤12 lines)
|
|
530
|
+
// Layer 3 (footer block) — machine-readable CIPHERWAKE_AI_GUARD_RESULT block
|
|
531
|
+
// that AI agents can deterministically parse to
|
|
532
|
+
// decide pass / review / block
|
|
533
|
+
//
|
|
534
|
+
// The `ship_decision` field is advisory only — Cipherwake is a scanner, not
|
|
535
|
+
// an authoritative deploy-blocker. The methodology page documents this
|
|
536
|
+
// explicitly (Rule 1).
|
|
537
|
+
// =============================================================================
|
|
538
|
+
|
|
539
|
+
function parseAiMode(args) {
|
|
540
|
+
return args.includes("--ai") || args.includes("--agent");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function severityRank(s) {
|
|
544
|
+
const map = { critical: 4, high: 3, medium: 2, low: 1, info: 0, none: -1 };
|
|
545
|
+
return map[String(s || "none").toLowerCase()] ?? 0;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function highestSeverity(findings) {
|
|
549
|
+
if (!Array.isArray(findings) || findings.length === 0) return "none";
|
|
550
|
+
let best = "none";
|
|
551
|
+
for (const f of findings) {
|
|
552
|
+
if (severityRank(f.severity) > severityRank(best)) best = f.severity;
|
|
553
|
+
}
|
|
554
|
+
return best;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Compute ship_decision: pass | review | block.
|
|
558
|
+
// Used in three contexts:
|
|
559
|
+
// - one-shot scan: severity-only, no diff baseline
|
|
560
|
+
// - trust-diff / preview-diff: severity + diff-since-baseline
|
|
561
|
+
// - deploy-check: same as trust-diff (it's an alias)
|
|
562
|
+
//
|
|
563
|
+
// Decision rules (advisory only):
|
|
564
|
+
// * `block` — critical severity present
|
|
565
|
+
// * `review` — high severity OR diff introduced new high-severity OR DBR drop ≥1.0
|
|
566
|
+
// * `pass` — everything else
|
|
567
|
+
function computeShipDecision({ maxSeverity, hasUnexpectedDiff, scoreDelta }) {
|
|
568
|
+
const sev = String(maxSeverity || "none").toLowerCase();
|
|
569
|
+
if (sev === "critical") return "block";
|
|
570
|
+
if (sev === "high") return "review";
|
|
571
|
+
if (hasUnexpectedDiff) return "review";
|
|
572
|
+
if (typeof scoreDelta === "number" && scoreDelta <= -1.0) return "review";
|
|
573
|
+
return "pass";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function aiBannerColor(shipDecision) {
|
|
577
|
+
if (shipDecision === "pass") return "green";
|
|
578
|
+
if (shipDecision === "block") return "red";
|
|
579
|
+
return "yellow"; // review
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function aiStatusEmoji(shipDecision) {
|
|
583
|
+
if (shipDecision === "pass") return "✓";
|
|
584
|
+
if (shipDecision === "block") return "✗";
|
|
585
|
+
return "⚠";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision }) {
|
|
589
|
+
const emoji = aiStatusEmoji(shipDecision);
|
|
590
|
+
const statusWord = ({ pass: "PASS", review: "REVIEW", block: "BLOCK" })[shipDecision] || "REVIEW";
|
|
591
|
+
const c = aiBannerColor(shipDecision);
|
|
592
|
+
const dbrStr = typeof dbr === "number" ? dbr.toFixed(1) : "—";
|
|
593
|
+
const gradeStr = grade ? ` ${grade}` : "";
|
|
594
|
+
const sevStr = maxSeverity && maxSeverity !== "none" ? ` · ${String(maxSeverity).toUpperCase()}` : "";
|
|
595
|
+
return color(c, `◆ cipherwake · ${kind} · ${emoji} ${statusWord} · ${domain} · DBR ${dbrStr}${gradeStr}${sevStr} · ship_decision=${shipDecision}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function formatAiBody({ topIssue, whyMatters, nextActions }) {
|
|
599
|
+
const lines = [];
|
|
600
|
+
if (topIssue) {
|
|
601
|
+
lines.push("");
|
|
602
|
+
lines.push(color("bold", "Top finding:"));
|
|
603
|
+
lines.push(` ${topIssue}`);
|
|
604
|
+
}
|
|
605
|
+
if (whyMatters) {
|
|
606
|
+
lines.push("");
|
|
607
|
+
lines.push(color("bold", "Why it matters:"));
|
|
608
|
+
lines.push(` ${whyMatters}`);
|
|
609
|
+
}
|
|
610
|
+
if (Array.isArray(nextActions) && nextActions.length > 0) {
|
|
611
|
+
lines.push("");
|
|
612
|
+
lines.push(color("bold", "Recommended next action:"));
|
|
613
|
+
for (const a of nextActions) {
|
|
614
|
+
lines.push(` ${a}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return lines.join("\n");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Emit the machine-readable block. Keys are normalized; values are
|
|
621
|
+
// newline-stripped. AI agents grep between the CIPHERWAKE_AI_GUARD_RESULT
|
|
622
|
+
// and END_CIPHERWAKE_AI_GUARD_RESULT markers and parse key=value lines.
|
|
623
|
+
function formatAiFooterBlock(fields) {
|
|
624
|
+
const lines = ["", "CIPHERWAKE_AI_GUARD_RESULT"];
|
|
625
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
626
|
+
if (v === undefined || v === null) continue;
|
|
627
|
+
const safeK = String(k).replace(/[\s=]/g, "_");
|
|
628
|
+
const safeV = String(v).replace(/[\r\n]+/g, " ");
|
|
629
|
+
lines.push(`${safeK}=${safeV}`);
|
|
630
|
+
}
|
|
631
|
+
lines.push("END_CIPHERWAKE_AI_GUARD_RESULT");
|
|
632
|
+
return color("dim", lines.join("\n"));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Persist last-scan state to ~/.config/cipherwake/last-scan.json.
|
|
636
|
+
// Feeds the optional cipherwake-statusline script (v0.16.0) so users get
|
|
637
|
+
// persistent ambient state in their AI coder's status line. Best-effort —
|
|
638
|
+
// never throws (a write failure here doesn't break the scan).
|
|
639
|
+
async function writeLastScanFile(payload) {
|
|
640
|
+
try {
|
|
641
|
+
const os = await import("node:os");
|
|
642
|
+
const path = await import("node:path");
|
|
643
|
+
const fs = await import("node:fs/promises");
|
|
644
|
+
const dir = path.join(os.homedir(), ".config", "cipherwake");
|
|
645
|
+
await fs.mkdir(dir, { recursive: true });
|
|
646
|
+
const file = path.join(dir, "last-scan.json");
|
|
647
|
+
await fs.writeFile(file, JSON.stringify({ ...payload, written_at: new Date().toISOString() }, null, 2));
|
|
648
|
+
} catch {
|
|
649
|
+
// best-effort
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Map ship_decision → exit code so CI / shell scripts can act on it
|
|
654
|
+
// without parsing the structured block. pass=0, review=1, block=2.
|
|
655
|
+
function shipDecisionExitCode(d) {
|
|
656
|
+
return d === "block" ? 2 : d === "review" ? 1 : 0;
|
|
657
|
+
}
|
|
658
|
+
|
|
408
659
|
// ---------- format renderers (CSV + markdown) ----------
|
|
409
660
|
|
|
410
661
|
let _csvHeaderPrinted = false;
|
|
@@ -660,7 +911,7 @@ ${color("bold", "What you'll see:")} a single letter grade (A–F), the score co
|
|
|
660
911
|
top findings, and a link to the full interactive report.
|
|
661
912
|
|
|
662
913
|
${color("bold", "Free + open methodology.")} No account needed for single-domain scans.
|
|
663
|
-
Add ${color("dim", "
|
|
914
|
+
Add ${color("dim", "CIPHERWAKE_API_KEY")} env var for higher rate limits + private results
|
|
664
915
|
(create one at ${color("dim", "https://cipherwake.io/signin")}).
|
|
665
916
|
`);
|
|
666
917
|
}
|
|
@@ -682,7 +933,9 @@ ${color("bold", "Commands:")}
|
|
|
682
933
|
npx pqcheck watch <domain> Add a domain to your watched-domain list (requires CIPHERWAKE_API_KEY)
|
|
683
934
|
npx pqcheck onboard <domain> One-command setup wizard (scan + init + vendors + checklist + open browser)
|
|
684
935
|
npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
|
|
685
|
-
npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing)
|
|
936
|
+
npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing) — see also: AI Coder Protocol at https://cipherwake.io/methodology/ai-coder-protocol
|
|
937
|
+
npx pqcheck guard --domain <D> -- <cmd> NEW: wrap any deploy command. Runs deploy-check first; conditionally runs <cmd> based on ship_decision. The strongest single artifact for AI-coder workflows.
|
|
938
|
+
npx pqcheck protocol install NEW: install the AI Coder Protocol into your CLAUDE.md / .cursorrules (Rule 17 consent flow)
|
|
686
939
|
npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
|
|
687
940
|
npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
|
|
688
941
|
npx pqcheck vendors check <domain> Compare current scan to lockfile; exit 4 on new origins (Free CI gate)
|
|
@@ -2021,8 +2274,89 @@ async function runChangesCommand(args) {
|
|
|
2021
2274
|
* 2 = fail — deltas observed at or above fail-on threshold
|
|
2022
2275
|
* 3 = error — auth/quota/network failure
|
|
2023
2276
|
*
|
|
2024
|
-
*
|
|
2277
|
+
* Authentication paths (per server's applyRepoQuota — supports all three):
|
|
2278
|
+
* • CIPHERWAKE_API_KEY=qpk_... (paid quota, higher cap, off-Actions usage)
|
|
2279
|
+
* • GITHUB_ACTIONS=true + id-token (OIDC, keyless, Free 100 calls/repo/mo)
|
|
2280
|
+
* • No auth (anonymous per-IP rate limit — first-use friction-free path)
|
|
2281
|
+
*
|
|
2282
|
+
* R74-confirm friction fix (GPT 2026-05-22): previously the CLI hard-gated
|
|
2283
|
+
* on CIPHERWAKE_API_KEY before even attempting the call, which broke the
|
|
2284
|
+
* frictionless first-use AI-coder funnel. The hard-gate was gratuitous —
|
|
2285
|
+
* the server has supported anonymous calls since the applyRepoQuota
|
|
2286
|
+
* middleware shipped. Now the CLI just attempts the call with whatever
|
|
2287
|
+
* auth context is available; server applies the appropriate quota path.
|
|
2025
2288
|
*/
|
|
2289
|
+
// R74-confirm friction fix (GPT 2026-05-22): when deploy-check has no
|
|
2290
|
+
// baseline yet (first-deploy of a brand-new domain), fall through to
|
|
2291
|
+
// /api/scan and emit ship_decision based on current absolute findings.
|
|
2292
|
+
// This makes `pqcheck deploy-check <new-domain> --ai` work on first call
|
|
2293
|
+
// with zero setup — no API key, no prior scan, nothing.
|
|
2294
|
+
async function runScanBasedDeployCheck(domain, args) {
|
|
2295
|
+
const headers = {
|
|
2296
|
+
"Content-Type": "application/json",
|
|
2297
|
+
"User-Agent": `pqcheck-cli/${VERSION}`,
|
|
2298
|
+
};
|
|
2299
|
+
if (QP_API_KEY) headers["Authorization"] = `Bearer ${QP_API_KEY}`;
|
|
2300
|
+
|
|
2301
|
+
let resp;
|
|
2302
|
+
try {
|
|
2303
|
+
resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, { headers });
|
|
2304
|
+
} catch (err) {
|
|
2305
|
+
console.error(color("red", `error: network failure calling /api/scan: ${err.message}`));
|
|
2306
|
+
process.exit(3);
|
|
2307
|
+
}
|
|
2308
|
+
if (!resp.ok) {
|
|
2309
|
+
console.error(color("red", `error: /api/scan returned ${resp.status}`));
|
|
2310
|
+
process.exit(3);
|
|
2311
|
+
}
|
|
2312
|
+
const report = await resp.json();
|
|
2313
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
2314
|
+
const maxSev = highestSeverity(findings);
|
|
2315
|
+
const shipDecision = computeShipDecision({ maxSeverity: maxSev });
|
|
2316
|
+
const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
|
|
2317
|
+
const topIssue = topFinding
|
|
2318
|
+
? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
|
|
2319
|
+
: "No findings at or above LOW severity.";
|
|
2320
|
+
const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
|
|
2321
|
+
const nextActions = shipDecision === "pass"
|
|
2322
|
+
? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
|
|
2323
|
+
: [
|
|
2324
|
+
`Review finding above and decide if it was intentional.`,
|
|
2325
|
+
`View full report: ${API_BASE}/r/${domain}`,
|
|
2326
|
+
`Subsequent deploy-checks will diff against this scan as baseline.`,
|
|
2327
|
+
];
|
|
2328
|
+
|
|
2329
|
+
console.log("");
|
|
2330
|
+
console.log(color("dim", ` ℹ first deploy-check for ${domain} — using current scan state (no baseline yet to diff against)`));
|
|
2331
|
+
console.log("");
|
|
2332
|
+
console.log(formatAiBanner({
|
|
2333
|
+
domain,
|
|
2334
|
+
kind: "scan",
|
|
2335
|
+
dbr: report.score,
|
|
2336
|
+
grade: report.grade,
|
|
2337
|
+
maxSeverity: maxSev,
|
|
2338
|
+
shipDecision,
|
|
2339
|
+
}));
|
|
2340
|
+
console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
|
|
2341
|
+
console.log(formatAiFooterBlock({
|
|
2342
|
+
status: shipDecision === "pass" ? "pass" : "review",
|
|
2343
|
+
domain,
|
|
2344
|
+
kind: "scan",
|
|
2345
|
+
dbr: report.score,
|
|
2346
|
+
grade: report.grade,
|
|
2347
|
+
max_severity: maxSev,
|
|
2348
|
+
ship_decision: shipDecision,
|
|
2349
|
+
top_issue: topFinding ? `findings.${topFinding.id || "unknown"}` : "none",
|
|
2350
|
+
findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
|
|
2351
|
+
findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
|
|
2352
|
+
scanned_at: new Date().toISOString(),
|
|
2353
|
+
advisory_only: true,
|
|
2354
|
+
note: "first-deploy: no baseline yet, scored on current state",
|
|
2355
|
+
}));
|
|
2356
|
+
// Exit code: 0 on pass, non-zero on review/block (matches deploy-check contract)
|
|
2357
|
+
process.exit(shipDecision === "pass" ? 0 : 1);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2026
2360
|
async function runTrustDiffCommand(args) {
|
|
2027
2361
|
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
2028
2362
|
if (positional.length === 0) {
|
|
@@ -2035,26 +2369,27 @@ async function runTrustDiffCommand(args) {
|
|
|
2035
2369
|
console.error(color("red", `error: invalid domain "${positional[0]}"`));
|
|
2036
2370
|
process.exit(3);
|
|
2037
2371
|
}
|
|
2038
|
-
if (!QP_API_KEY) {
|
|
2039
|
-
console.error(color("red", "error: pqcheck trust-diff requires CIPHERWAKE_API_KEY"));
|
|
2040
|
-
console.error(color("dim", "Generate a free key (30 calls/mo) at https://cipherwake.io/account#api-keys"));
|
|
2041
|
-
console.error(color("dim", "Then: export CIPHERWAKE_API_KEY=qpk_<32-hex>"));
|
|
2042
|
-
process.exit(3);
|
|
2043
|
-
}
|
|
2044
2372
|
|
|
2045
2373
|
const baseline = parseFlag(args, "--baseline") || "last-week";
|
|
2046
2374
|
const failOn = parseFlag(args, "--fail-on") || "high";
|
|
2047
2375
|
const format = parseFlag(args, "--format") || "pretty";
|
|
2048
2376
|
|
|
2377
|
+
// Build headers conditionally — Authorization is set ONLY if the user has
|
|
2378
|
+
// an API key. Without it, the server's applyRepoQuota falls through to the
|
|
2379
|
+
// anonymous per-IP rate limit path (just like /api/scan).
|
|
2380
|
+
const headers = {
|
|
2381
|
+
"Content-Type": "application/json",
|
|
2382
|
+
"User-Agent": `pqcheck-cli/${VERSION}`,
|
|
2383
|
+
};
|
|
2384
|
+
if (QP_API_KEY) {
|
|
2385
|
+
headers["Authorization"] = `Bearer ${QP_API_KEY}`;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2049
2388
|
let resp;
|
|
2050
2389
|
try {
|
|
2051
2390
|
resp = await fetch(`${API_BASE}/api/trust-diff`, {
|
|
2052
2391
|
method: "POST",
|
|
2053
|
-
headers
|
|
2054
|
-
"Content-Type": "application/json",
|
|
2055
|
-
"Authorization": `Bearer ${QP_API_KEY}`,
|
|
2056
|
-
"User-Agent": `pqcheck-cli/${VERSION}`,
|
|
2057
|
-
},
|
|
2392
|
+
headers,
|
|
2058
2393
|
body: JSON.stringify({ domain, baseline, fail_on: failOn }),
|
|
2059
2394
|
});
|
|
2060
2395
|
} catch (err) {
|
|
@@ -2068,14 +2403,26 @@ async function runTrustDiffCommand(args) {
|
|
|
2068
2403
|
}
|
|
2069
2404
|
if (resp.status === 429) {
|
|
2070
2405
|
const body = await safeJSON(resp);
|
|
2071
|
-
console.error(color("red", "error: Trust Diff API quota exceeded
|
|
2406
|
+
console.error(color("red", "error: Trust Diff API quota exceeded"));
|
|
2072
2407
|
if (body?.message) console.error(color("dim", body.message));
|
|
2408
|
+
console.error(color("dim", "Higher quota via free API key (no card): https://cipherwake.io/account#api-keys"));
|
|
2073
2409
|
process.exit(3);
|
|
2074
2410
|
}
|
|
2411
|
+
// R74-confirm friction fix #2 (GPT 2026-05-22): 404 on first-deploy is
|
|
2412
|
+
// expected — the domain has never been scanned, so there's no baseline to
|
|
2413
|
+
// diff against. Instead of asking the user to run `pqcheck <domain>` first
|
|
2414
|
+
// (a friction step that breaks the AI-coder funnel), automatically fall
|
|
2415
|
+
// through to /api/scan, populate the cache, and emit ship_decision based
|
|
2416
|
+
// on the scan's current absolute state. Subsequent deploy-checks will
|
|
2417
|
+
// have a baseline and produce a real drift verdict.
|
|
2418
|
+
if (resp.status === 404 && parseAiMode(args)) {
|
|
2419
|
+
return await runScanBasedDeployCheck(domain, args);
|
|
2420
|
+
}
|
|
2075
2421
|
if (!resp.ok) {
|
|
2076
2422
|
const body = await safeJSON(resp);
|
|
2077
2423
|
console.error(color("red", `error: /api/trust-diff returned ${resp.status}`));
|
|
2078
2424
|
if (body?.message) console.error(color("dim", body.message));
|
|
2425
|
+
if (body?.hint) console.error(color("dim", body.hint));
|
|
2079
2426
|
process.exit(3);
|
|
2080
2427
|
}
|
|
2081
2428
|
|
|
@@ -2083,6 +2430,72 @@ async function runTrustDiffCommand(args) {
|
|
|
2083
2430
|
const verdict = result.verdict || "pass";
|
|
2084
2431
|
const deltas = Array.isArray(result.deltas) ? result.deltas : [];
|
|
2085
2432
|
|
|
2433
|
+
// AI Coder Mode — three-layer output (banner / body / structured block).
|
|
2434
|
+
if (parseAiMode(args)) {
|
|
2435
|
+
const maxSev = highestSeverity(deltas);
|
|
2436
|
+
const hasUnexpectedDiff = deltas.length > 0;
|
|
2437
|
+
const shipDecision = computeShipDecision({ maxSeverity: maxSev, hasUnexpectedDiff });
|
|
2438
|
+
const topDelta = [...deltas].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
|
|
2439
|
+
|
|
2440
|
+
const topIssue = topDelta
|
|
2441
|
+
? `[${String(topDelta.severity || "").toUpperCase()}] ${topDelta.title || topDelta.type}${topDelta.what_changed ? ` — ${topDelta.what_changed}` : ""}`
|
|
2442
|
+
: "No deltas observed since baseline.";
|
|
2443
|
+
const whyMatters = topDelta
|
|
2444
|
+
? `This change happened between your baseline (${baseline}) and now. If it's an intentional change you shipped, accept it. If unexpected, your domain's posture drifted without an associated deploy — investigate before the diff compounds.`
|
|
2445
|
+
: `Your public trust posture is stable since ${baseline}. No CSP / HSTS / cert / SPKI / DMARC / vendor-script regressions.`;
|
|
2446
|
+
const nextActions = shipDecision === "pass"
|
|
2447
|
+
? [`Posture stable. Safe to announce deploy.`]
|
|
2448
|
+
: [
|
|
2449
|
+
`Review each delta and decide if it was intentional.`,
|
|
2450
|
+
`If intentional: accept (no action needed in CI; quota tick recorded).`,
|
|
2451
|
+
`If not intentional: revert the deploy or investigate the drift source.`,
|
|
2452
|
+
];
|
|
2453
|
+
|
|
2454
|
+
console.log("");
|
|
2455
|
+
console.log(formatAiBanner({
|
|
2456
|
+
domain,
|
|
2457
|
+
kind: "trust-diff",
|
|
2458
|
+
dbr: result.current_score,
|
|
2459
|
+
grade: result.current_grade,
|
|
2460
|
+
maxSeverity: maxSev,
|
|
2461
|
+
shipDecision,
|
|
2462
|
+
}));
|
|
2463
|
+
console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
|
|
2464
|
+
console.log(formatAiFooterBlock({
|
|
2465
|
+
status: shipDecision,
|
|
2466
|
+
domain,
|
|
2467
|
+
kind: "trust-diff",
|
|
2468
|
+
baseline,
|
|
2469
|
+
verdict,
|
|
2470
|
+
delta_count: deltas.length,
|
|
2471
|
+
max_severity: maxSev,
|
|
2472
|
+
ship_decision: shipDecision,
|
|
2473
|
+
top_issue: topDelta?.id || topDelta?.type || "none",
|
|
2474
|
+
top_issue_title: topDelta?.title || "",
|
|
2475
|
+
dbr: typeof result.current_score === "number" ? result.current_score.toFixed(1) : "",
|
|
2476
|
+
grade: result.current_grade || "",
|
|
2477
|
+
quota_used: result.quota?.used_this_month ?? "",
|
|
2478
|
+
quota_limit: result.quota?.monthly_limit ?? "",
|
|
2479
|
+
scanned_at: new Date().toISOString(),
|
|
2480
|
+
advisory_only: "true",
|
|
2481
|
+
}));
|
|
2482
|
+
console.log("");
|
|
2483
|
+
|
|
2484
|
+
await writeLastScanFile({
|
|
2485
|
+
domain,
|
|
2486
|
+
kind: "trust-diff",
|
|
2487
|
+
score: typeof result.current_score === "number" ? result.current_score : null,
|
|
2488
|
+
grade: result.current_grade || null,
|
|
2489
|
+
max_severity: maxSev,
|
|
2490
|
+
ship_decision: shipDecision,
|
|
2491
|
+
baseline,
|
|
2492
|
+
delta_count: deltas.length,
|
|
2493
|
+
top_issue: topDelta?.id || topDelta?.title || null,
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
process.exit(shipDecisionExitCode(shipDecision));
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2086
2499
|
// Format output
|
|
2087
2500
|
if (format === "json") {
|
|
2088
2501
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -2146,8 +2559,14 @@ async function runTrustDiffCommand(args) {
|
|
|
2146
2559
|
*
|
|
2147
2560
|
* Exit codes match trust-diff: 0 pass · 1 warn · 2 fail · 3 error.
|
|
2148
2561
|
*
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2562
|
+
* Authentication paths (server's applyRepoQuota supports all three):
|
|
2563
|
+
* • CIPHERWAKE_API_KEY=qpk_... (paid quota, higher cap)
|
|
2564
|
+
* • GITHUB_ACTIONS=true + id-token (OIDC, keyless, Free 100 calls/repo/mo)
|
|
2565
|
+
* • No auth (anonymous per-IP rate limit — frictionless first-use)
|
|
2566
|
+
*
|
|
2567
|
+
* R74-confirm friction fix (GPT 2026-05-22): the hard-gate on API key has
|
|
2568
|
+
* been removed; server applyRepoQuota handles all 3 auth paths, including
|
|
2569
|
+
* anonymous per-IP rate limit for frictionless first use.
|
|
2151
2570
|
*/
|
|
2152
2571
|
async function runPreviewDiffCommand(args) {
|
|
2153
2572
|
const previewUrl = parseFlag(args, "--preview");
|
|
@@ -2157,13 +2576,6 @@ async function runPreviewDiffCommand(args) {
|
|
|
2157
2576
|
console.error(color("dim", "Usage: npx pqcheck preview-diff --preview https://preview-xyz.vercel.app --production https://example.com [--compare-transport] [--fail-on high] [--format pretty|json]"));
|
|
2158
2577
|
process.exit(3);
|
|
2159
2578
|
}
|
|
2160
|
-
if (!QP_API_KEY) {
|
|
2161
|
-
console.error(color("red", "error: pqcheck preview-diff requires CIPHERWAKE_API_KEY"));
|
|
2162
|
-
console.error(color("dim", "Generate a free key (100 calls/repo/mo) at https://cipherwake.io/account#api-keys"));
|
|
2163
|
-
console.error(color("dim", "Then: export CIPHERWAKE_API_KEY=qpk_<32-hex>"));
|
|
2164
|
-
console.error(color("dim", "Or use the GitHub Action with 'permissions: id-token: write' — no key needed."));
|
|
2165
|
-
process.exit(3);
|
|
2166
|
-
}
|
|
2167
2579
|
|
|
2168
2580
|
const compareTransport = args.includes("--compare-transport");
|
|
2169
2581
|
const failOn = parseFlag(args, "--fail-on") || "high";
|
|
@@ -2177,15 +2589,19 @@ async function runPreviewDiffCommand(args) {
|
|
|
2177
2589
|
? "report"
|
|
2178
2590
|
: "fail";
|
|
2179
2591
|
|
|
2592
|
+
const headers = {
|
|
2593
|
+
"Content-Type": "application/json",
|
|
2594
|
+
"User-Agent": `pqcheck-cli/${VERSION}`,
|
|
2595
|
+
};
|
|
2596
|
+
if (QP_API_KEY) {
|
|
2597
|
+
headers["Authorization"] = `Bearer ${QP_API_KEY}`;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2180
2600
|
let resp;
|
|
2181
2601
|
try {
|
|
2182
2602
|
resp = await fetch(`${API_BASE}/api/preview-diff`, {
|
|
2183
2603
|
method: "POST",
|
|
2184
|
-
headers
|
|
2185
|
-
"Content-Type": "application/json",
|
|
2186
|
-
"Authorization": `Bearer ${QP_API_KEY}`,
|
|
2187
|
-
"User-Agent": `pqcheck-cli/${VERSION}`,
|
|
2188
|
-
},
|
|
2604
|
+
headers,
|
|
2189
2605
|
body: JSON.stringify({
|
|
2190
2606
|
preview_url: previewUrl,
|
|
2191
2607
|
production_url: productionUrl,
|
|
@@ -2213,6 +2629,10 @@ async function runPreviewDiffCommand(args) {
|
|
|
2213
2629
|
const body = await safeJSON(resp);
|
|
2214
2630
|
console.error(color("red", `error: /api/preview-diff returned ${resp.status}`));
|
|
2215
2631
|
if (body?.message) console.error(color("dim", body.message));
|
|
2632
|
+
// Server-side may return a `hint` field on rejected inputs (e.g. localhost
|
|
2633
|
+
// / private-IP URLs surface the tunnel-options hint). Print it so the
|
|
2634
|
+
// user knows what to do next instead of just seeing the rejection.
|
|
2635
|
+
if (body?.hint) console.error(color("dim", body.hint));
|
|
2216
2636
|
process.exit(3);
|
|
2217
2637
|
}
|
|
2218
2638
|
|
|
@@ -2220,6 +2640,73 @@ async function runPreviewDiffCommand(args) {
|
|
|
2220
2640
|
const verdict = result?.result?.verdict || "pass";
|
|
2221
2641
|
const summaryLines = result?.result?.summary_lines || [];
|
|
2222
2642
|
|
|
2643
|
+
// AI Coder Mode — three-layer output (banner / body / structured block).
|
|
2644
|
+
if (parseAiMode(args)) {
|
|
2645
|
+
const maxSev = result?.result?.max_severity || "none";
|
|
2646
|
+
const hasUnexpectedDiff = summaryLines.some((l) => !/no meaningful/i.test(l));
|
|
2647
|
+
const prevScore = result?.preview?.score;
|
|
2648
|
+
const prodScore = result?.production?.score;
|
|
2649
|
+
const scoreDelta = (typeof prevScore === "number" && typeof prodScore === "number")
|
|
2650
|
+
? Number((prevScore - prodScore).toFixed(2))
|
|
2651
|
+
: null;
|
|
2652
|
+
const shipDecision = computeShipDecision({ maxSeverity: maxSev, hasUnexpectedDiff, scoreDelta });
|
|
2653
|
+
|
|
2654
|
+
const firstDelta = summaryLines.find((l) => !/no meaningful/i.test(l));
|
|
2655
|
+
const topIssue = firstDelta || "No meaningful application-surface changes between preview and production.";
|
|
2656
|
+
const whyMatters = hasUnexpectedDiff
|
|
2657
|
+
? `Preview URL introduces application-surface changes vs production. If you intentionally added (e.g.) a new third-party script or relaxed a CSP, accept and merge. If unexpected, the change is hidden in this PR's bundle — investigate before merge.`
|
|
2658
|
+
: `Your preview is application-equivalent to production. No new vendors, no header regressions, no DBR score drop.`;
|
|
2659
|
+
const nextActions = shipDecision === "pass"
|
|
2660
|
+
? [`Preview matches production. Safe to merge.`]
|
|
2661
|
+
: [
|
|
2662
|
+
`Review the changes above in this PR before merging.`,
|
|
2663
|
+
`Each "+ New third-party script" is a real new third-party request your users will make on this deploy.`,
|
|
2664
|
+
`Each "- CSP weakened" or "~ HSTS weakened" reduces production safety.`,
|
|
2665
|
+
];
|
|
2666
|
+
|
|
2667
|
+
console.log("");
|
|
2668
|
+
console.log(formatAiBanner({
|
|
2669
|
+
domain: result?.production?.domain || productionUrl,
|
|
2670
|
+
kind: "preview-diff",
|
|
2671
|
+
dbr: prevScore,
|
|
2672
|
+
grade: result?.preview?.grade,
|
|
2673
|
+
maxSeverity: maxSev,
|
|
2674
|
+
shipDecision,
|
|
2675
|
+
}));
|
|
2676
|
+
console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
|
|
2677
|
+
console.log(formatAiFooterBlock({
|
|
2678
|
+
status: shipDecision,
|
|
2679
|
+
domain: result?.production?.domain || productionUrl,
|
|
2680
|
+
kind: "preview-diff",
|
|
2681
|
+
preview_url: previewUrl,
|
|
2682
|
+
production_url: productionUrl,
|
|
2683
|
+
verdict,
|
|
2684
|
+
max_severity: maxSev,
|
|
2685
|
+
delta_count: summaryLines.filter((l) => !/no meaningful/i.test(l)).length,
|
|
2686
|
+
ship_decision: shipDecision,
|
|
2687
|
+
preview_dbr: typeof prevScore === "number" ? prevScore.toFixed(1) : "",
|
|
2688
|
+
production_dbr: typeof prodScore === "number" ? prodScore.toFixed(1) : "",
|
|
2689
|
+
score_delta: scoreDelta !== null ? scoreDelta.toString() : "",
|
|
2690
|
+
top_issue: firstDelta || "none",
|
|
2691
|
+
scanned_at: new Date().toISOString(),
|
|
2692
|
+
advisory_only: "true",
|
|
2693
|
+
}));
|
|
2694
|
+
console.log("");
|
|
2695
|
+
|
|
2696
|
+
await writeLastScanFile({
|
|
2697
|
+
domain: result?.production?.domain || productionUrl,
|
|
2698
|
+
kind: "preview-diff",
|
|
2699
|
+
preview_url: previewUrl,
|
|
2700
|
+
production_url: productionUrl,
|
|
2701
|
+
score: typeof prevScore === "number" ? prevScore : null,
|
|
2702
|
+
max_severity: maxSev,
|
|
2703
|
+
ship_decision: shipDecision,
|
|
2704
|
+
delta_count: summaryLines.filter((l) => !/no meaningful/i.test(l)).length,
|
|
2705
|
+
});
|
|
2706
|
+
|
|
2707
|
+
process.exit(shipDecisionExitCode(shipDecision));
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2223
2710
|
if (format === "json") {
|
|
2224
2711
|
console.log(JSON.stringify(result, null, 2));
|
|
2225
2712
|
} else {
|
|
@@ -2409,7 +2896,7 @@ function computeLockDiff(oldLock, newLock) {
|
|
|
2409
2896
|
|
|
2410
2897
|
// `pqcheck watch <domain>` — adds the given domain to the user's watched-
|
|
2411
2898
|
// domain list via the authenticated /api/watched-domains POST. Requires
|
|
2412
|
-
//
|
|
2899
|
+
// CIPHERWAKE_API_KEY env var. Closes the CLI ↔ account loop: developers
|
|
2413
2900
|
// who use the CLI can now opt into persistent monitoring from the same
|
|
2414
2901
|
// surface without leaving the terminal.
|
|
2415
2902
|
async function runWatchCommand(args) {
|
|
@@ -2921,9 +3408,10 @@ async function runDeployCheckCommand(args) {
|
|
|
2921
3408
|
if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
|
|
2922
3409
|
|
|
2923
3410
|
// Pre-print a deploy-context header (only in text mode — JSON/SARIF users
|
|
2924
|
-
// are scripting and don't want our preamble polluting their pipe
|
|
3411
|
+
// are scripting and don't want our preamble polluting their pipe; AI mode
|
|
3412
|
+
// emits its own banner so don't double-up).
|
|
2925
3413
|
const format = parseFormat(forwarded);
|
|
2926
|
-
if (format === "text") {
|
|
3414
|
+
if (format === "text" && !parseAiMode(forwarded)) {
|
|
2927
3415
|
console.log("");
|
|
2928
3416
|
console.log(` ${color("bold", "🚀 Deploy gate")} ${color("dim", "— checking public trust posture vs last scan")}`);
|
|
2929
3417
|
console.log("");
|
|
@@ -3541,6 +4029,1093 @@ async function tryOpenBrowser(url) {
|
|
|
3541
4029
|
});
|
|
3542
4030
|
}
|
|
3543
4031
|
|
|
4032
|
+
// =============================================================================
|
|
4033
|
+
// `pqcheck guard --domain X -- <deploy command>` — wrapper command
|
|
4034
|
+
// =============================================================================
|
|
4035
|
+
// The strongest single artifact for terminal-first AI-coder workflows.
|
|
4036
|
+
// Runs deploy-check first, conditionally executes the wrapped deploy command
|
|
4037
|
+
// based on ship_decision. AI coders type ONE command; Cipherwake controls
|
|
4038
|
+
// whether the deploy actually runs.
|
|
4039
|
+
//
|
|
4040
|
+
// Usage:
|
|
4041
|
+
// npx pqcheck guard --domain example.com -- vercel deploy --prod
|
|
4042
|
+
// npx pqcheck guard --domain example.com --gate-mode strict -- bash deploy.sh
|
|
4043
|
+
// npx pqcheck guard --domain example.com --bypass "shipping despite review" -- ...
|
|
4044
|
+
//
|
|
4045
|
+
// Exit codes:
|
|
4046
|
+
// 0 = deploy ran and succeeded (pre-check passed or user confirmed review)
|
|
4047
|
+
// 1 = pre-check returned review and user chose not to proceed
|
|
4048
|
+
// 2 = pre-check returned block and deploy was refused (use --bypass to override)
|
|
4049
|
+
// 3 = wrapper / deploy command itself errored
|
|
4050
|
+
// =============================================================================
|
|
4051
|
+
async function runGuardCommand(args) {
|
|
4052
|
+
// Parse flags and the `--` separator.
|
|
4053
|
+
const sepIdx = args.indexOf("--");
|
|
4054
|
+
const ourArgs = sepIdx >= 0 ? args.slice(0, sepIdx) : args;
|
|
4055
|
+
const deployCmd = sepIdx >= 0 ? args.slice(sepIdx + 1) : [];
|
|
4056
|
+
|
|
4057
|
+
const domain = parseFlag(ourArgs, "--domain");
|
|
4058
|
+
const gateMode = parseFlag(ourArgs, "--gate-mode") || "balanced";
|
|
4059
|
+
const bypassReason = parseFlag(ourArgs, "--bypass");
|
|
4060
|
+
const noPostCheck = ourArgs.includes("--no-post-check");
|
|
4061
|
+
|
|
4062
|
+
if (!domain) {
|
|
4063
|
+
console.error(color("red", "error: pqcheck guard requires --domain"));
|
|
4064
|
+
console.error(color("dim", "Usage: npx pqcheck guard --domain example.com -- <deploy command>"));
|
|
4065
|
+
console.error(color("dim", "Example: npx pqcheck guard --domain example.com -- vercel deploy --prod"));
|
|
4066
|
+
process.exit(3);
|
|
4067
|
+
}
|
|
4068
|
+
if (deployCmd.length === 0) {
|
|
4069
|
+
console.error(color("red", "error: pqcheck guard requires a deploy command after `--`"));
|
|
4070
|
+
console.error(color("dim", "Usage: npx pqcheck guard --domain example.com -- vercel deploy --prod"));
|
|
4071
|
+
process.exit(3);
|
|
4072
|
+
}
|
|
4073
|
+
if (!["balanced", "advisory", "strict"].includes(gateMode)) {
|
|
4074
|
+
console.error(color("red", `error: --gate-mode must be one of: balanced, advisory, strict (got "${gateMode}")`));
|
|
4075
|
+
process.exit(3);
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
const labelByMode = {
|
|
4079
|
+
balanced: "Balanced (default — review on HIGH, block on CRITICAL)",
|
|
4080
|
+
advisory: "Advisory (warnings only, deploy never blocked)",
|
|
4081
|
+
strict: "Strict (block on any finding ≥ medium)",
|
|
4082
|
+
};
|
|
4083
|
+
|
|
4084
|
+
console.log("");
|
|
4085
|
+
console.log(` ${color("bold", "◆ Cipherwake Deploy Guard")} ${color("dim", `· ${labelByMode[gateMode]}`)}`);
|
|
4086
|
+
console.log(` ${color("dim", `domain: ${domain}`)}`);
|
|
4087
|
+
console.log(` ${color("dim", `deploy: ${deployCmd.join(" ")}`)}`);
|
|
4088
|
+
console.log("");
|
|
4089
|
+
|
|
4090
|
+
if (bypassReason) {
|
|
4091
|
+
console.log(color("yellow", ` ⚠ --bypass set with reason: "${bypassReason}"`));
|
|
4092
|
+
console.log(color("dim", " The pre-deploy check will still run, but a review or block won't stop the deploy."));
|
|
4093
|
+
console.log("");
|
|
4094
|
+
}
|
|
4095
|
+
|
|
4096
|
+
// Run the pre-deploy check. We invoke ourselves (the CLI) as a subprocess
|
|
4097
|
+
// rather than calling internal functions because the deploy-check exit
|
|
4098
|
+
// code + ship_decision semantics are the contract; recreating that logic
|
|
4099
|
+
// inline would risk drift.
|
|
4100
|
+
const { spawn } = await import("node:child_process");
|
|
4101
|
+
|
|
4102
|
+
// Detect the right binary to invoke: if we're running via `npx pqcheck`
|
|
4103
|
+
// we want to re-invoke pqcheck (process.argv[1]); if we're installed
|
|
4104
|
+
// globally, same path.
|
|
4105
|
+
const selfPath = process.argv[1];
|
|
4106
|
+
|
|
4107
|
+
console.log(color("dim", " Running pre-deploy check ..."));
|
|
4108
|
+
const checkArgs = ["deploy-check", domain, "--ai"];
|
|
4109
|
+
// In advisory mode the deploy NEVER blocks — pass --fail-on none so the
|
|
4110
|
+
// server returns findings but the verdict downgrades to report.
|
|
4111
|
+
if (gateMode === "advisory") checkArgs.push("--fail-on", "none");
|
|
4112
|
+
// In strict mode block on any finding ≥ medium.
|
|
4113
|
+
if (gateMode === "strict") checkArgs.push("--fail-on", "medium");
|
|
4114
|
+
|
|
4115
|
+
let checkOutput = "";
|
|
4116
|
+
const checkExitCode = await new Promise((resolve) => {
|
|
4117
|
+
const child = spawn(process.execPath, [selfPath, ...checkArgs], {
|
|
4118
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
4119
|
+
});
|
|
4120
|
+
child.stdout.on("data", (chunk) => {
|
|
4121
|
+
const s = chunk.toString();
|
|
4122
|
+
checkOutput += s;
|
|
4123
|
+
process.stdout.write(s);
|
|
4124
|
+
});
|
|
4125
|
+
child.on("close", (code) => resolve(code ?? 3));
|
|
4126
|
+
child.on("error", () => resolve(3));
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4129
|
+
// Parse the structured CIPHERWAKE_AI_GUARD_RESULT block to extract
|
|
4130
|
+
// ship_decision (the contract field). If we can't find it, fail safe.
|
|
4131
|
+
let shipDecision = "review";
|
|
4132
|
+
const blockMatch = checkOutput.match(/CIPHERWAKE_AI_GUARD_RESULT\n([\s\S]*?)\nEND_CIPHERWAKE_AI_GUARD_RESULT/);
|
|
4133
|
+
if (blockMatch) {
|
|
4134
|
+
const sdLine = blockMatch[1].split("\n").find((l) => l.startsWith("ship_decision="));
|
|
4135
|
+
if (sdLine) shipDecision = sdLine.split("=")[1].trim();
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
console.log("");
|
|
4139
|
+
console.log(color("bold", ` Pre-deploy check returned: ship_decision=${shipDecision}`));
|
|
4140
|
+
|
|
4141
|
+
// Decide what to do.
|
|
4142
|
+
if (shipDecision === "pass") {
|
|
4143
|
+
console.log(color("green", " ✓ Posture stable — running deploy command."));
|
|
4144
|
+
console.log("");
|
|
4145
|
+
} else if (shipDecision === "review") {
|
|
4146
|
+
if (bypassReason) {
|
|
4147
|
+
console.log(color("yellow", " ⚠ Review-level finding(s) detected, but --bypass is set — proceeding."));
|
|
4148
|
+
} else if (process.stdin.isTTY) {
|
|
4149
|
+
const readline = await import("node:readline");
|
|
4150
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
4151
|
+
const answer = await new Promise((resolve) => {
|
|
4152
|
+
rl.question(color("yellow", "\n ⚠ Cipherwake flagged a review-level finding. Continue with deploy? [y/N]: "), (a) => {
|
|
4153
|
+
rl.close();
|
|
4154
|
+
resolve((a || "").trim().toLowerCase());
|
|
4155
|
+
});
|
|
4156
|
+
});
|
|
4157
|
+
if (answer !== "y" && answer !== "yes") {
|
|
4158
|
+
console.log(color("dim", " Deploy cancelled by user."));
|
|
4159
|
+
process.exit(1);
|
|
4160
|
+
}
|
|
4161
|
+
} else {
|
|
4162
|
+
console.error(color("red", " ✗ Review-level finding(s) and no interactive terminal — failing closed."));
|
|
4163
|
+
console.error(color("dim", " Re-run interactively, or pass --bypass \"<reason>\" to acknowledge."));
|
|
4164
|
+
process.exit(1);
|
|
4165
|
+
}
|
|
4166
|
+
} else if (shipDecision === "block") {
|
|
4167
|
+
if (bypassReason) {
|
|
4168
|
+
console.log(color("red", " ⚠ BLOCK-level finding, but --bypass is set with explicit reason — proceeding."));
|
|
4169
|
+
console.log(color("dim", ` Logged bypass reason: "${bypassReason}"`));
|
|
4170
|
+
} else {
|
|
4171
|
+
console.error(color("red", " ✗ Cipherwake returned BLOCK — refusing to run deploy command."));
|
|
4172
|
+
console.error(color("dim", " Investigate the finding above. To override, re-run with --bypass \"<your reason>\"."));
|
|
4173
|
+
process.exit(2);
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
// Execute the wrapped deploy command.
|
|
4178
|
+
console.log(color("dim", ` Executing: ${deployCmd.join(" ")}`));
|
|
4179
|
+
console.log("");
|
|
4180
|
+
const deployExitCode = await new Promise((resolve) => {
|
|
4181
|
+
const child = spawn(deployCmd[0], deployCmd.slice(1), { stdio: "inherit" });
|
|
4182
|
+
child.on("close", (code) => resolve(code ?? 3));
|
|
4183
|
+
child.on("error", (err) => {
|
|
4184
|
+
console.error(color("red", ` ✗ Deploy command failed to spawn: ${err.message}`));
|
|
4185
|
+
resolve(3);
|
|
4186
|
+
});
|
|
4187
|
+
});
|
|
4188
|
+
|
|
4189
|
+
if (deployExitCode !== 0) {
|
|
4190
|
+
console.error(color("red", `\n ✗ Deploy command exited with code ${deployExitCode}.`));
|
|
4191
|
+
process.exit(deployExitCode);
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
// Post-deploy check — useful for verifying the live URL after deploy.
|
|
4195
|
+
if (!noPostCheck) {
|
|
4196
|
+
console.log("");
|
|
4197
|
+
console.log(color("dim", " Running post-deploy check ..."));
|
|
4198
|
+
const postExitCode = await new Promise((resolve) => {
|
|
4199
|
+
const child = spawn(process.execPath, [selfPath, domain, "--ai", "--fresh"], { stdio: "inherit" });
|
|
4200
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
4201
|
+
child.on("error", () => resolve(0));
|
|
4202
|
+
});
|
|
4203
|
+
if (postExitCode !== 0) {
|
|
4204
|
+
console.error(color("yellow", `\n ⚠ Post-deploy check returned non-zero (${postExitCode}) — investigate before announcing.`));
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
console.log("");
|
|
4209
|
+
console.log(color("green", " ◆ Deploy guard complete."));
|
|
4210
|
+
process.exit(0);
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
// =============================================================================
|
|
4214
|
+
// `pqcheck protocol install` — Rule 17 consent-flow installer for the AI Coder
|
|
4215
|
+
// Protocol.
|
|
4216
|
+
// =============================================================================
|
|
4217
|
+
// Detects ~/.claude/CLAUDE.md and ./.cursorrules, then asks ONE upfront question
|
|
4218
|
+
// (auto / manual / no) per Rule 17. Never silently writes user config.
|
|
4219
|
+
// =============================================================================
|
|
4220
|
+
const AI_CODER_PROTOCOL_TEXT = `
|
|
4221
|
+
## Pre-deploy verification with Cipherwake
|
|
4222
|
+
|
|
4223
|
+
Before announcing any deploy of a public-facing site, you MUST run a
|
|
4224
|
+
Cipherwake deploy check and route on the result.
|
|
4225
|
+
|
|
4226
|
+
1. Run:
|
|
4227
|
+
|
|
4228
|
+
npx pqcheck deploy-check <your-domain> --ai
|
|
4229
|
+
|
|
4230
|
+
(Or wrap your deploy command with: npx pqcheck guard --domain <your-domain> -- <deploy-cmd>)
|
|
4231
|
+
|
|
4232
|
+
2. Parse the CIPHERWAKE_AI_GUARD_RESULT block at the end of stdout.
|
|
4233
|
+
Extract the \`ship_decision\` field.
|
|
4234
|
+
|
|
4235
|
+
3. Route on ship_decision:
|
|
4236
|
+
|
|
4237
|
+
• pass — Public trust surface is stable. Safe to announce.
|
|
4238
|
+
|
|
4239
|
+
• review — STOP. Surface the change to the user:
|
|
4240
|
+
|
|
4241
|
+
"Cipherwake flagged a change: <top_issue>. The public
|
|
4242
|
+
trust surface drifted between baseline and now. Want me to:
|
|
4243
|
+
1) Ship anyway (you accept the change as intentional)
|
|
4244
|
+
2) Revert the deploy
|
|
4245
|
+
3) Investigate before deciding"
|
|
4246
|
+
|
|
4247
|
+
Wait for explicit user choice. Do NOT announce the deploy
|
|
4248
|
+
until they confirm.
|
|
4249
|
+
|
|
4250
|
+
• block — STOP. Surface to the user:
|
|
4251
|
+
|
|
4252
|
+
"Cipherwake flagged a CRITICAL change: <top_issue>.
|
|
4253
|
+
I recommend not announcing this deploy until we investigate.
|
|
4254
|
+
Want to revert and look, or override?"
|
|
4255
|
+
|
|
4256
|
+
Wait for explicit user override before announcing.
|
|
4257
|
+
|
|
4258
|
+
4. Never claim a deploy is "done" or "shipped" until either:
|
|
4259
|
+
• ship_decision was pass, OR
|
|
4260
|
+
• the user explicitly confirmed they want to ship despite review/block.
|
|
4261
|
+
|
|
4262
|
+
Reference: https://cipherwake.io/methodology/ai-coder-protocol
|
|
4263
|
+
`;
|
|
4264
|
+
|
|
4265
|
+
async function runProtocolCommand(args) {
|
|
4266
|
+
const sub = args[0];
|
|
4267
|
+
if (sub !== "install") {
|
|
4268
|
+
console.error(color("red", `error: unknown protocol subcommand "${sub || ""}"`));
|
|
4269
|
+
console.error(color("dim", "Usage:"));
|
|
4270
|
+
console.error(color("dim", " Interactive: npx pqcheck protocol install"));
|
|
4271
|
+
console.error(color("dim", " Auto (incl AI): npx pqcheck protocol install --auto"));
|
|
4272
|
+
console.error(color("dim", " Print only: npx pqcheck protocol install --manual"));
|
|
4273
|
+
console.error(color("dim", " Add audit metadata: --invoked-by=\"<name>\" --consent-phrase=\"<user words>\""));
|
|
4274
|
+
process.exit(3);
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
const os = await import("node:os");
|
|
4278
|
+
const path = await import("node:path");
|
|
4279
|
+
const fs = await import("node:fs/promises");
|
|
4280
|
+
const readline = await import("node:readline");
|
|
4281
|
+
|
|
4282
|
+
// -------------------------------------------------------------------------
|
|
4283
|
+
// Flag-driven setup mode (per CLAUDE.md Rule 17 — agent-invocation path).
|
|
4284
|
+
// --auto → install to all detected files, no prompts. AI-agent-friendly.
|
|
4285
|
+
// --manual → print only, no install. Same as choosing [m] interactively.
|
|
4286
|
+
// (no flag, with TTY) → interactive [a/m/n] prompt
|
|
4287
|
+
// (no flag, no TTY) → bail with helpful error pointing at --auto/--manual
|
|
4288
|
+
//
|
|
4289
|
+
// Optional audit metadata (richer trail in install-prefs.json):
|
|
4290
|
+
// --invoked-by="<name>" — who initiated the install (AI name+version, "human", etc.)
|
|
4291
|
+
// --consent-phrase="<words>" — the literal words that authorized this (free-form)
|
|
4292
|
+
//
|
|
4293
|
+
// The flag's presence IS the consent signal — Cipherwake doesn't second-guess
|
|
4294
|
+
// a flag that's explicitly typed. The audit trail file captures who, when,
|
|
4295
|
+
// and what for customer recourse if needed.
|
|
4296
|
+
//
|
|
4297
|
+
// Rule 17's spirit is "no surprises." A flag that was explicitly passed
|
|
4298
|
+
// (by a human or by an AI on the human's behalf) is the opposite of a
|
|
4299
|
+
// surprise — it's a recorded, intentional action.
|
|
4300
|
+
// -------------------------------------------------------------------------
|
|
4301
|
+
const autoFlag = args.includes("--auto");
|
|
4302
|
+
const manualFlag = args.includes("--manual");
|
|
4303
|
+
const invokedBy = parseFlag(args, "--invoked-by") || (autoFlag ? "unknown-agent-or-script" : null);
|
|
4304
|
+
const consentPhrase = parseFlag(args, "--consent-phrase") || null;
|
|
4305
|
+
|
|
4306
|
+
if (autoFlag && manualFlag) {
|
|
4307
|
+
console.error(color("red", "error: --auto and --manual are mutually exclusive"));
|
|
4308
|
+
process.exit(3);
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
// Detect candidate files across major AI coders that:
|
|
4312
|
+
// (a) read an instructions file at session start, AND
|
|
4313
|
+
// (b) can run shell commands (so they can actually invoke pqcheck).
|
|
4314
|
+
//
|
|
4315
|
+
// Surfaces that ONLY do autocomplete (Copilot ghost text, basic Codeium,
|
|
4316
|
+
// tab-only IDEs) are intentionally not here — the protocol can't apply to
|
|
4317
|
+
// them; they need the GitHub Action PR-comment surface instead.
|
|
4318
|
+
const candidates = [
|
|
4319
|
+
{ label: "Claude Code (global)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") },
|
|
4320
|
+
{ label: "Claude Code (project)", path: path.join(process.cwd(), "CLAUDE.md") },
|
|
4321
|
+
{ label: "Cursor (project)", path: path.join(process.cwd(), ".cursorrules") },
|
|
4322
|
+
{ label: "Aider conf (project)", path: path.join(process.cwd(), ".aider.conf.yml") },
|
|
4323
|
+
{ label: "Aider conventions (project)", path: path.join(process.cwd(), "CONVENTIONS.md") },
|
|
4324
|
+
{ label: "GitHub Copilot Chat / Workspace (project)", path: path.join(process.cwd(), ".github", "copilot-instructions.md") },
|
|
4325
|
+
{ label: "Windsurf / Codeium (project)", path: path.join(process.cwd(), ".windsurfrules") },
|
|
4326
|
+
{ label: "Continue.dev (project)", path: path.join(process.cwd(), ".continuerules") },
|
|
4327
|
+
{ label: "Cline / Roo Cline (project)", path: path.join(process.cwd(), ".clinerules") },
|
|
4328
|
+
{ label: "AGENTS.md (cross-tool standard)", path: path.join(process.cwd(), "AGENTS.md") },
|
|
4329
|
+
];
|
|
4330
|
+
|
|
4331
|
+
const detected = [];
|
|
4332
|
+
for (const c of candidates) {
|
|
4333
|
+
try {
|
|
4334
|
+
await fs.access(c.path);
|
|
4335
|
+
detected.push(c);
|
|
4336
|
+
} catch { /* file doesn't exist; that's OK */ }
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
console.log("");
|
|
4340
|
+
console.log(color("bold", "◆ Cipherwake AI Coder Protocol — install"));
|
|
4341
|
+
console.log("");
|
|
4342
|
+
if (autoFlag || manualFlag) {
|
|
4343
|
+
console.log(color("dim", `Mode: ${autoFlag ? "auto (--auto flag set)" : "print-only (--manual flag set)"}`));
|
|
4344
|
+
if (invokedBy) console.log(color("dim", `Invoked by: ${invokedBy}`));
|
|
4345
|
+
if (consentPhrase) console.log(color("dim", `Consent phrase: "${consentPhrase}"`));
|
|
4346
|
+
console.log("");
|
|
4347
|
+
}
|
|
4348
|
+
console.log("Here's what would be added:");
|
|
4349
|
+
console.log("");
|
|
4350
|
+
if (detected.length === 0) {
|
|
4351
|
+
console.log(color("dim", " No existing CLAUDE.md / .cursorrules / .aider.conf.yml found."));
|
|
4352
|
+
console.log(color("dim", " Creating ~/.claude/CLAUDE.md with the protocol."));
|
|
4353
|
+
detected.push({ label: "Claude Code (will create)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") });
|
|
4354
|
+
}
|
|
4355
|
+
for (const d of detected) {
|
|
4356
|
+
console.log(` • Append a ~30-line "## Pre-deploy verification with Cipherwake" section to ${color("bold", d.path)}`);
|
|
4357
|
+
console.log(` ${color("dim", `(${d.label} — existing content preserved)`)}`);
|
|
4358
|
+
}
|
|
4359
|
+
console.log("");
|
|
4360
|
+
|
|
4361
|
+
// --manual → print only, no install
|
|
4362
|
+
if (manualFlag) {
|
|
4363
|
+
console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
|
|
4364
|
+
console.log(AI_CODER_PROTOCOL_TEXT);
|
|
4365
|
+
console.log(color("bold", "── End of protocol ──"));
|
|
4366
|
+
console.log("");
|
|
4367
|
+
console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
|
|
4368
|
+
process.exit(0);
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
// --auto → install to all detected files
|
|
4372
|
+
if (autoFlag) {
|
|
4373
|
+
console.log(color("dim", "Auto-install (--auto flag set). Writing audit trail to ~/.config/cipherwake/install-prefs.json."));
|
|
4374
|
+
console.log("");
|
|
4375
|
+
return await performAutoInstall(detected, {
|
|
4376
|
+
mode: "auto-flag",
|
|
4377
|
+
consent_phrase: consentPhrase,
|
|
4378
|
+
invoked_by: invokedBy,
|
|
4379
|
+
fs, path, os,
|
|
4380
|
+
});
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
// Interactive (human-at-terminal) path.
|
|
4384
|
+
console.log("Per CLAUDE.md Rule 17, Cipherwake never modifies your config without asking.");
|
|
4385
|
+
console.log("");
|
|
4386
|
+
console.log(" [a]uto — I add the protocol to all detected files + show you a diff afterward");
|
|
4387
|
+
console.log(" [m]anual — Print the protocol so you can paste it yourself");
|
|
4388
|
+
console.log(" [n]o — Skip; you can re-run anytime with `npx pqcheck protocol install`");
|
|
4389
|
+
console.log("");
|
|
4390
|
+
|
|
4391
|
+
if (!process.stdin.isTTY) {
|
|
4392
|
+
console.error(color("red", "error: pqcheck protocol install requires an interactive terminal — OR an explicit --auto / --manual flag."));
|
|
4393
|
+
console.error(color("dim", "Re-run in an interactive shell, OR pass one of:"));
|
|
4394
|
+
console.error(color("dim", " --auto (install to all detected files; AI-friendly)"));
|
|
4395
|
+
console.error(color("dim", " --manual (print protocol so you can paste it yourself; no install)"));
|
|
4396
|
+
console.error(color("dim", "Optional audit metadata: --invoked-by=\"<name>\" --consent-phrase=\"<words>\""));
|
|
4397
|
+
process.exit(3);
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
4401
|
+
const choice = await new Promise((resolve) => {
|
|
4402
|
+
rl.question("Choose [a/m/n]: ", (a) => {
|
|
4403
|
+
rl.close();
|
|
4404
|
+
resolve((a || "").trim().toLowerCase());
|
|
4405
|
+
});
|
|
4406
|
+
});
|
|
4407
|
+
|
|
4408
|
+
if (choice === "n" || choice === "no") {
|
|
4409
|
+
console.log("");
|
|
4410
|
+
console.log(color("dim", "Skipped. Re-run anytime: npx pqcheck protocol install"));
|
|
4411
|
+
process.exit(0);
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
if (choice === "m" || choice === "manual") {
|
|
4415
|
+
console.log("");
|
|
4416
|
+
console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
|
|
4417
|
+
console.log(AI_CODER_PROTOCOL_TEXT);
|
|
4418
|
+
console.log(color("bold", "── End of protocol ──"));
|
|
4419
|
+
console.log("");
|
|
4420
|
+
console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
|
|
4421
|
+
process.exit(0);
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
if (choice === "a" || choice === "auto") {
|
|
4425
|
+
return await performAutoInstall(detected, {
|
|
4426
|
+
mode: "interactive",
|
|
4427
|
+
consent_phrase: "user typed [a] at the install prompt",
|
|
4428
|
+
invoked_by: "human-at-terminal",
|
|
4429
|
+
fs, path, os,
|
|
4430
|
+
});
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
console.error(color("red", `Unknown choice "${choice}". Expected a / m / n.`));
|
|
4434
|
+
process.exit(3);
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
// Shared install routine — used by both interactive and agent-invoked paths.
|
|
4438
|
+
// Writes the protocol to each detected file and records the audit trail.
|
|
4439
|
+
async function performAutoInstall(detected, opts) {
|
|
4440
|
+
const { fs, path, os } = opts;
|
|
4441
|
+
const results = [];
|
|
4442
|
+
for (const d of detected) {
|
|
4443
|
+
try {
|
|
4444
|
+
await fs.mkdir(path.dirname(d.path), { recursive: true });
|
|
4445
|
+
let existing = "";
|
|
4446
|
+
try { existing = await fs.readFile(d.path, "utf8"); } catch { /* file may not exist yet */ }
|
|
4447
|
+
if (existing.includes("## Pre-deploy verification with Cipherwake")) {
|
|
4448
|
+
console.log(color("dim", ` ⊝ ${d.path} — already contains the protocol, skipping.`));
|
|
4449
|
+
results.push({ path: d.path, label: d.label, status: "skipped-already-present" });
|
|
4450
|
+
continue;
|
|
4451
|
+
}
|
|
4452
|
+
const newContent = existing + "\n" + AI_CODER_PROTOCOL_TEXT + "\n";
|
|
4453
|
+
await fs.writeFile(d.path, newContent, "utf8");
|
|
4454
|
+
console.log(color("green", ` ✓ ${d.path} — protocol appended (${AI_CODER_PROTOCOL_TEXT.split("\n").length} lines)`));
|
|
4455
|
+
results.push({ path: d.path, label: d.label, status: "installed" });
|
|
4456
|
+
} catch (err) {
|
|
4457
|
+
console.log(color("red", ` ✗ ${d.path} — failed: ${err.message}`));
|
|
4458
|
+
results.push({ path: d.path, label: d.label, status: "failed", error: String(err?.message || err) });
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
// Persist the audit trail to ~/.config/cipherwake/install-prefs.json.
|
|
4463
|
+
// This is the customer's recourse if they ever dispute the install — the
|
|
4464
|
+
// file shows who invoked it, when, and the exact consent phrase used.
|
|
4465
|
+
try {
|
|
4466
|
+
const prefsDir = path.join(os.homedir(), ".config", "cipherwake");
|
|
4467
|
+
await fs.mkdir(prefsDir, { recursive: true });
|
|
4468
|
+
const prefsPath = path.join(prefsDir, "install-prefs.json");
|
|
4469
|
+
let prefs = { history: [] };
|
|
4470
|
+
try {
|
|
4471
|
+
const existing = await fs.readFile(prefsPath, "utf8");
|
|
4472
|
+
prefs = JSON.parse(existing);
|
|
4473
|
+
if (!Array.isArray(prefs.history)) prefs.history = [];
|
|
4474
|
+
} catch { /* first run, file doesn't exist or is malformed */ }
|
|
4475
|
+
prefs.history.push({
|
|
4476
|
+
timestamp: new Date().toISOString(),
|
|
4477
|
+
command: "protocol-install",
|
|
4478
|
+
mode: opts.mode,
|
|
4479
|
+
consent_phrase: opts.consent_phrase,
|
|
4480
|
+
invoked_by: opts.invoked_by,
|
|
4481
|
+
cwd: process.cwd(),
|
|
4482
|
+
hostname: os.hostname(),
|
|
4483
|
+
pqcheck_version: VERSION,
|
|
4484
|
+
results,
|
|
4485
|
+
});
|
|
4486
|
+
await fs.writeFile(prefsPath, JSON.stringify(prefs, null, 2), "utf8");
|
|
4487
|
+
console.log(color("dim", ` Audit trail: ${prefsPath}`));
|
|
4488
|
+
} catch (err) {
|
|
4489
|
+
console.log(color("dim", ` (audit trail write failed: ${err.message} — non-fatal)`));
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4492
|
+
console.log("");
|
|
4493
|
+
console.log(color("green", "Done. Your AI coder will follow the protocol on its next deploy."));
|
|
4494
|
+
console.log(color("dim", "Reference: https://cipherwake.io/methodology/ai-coder-protocol"));
|
|
4495
|
+
process.exit(0);
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
// =============================================================================
|
|
4499
|
+
// `pqcheck setup --auto --domain <D>` — consolidated installer
|
|
4500
|
+
// =============================================================================
|
|
4501
|
+
// One command installs everything an AI-coder workflow needs:
|
|
4502
|
+
// 1. GitHub Action workflow file (CI hard gate)
|
|
4503
|
+
// 2. AI Coder Protocol across all detected rules files (CLAUDE.md /
|
|
4504
|
+
// .cursorrules / .github/copilot-instructions.md / .aider.conf.yml /
|
|
4505
|
+
// AGENTS.md / etc.) — multi-platform, defense-in-depth
|
|
4506
|
+
// 3. Git pre-push hook (catches manual git push origin main bypasses)
|
|
4507
|
+
// 4. Claude Code statusLine config in ~/.claude/settings.json (per-flag
|
|
4508
|
+
// consent counts as Rule 17 consent; recorded in audit trail)
|
|
4509
|
+
// 5. VS Code extension via `code --install-extension` if `code` CLI on PATH
|
|
4510
|
+
// (skipped silently if not available)
|
|
4511
|
+
//
|
|
4512
|
+
// The pinnedai-equivalent for Cipherwake. Launch story: "One command, every
|
|
4513
|
+
// AI coder ready."
|
|
4514
|
+
//
|
|
4515
|
+
// Usage:
|
|
4516
|
+
// npx pqcheck setup --auto --domain cipherwake.io
|
|
4517
|
+
// npx pqcheck setup --auto --domain cipherwake.io \\
|
|
4518
|
+
// --invoked-by "Claude Code" --consent-phrase "the user said yes do B"
|
|
4519
|
+
// =============================================================================
|
|
4520
|
+
|
|
4521
|
+
const GIT_PREPUSH_HOOK_SCRIPT = `#!/usr/bin/env bash
|
|
4522
|
+
# Cipherwake pre-push hook — installed by \`pqcheck setup --auto\`
|
|
4523
|
+
#
|
|
4524
|
+
# Runs deploy-check before pushes to deploy-triggering branches. Catches the
|
|
4525
|
+
# case where a human (or an AI that forgot the protocol) pushes directly to
|
|
4526
|
+
# main without running deploy-check first. Defense-in-depth alongside the
|
|
4527
|
+
# GitHub Action + the AI Coder Protocol.
|
|
4528
|
+
#
|
|
4529
|
+
# To bypass for a single push: CIPHERWAKE_HOOK_SKIP=1 git push
|
|
4530
|
+
# To uninstall: rm .git/hooks/pre-push (or pqcheck setup --uninstall — TBD)
|
|
4531
|
+
|
|
4532
|
+
CIPHERWAKE_DOMAIN="\${CIPHERWAKE_DOMAIN:-DOMAIN_PLACEHOLDER}"
|
|
4533
|
+
DEPLOY_BRANCHES_REGEX="\${CIPHERWAKE_DEPLOY_BRANCHES:-^refs/heads/(main|master|production|deploy)$}"
|
|
4534
|
+
|
|
4535
|
+
if [ "\$CIPHERWAKE_HOOK_SKIP" = "1" ]; then
|
|
4536
|
+
echo "▶ Cipherwake hook: CIPHERWAKE_HOOK_SKIP=1 — bypassing"
|
|
4537
|
+
exit 0
|
|
4538
|
+
fi
|
|
4539
|
+
|
|
4540
|
+
# Read which refs are being pushed. We only act on pushes to deploy branches.
|
|
4541
|
+
SHOULD_RUN=0
|
|
4542
|
+
while read local_ref local_sha remote_ref remote_sha; do
|
|
4543
|
+
if [[ "\$remote_ref" =~ \$DEPLOY_BRANCHES_REGEX ]]; then
|
|
4544
|
+
SHOULD_RUN=1
|
|
4545
|
+
break
|
|
4546
|
+
fi
|
|
4547
|
+
done
|
|
4548
|
+
|
|
4549
|
+
if [ "\$SHOULD_RUN" = "0" ]; then
|
|
4550
|
+
exit 0
|
|
4551
|
+
fi
|
|
4552
|
+
|
|
4553
|
+
echo "▶ Cipherwake pre-push: running deploy-check on \$CIPHERWAKE_DOMAIN..."
|
|
4554
|
+
|
|
4555
|
+
# npx pqcheck deploy-check exits: 0=pass, 1=warn/review, 2=fail/block, 3=error
|
|
4556
|
+
npx --yes pqcheck deploy-check "\$CIPHERWAKE_DOMAIN" --ai
|
|
4557
|
+
RC=\$?
|
|
4558
|
+
|
|
4559
|
+
case "\$RC" in
|
|
4560
|
+
0)
|
|
4561
|
+
echo "▶ Cipherwake: ship_decision=pass — proceeding with push"
|
|
4562
|
+
exit 0
|
|
4563
|
+
;;
|
|
4564
|
+
1)
|
|
4565
|
+
echo ""
|
|
4566
|
+
echo "▶ Cipherwake flagged a REVIEW-level finding (see output above)."
|
|
4567
|
+
echo "▶ Push allowed — but please review the change before announcing the deploy."
|
|
4568
|
+
echo "▶ To suppress this notice for one push: CIPHERWAKE_HOOK_SKIP=1 git push"
|
|
4569
|
+
exit 0
|
|
4570
|
+
;;
|
|
4571
|
+
2)
|
|
4572
|
+
echo ""
|
|
4573
|
+
echo "✗ Cipherwake returned BLOCK — refusing push."
|
|
4574
|
+
echo "✗ Investigate the finding above before deploying."
|
|
4575
|
+
echo "✗ To override (with explicit acknowledgement): CIPHERWAKE_HOOK_SKIP=1 git push"
|
|
4576
|
+
exit 1
|
|
4577
|
+
;;
|
|
4578
|
+
*)
|
|
4579
|
+
echo "▶ Cipherwake pre-check errored (exit \$RC) — allowing push (fail-open)."
|
|
4580
|
+
echo "▶ Set CIPHERWAKE_API_KEY if deploy-check is failing on auth."
|
|
4581
|
+
exit 0
|
|
4582
|
+
;;
|
|
4583
|
+
esac
|
|
4584
|
+
`;
|
|
4585
|
+
|
|
4586
|
+
const CLAUDE_STATUSLINE_CONFIG_SNIPPET = `
|
|
4587
|
+
"statusLine": {
|
|
4588
|
+
"type": "command",
|
|
4589
|
+
"command": "npx cipherwake-statusline"
|
|
4590
|
+
}`;
|
|
4591
|
+
|
|
4592
|
+
// R74-confirm SHIP #14-15 (GPT 2026-05-22): network connectivity diagnostic.
|
|
4593
|
+
// Customer runs this when "scan hung" or "command not found" to surface the
|
|
4594
|
+
// actual broken hop instead of guessing. Tests: DNS resolution → TCP → TLS
|
|
4595
|
+
// → HTTP for each upstream.
|
|
4596
|
+
async function runDebugNetworkCommand() {
|
|
4597
|
+
console.log("");
|
|
4598
|
+
console.log(color("bold", "◆ Cipherwake — network diagnostic"));
|
|
4599
|
+
console.log(color("dim", "Probes every upstream the CLI depends on. Run this when scans hang or fail."));
|
|
4600
|
+
console.log("");
|
|
4601
|
+
|
|
4602
|
+
const probes = [
|
|
4603
|
+
{ name: "cipherwake.io (API)", url: "https://cipherwake.io/api/scan?domain=cipherwake.io" },
|
|
4604
|
+
{ name: "cipherwake.io (homepage)", url: "https://cipherwake.io/" },
|
|
4605
|
+
{ name: "crt.sh (CT log upstream)", url: "https://crt.sh/?q=%25.cipherwake.io&output=json" },
|
|
4606
|
+
{ name: "Vercel direct (bypass Cloudflare)", url: "https://quantapact.vercel.app/" },
|
|
4607
|
+
];
|
|
4608
|
+
|
|
4609
|
+
let anyFailed = false;
|
|
4610
|
+
for (const p of probes) {
|
|
4611
|
+
const t0 = Date.now();
|
|
4612
|
+
try {
|
|
4613
|
+
const ctrl = new AbortController();
|
|
4614
|
+
const tmr = setTimeout(() => ctrl.abort(), 10000);
|
|
4615
|
+
const resp = await fetch(p.url, { method: "HEAD", signal: ctrl.signal });
|
|
4616
|
+
clearTimeout(tmr);
|
|
4617
|
+
const elapsed = Date.now() - t0;
|
|
4618
|
+
const statusOk = resp.status >= 200 && resp.status < 500;
|
|
4619
|
+
const marker = statusOk ? color("green", "✓") : color("yellow", "⚠");
|
|
4620
|
+
console.log(` ${marker} ${p.name.padEnd(38)} HTTP ${resp.status} ${elapsed}ms`);
|
|
4621
|
+
if (!statusOk) anyFailed = true;
|
|
4622
|
+
} catch (err) {
|
|
4623
|
+
anyFailed = true;
|
|
4624
|
+
const elapsed = Date.now() - t0;
|
|
4625
|
+
const msg = (err && err.message) || String(err);
|
|
4626
|
+
const kind = err?.name === "AbortError" ? "timeout" : "error";
|
|
4627
|
+
console.log(` ${color("red", "✗")} ${p.name.padEnd(38)} ${kind.toUpperCase()} ${elapsed}ms ${msg.slice(0, 60)}`);
|
|
4628
|
+
}
|
|
4629
|
+
}
|
|
4630
|
+
|
|
4631
|
+
console.log("");
|
|
4632
|
+
if (anyFailed) {
|
|
4633
|
+
console.log(color("bold", "Possible causes (in order of likelihood):"));
|
|
4634
|
+
console.log(" 1. Corporate proxy / VPN blocking outbound HTTPS to public scanners.");
|
|
4635
|
+
console.log(" → Try setting HTTPS_PROXY env var, or run from a non-corporate network.");
|
|
4636
|
+
console.log(" 2. Cloudflare WAF rate-limited your IP. Wait 5-15min and retry.");
|
|
4637
|
+
console.log(" → Or switch networks (mobile hotspot bypasses your home IP).");
|
|
4638
|
+
console.log(" 3. DNS resolver doesn't resolve cipherwake.io.");
|
|
4639
|
+
console.log(" → Test with: `dig cipherwake.io`. If it fails, your resolver is offline.");
|
|
4640
|
+
console.log(" 4. crt.sh is intermittently slow — that's the upstream we use for CT logs.");
|
|
4641
|
+
console.log(" → Cipherwake degrades to a 14-day stale-cache fallback when crt.sh fails, so this");
|
|
4642
|
+
console.log(" only matters on first scan of brand-new domains.");
|
|
4643
|
+
console.log("");
|
|
4644
|
+
console.log(color("dim", "If you're still blocked, file a bug at https://cipherwake.io/feedback"));
|
|
4645
|
+
process.exit(1);
|
|
4646
|
+
} else {
|
|
4647
|
+
console.log(color("green", " ✓ All upstreams reachable — Cipherwake should work for you."));
|
|
4648
|
+
console.log("");
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
async function runSetupCommand(args) {
|
|
4653
|
+
const autoFlag = args.includes("--auto");
|
|
4654
|
+
const planFlag = args.includes("--plan"); // R74-confirm BLOCKING #19 (GPT 2026-05-22)
|
|
4655
|
+
const domain = parseFlag(args, "--domain");
|
|
4656
|
+
const failOn = parseFlag(args, "--fail-on") || "high";
|
|
4657
|
+
const baseline = parseFlag(args, "--baseline") || "last-scan";
|
|
4658
|
+
const invokedBy = parseFlag(args, "--invoked-by") || (autoFlag ? "unknown-agent-or-script" : null);
|
|
4659
|
+
const consentPhrase = parseFlag(args, "--consent-phrase") || null;
|
|
4660
|
+
const skipWorkflow = args.includes("--skip-workflow");
|
|
4661
|
+
const skipProtocol = args.includes("--skip-protocol");
|
|
4662
|
+
const skipHook = args.includes("--skip-hook");
|
|
4663
|
+
const skipStatusline = args.includes("--skip-statusline");
|
|
4664
|
+
const skipVscode = args.includes("--skip-vscode");
|
|
4665
|
+
|
|
4666
|
+
if (!domain) {
|
|
4667
|
+
console.error(color("red", "error: pqcheck setup requires --domain"));
|
|
4668
|
+
console.error(color("dim", "Usage: npx pqcheck setup --auto --domain example.com"));
|
|
4669
|
+
console.error(color("dim", " --plan Print the install plan without writing any files"));
|
|
4670
|
+
console.error(color("dim", " --invoked-by=\"<name>\" --consent-phrase=\"<words>\" (audit trail)"));
|
|
4671
|
+
console.error(color("dim", "Skip flags: --skip-workflow --skip-protocol --skip-hook --skip-statusline --skip-vscode"));
|
|
4672
|
+
process.exit(3);
|
|
4673
|
+
}
|
|
4674
|
+
if (!autoFlag && !planFlag && !process.stdin.isTTY) {
|
|
4675
|
+
console.error(color("red", "error: pqcheck setup requires --auto or --plan when stdin is not a TTY"));
|
|
4676
|
+
console.error(color("dim", "Pass --auto explicitly (the flag IS the consent signal per CLAUDE.md Rule 17),"));
|
|
4677
|
+
console.error(color("dim", "or --plan to see what would be installed without writing anything."));
|
|
4678
|
+
process.exit(3);
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
// R74-confirm BLOCKING #19 (GPT 2026-05-22): --plan mode prints the
|
|
4682
|
+
// intended changes without writing. Addresses GPT's "init modifies too
|
|
4683
|
+
// much without a clear dry run" concern. Customer can run --plan first
|
|
4684
|
+
// to inspect, then run --auto when they're comfortable. AI agents are
|
|
4685
|
+
// encouraged (per the protocol page) to run --plan first when no recent
|
|
4686
|
+
// user consent for --auto exists in the conversation.
|
|
4687
|
+
if (planFlag) {
|
|
4688
|
+
const os = await import("node:os");
|
|
4689
|
+
const path = await import("node:path");
|
|
4690
|
+
const fs = await import("node:fs/promises");
|
|
4691
|
+
console.log("");
|
|
4692
|
+
console.log(color("bold", `◆ Cipherwake Setup — PLAN (dry run, no files will be written)`));
|
|
4693
|
+
console.log(color("dim", `Domain target: ${domain}`));
|
|
4694
|
+
console.log("");
|
|
4695
|
+
console.log(color("bold", "Files this install would touch:"));
|
|
4696
|
+
const planEntries = [];
|
|
4697
|
+
if (!skipWorkflow) planEntries.push({ what: "GitHub Action workflow", to: path.join(process.cwd(), ".github", "workflows", "cipherwake.yml"), op: "create" });
|
|
4698
|
+
if (!skipProtocol) {
|
|
4699
|
+
const candidates = [
|
|
4700
|
+
{ label: "Claude Code (global)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") },
|
|
4701
|
+
{ label: "Claude Code (project)", path: path.join(process.cwd(), "CLAUDE.md") },
|
|
4702
|
+
{ label: "Cursor", path: path.join(process.cwd(), ".cursorrules") },
|
|
4703
|
+
{ label: "GitHub Copilot", path: path.join(process.cwd(), ".github", "copilot-instructions.md") },
|
|
4704
|
+
{ label: "Aider", path: path.join(process.cwd(), ".aider.conf.yml") },
|
|
4705
|
+
{ label: "AGENTS.md", path: path.join(process.cwd(), "AGENTS.md") },
|
|
4706
|
+
];
|
|
4707
|
+
for (const c of candidates) {
|
|
4708
|
+
let exists = false;
|
|
4709
|
+
try { await fs.access(c.path); exists = true; } catch { /* */ }
|
|
4710
|
+
planEntries.push({ what: `AI Coder Protocol — ${c.label}`, to: c.path, op: exists ? "append-markered" : "skip (file not present)" });
|
|
4711
|
+
}
|
|
4712
|
+
}
|
|
4713
|
+
if (!skipHook) planEntries.push({ what: "git pre-push hook", to: path.join(process.cwd(), ".git", "hooks", "pre-push"), op: "create (if git repo)" });
|
|
4714
|
+
if (!skipStatusline) {
|
|
4715
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
4716
|
+
let exists = false;
|
|
4717
|
+
try { await fs.access(settingsPath); exists = true; } catch { /* */ }
|
|
4718
|
+
planEntries.push({ what: "Claude Code statusLine", to: settingsPath, op: exists ? "deep-merge (backup first)" : "create" });
|
|
4719
|
+
planEntries.push({ what: "Claude Code chat-hook (PostToolUse Bash)", to: settingsPath, op: exists ? "deep-merge into hooks.PostToolUse" : "create" });
|
|
4720
|
+
}
|
|
4721
|
+
if (!skipVscode) planEntries.push({ what: "VS Code / Cursor extension", to: "via `code --install-extension cipherwakelabs.cipherwake-statusbar`", op: "attempt (soft-fail if Marketplace listing missing)" });
|
|
4722
|
+
for (const e of planEntries) {
|
|
4723
|
+
console.log(` ${color("dim", e.op.padEnd(28))} ${color("bold", e.what)}`);
|
|
4724
|
+
console.log(` ${color("dim", "→")} ${e.to}`);
|
|
4725
|
+
}
|
|
4726
|
+
console.log("");
|
|
4727
|
+
console.log(color("bold", "To proceed:"));
|
|
4728
|
+
console.log(` npx pqcheck setup --auto --domain ${domain}${invokedBy ? ` --invoked-by "${invokedBy}"` : ""}${consentPhrase ? ` --consent-phrase "${consentPhrase}"` : ""}`);
|
|
4729
|
+
console.log("");
|
|
4730
|
+
console.log(color("dim", "Per Cipherwake Rule 17, --auto is the consent signal. Backups are taken before each settings.json write."));
|
|
4731
|
+
console.log("");
|
|
4732
|
+
return;
|
|
4733
|
+
}
|
|
4734
|
+
|
|
4735
|
+
const os = await import("node:os");
|
|
4736
|
+
const path = await import("node:path");
|
|
4737
|
+
const fs = await import("node:fs/promises");
|
|
4738
|
+
const { spawn } = await import("node:child_process");
|
|
4739
|
+
|
|
4740
|
+
console.log("");
|
|
4741
|
+
console.log(color("bold", `◆ Cipherwake Setup — ${domain}`));
|
|
4742
|
+
console.log("");
|
|
4743
|
+
if (autoFlag) {
|
|
4744
|
+
console.log(color("dim", "Auto mode (--auto flag set). Per CLAUDE.md Rule 17, the flag is the consent signal."));
|
|
4745
|
+
if (invokedBy) console.log(color("dim", `Invoked by: ${invokedBy}`));
|
|
4746
|
+
if (consentPhrase) console.log(color("dim", `Consent phrase: "${consentPhrase}"`));
|
|
4747
|
+
console.log("");
|
|
4748
|
+
}
|
|
4749
|
+
|
|
4750
|
+
const installSummary = [];
|
|
4751
|
+
|
|
4752
|
+
// -------------------------------------------------------------------------
|
|
4753
|
+
// Component 1: GitHub Action workflow (.github/workflows/cipherwake.yml)
|
|
4754
|
+
// -------------------------------------------------------------------------
|
|
4755
|
+
if (!skipWorkflow) {
|
|
4756
|
+
const workflowPath = path.join(process.cwd(), ".github", "workflows", "cipherwake.yml");
|
|
4757
|
+
try {
|
|
4758
|
+
try {
|
|
4759
|
+
await fs.access(workflowPath);
|
|
4760
|
+
console.log(color("dim", ` ⊝ workflow at .github/workflows/cipherwake.yml already exists — skipping`));
|
|
4761
|
+
installSummary.push({ component: "GitHub Action workflow", path: workflowPath, status: "skipped-already-present" });
|
|
4762
|
+
} catch {
|
|
4763
|
+
const workflowYaml = renderTrustDiffWorkflow({ domain, failOn, baseline });
|
|
4764
|
+
await fs.mkdir(path.dirname(workflowPath), { recursive: true });
|
|
4765
|
+
await fs.writeFile(workflowPath, workflowYaml, "utf8");
|
|
4766
|
+
console.log(color("green", ` ✓ wrote .github/workflows/cipherwake.yml (CI hard-gate layer)`));
|
|
4767
|
+
installSummary.push({ component: "GitHub Action workflow", path: workflowPath, status: "installed" });
|
|
4768
|
+
}
|
|
4769
|
+
} catch (err) {
|
|
4770
|
+
console.log(color("red", ` ✗ workflow install failed: ${err.message}`));
|
|
4771
|
+
installSummary.push({ component: "GitHub Action workflow", status: "failed", error: String(err?.message || err) });
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
|
|
4775
|
+
// -------------------------------------------------------------------------
|
|
4776
|
+
// Component 2: AI Coder Protocol across detected rules files
|
|
4777
|
+
// -------------------------------------------------------------------------
|
|
4778
|
+
if (!skipProtocol) {
|
|
4779
|
+
const candidates = [
|
|
4780
|
+
{ label: "Claude Code (global)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") },
|
|
4781
|
+
{ label: "Claude Code (project)", path: path.join(process.cwd(), "CLAUDE.md") },
|
|
4782
|
+
{ label: "Cursor (project)", path: path.join(process.cwd(), ".cursorrules") },
|
|
4783
|
+
{ label: "Aider conf (project)", path: path.join(process.cwd(), ".aider.conf.yml") },
|
|
4784
|
+
{ label: "Aider conventions (project)", path: path.join(process.cwd(), "CONVENTIONS.md") },
|
|
4785
|
+
{ label: "GitHub Copilot Chat / Workspace (project)", path: path.join(process.cwd(), ".github", "copilot-instructions.md") },
|
|
4786
|
+
{ label: "Windsurf / Codeium (project)", path: path.join(process.cwd(), ".windsurfrules") },
|
|
4787
|
+
{ label: "Continue.dev (project)", path: path.join(process.cwd(), ".continuerules") },
|
|
4788
|
+
{ label: "Cline / Roo Cline (project)", path: path.join(process.cwd(), ".clinerules") },
|
|
4789
|
+
{ label: "AGENTS.md (cross-tool standard)", path: path.join(process.cwd(), "AGENTS.md") },
|
|
4790
|
+
];
|
|
4791
|
+
// Detect any existing files; auto-create the global Claude Code file so
|
|
4792
|
+
// every install yields at least one rules-file landing site.
|
|
4793
|
+
const protocolTargets = [];
|
|
4794
|
+
for (const c of candidates) {
|
|
4795
|
+
try {
|
|
4796
|
+
await fs.access(c.path);
|
|
4797
|
+
protocolTargets.push(c);
|
|
4798
|
+
} catch { /* skip */ }
|
|
4799
|
+
}
|
|
4800
|
+
if (protocolTargets.length === 0) {
|
|
4801
|
+
protocolTargets.push({ label: "Claude Code (created)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") });
|
|
4802
|
+
}
|
|
4803
|
+
// R74-confirm BLOCKING #6 (GPT 2026-05-22): wrap the protocol section in
|
|
4804
|
+
// fenced markers so future installs / updates / removals can locate and
|
|
4805
|
+
// replace exactly this block without scanning for the heading. Idempotent:
|
|
4806
|
+
// if markers exist, the block between them is replaced; if neither markers
|
|
4807
|
+
// nor heading exist, the block is appended. Always preserves whatever the
|
|
4808
|
+
// user has written outside the markers.
|
|
4809
|
+
const START_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_START — managed by pqcheck setup; safe to delete this section between markers but do not edit by hand -->";
|
|
4810
|
+
const END_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_END -->";
|
|
4811
|
+
for (const t of protocolTargets) {
|
|
4812
|
+
try {
|
|
4813
|
+
await fs.mkdir(path.dirname(t.path), { recursive: true });
|
|
4814
|
+
let existing = "";
|
|
4815
|
+
try { existing = await fs.readFile(t.path, "utf8"); } catch { /* file may not exist */ }
|
|
4816
|
+
|
|
4817
|
+
const hasMarkers = existing.includes(START_MARKER) && existing.includes(END_MARKER);
|
|
4818
|
+
const hasLegacyHeading = existing.includes("## Pre-deploy verification with Cipherwake");
|
|
4819
|
+
|
|
4820
|
+
if (hasMarkers) {
|
|
4821
|
+
// Replace just the bounded section — leaves all other user content untouched.
|
|
4822
|
+
const startIdx = existing.indexOf(START_MARKER);
|
|
4823
|
+
const endIdx = existing.indexOf(END_MARKER) + END_MARKER.length;
|
|
4824
|
+
const before = existing.slice(0, startIdx);
|
|
4825
|
+
const after = existing.slice(endIdx);
|
|
4826
|
+
const next = `${before.replace(/\n+$/, "")}\n\n${START_MARKER}\n${AI_CODER_PROTOCOL_TEXT}\n${END_MARKER}\n${after.replace(/^\n+/, "")}`;
|
|
4827
|
+
if (next === existing) {
|
|
4828
|
+
console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — protocol already current`));
|
|
4829
|
+
installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-already-present" });
|
|
4830
|
+
continue;
|
|
4831
|
+
}
|
|
4832
|
+
await fs.writeFile(t.path, next, "utf8");
|
|
4833
|
+
console.log(color("green", ` ✓ refreshed protocol section → ${path.basename(t.path)} (${t.label})`));
|
|
4834
|
+
installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "installed-updated" });
|
|
4835
|
+
} else if (hasLegacyHeading) {
|
|
4836
|
+
// Old install lacks markers. Don't double-append; treat as already present.
|
|
4837
|
+
// Note: customers can re-run `pqcheck protocol install --auto` to upgrade to the markered form.
|
|
4838
|
+
console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — legacy unmarkered protocol present (run \`pqcheck protocol install --auto\` to upgrade to markered form)`));
|
|
4839
|
+
installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-legacy-present" });
|
|
4840
|
+
} else {
|
|
4841
|
+
// Fresh install — append with fenced markers.
|
|
4842
|
+
const sep = existing.length > 0 ? "\n\n" : "";
|
|
4843
|
+
await fs.writeFile(t.path, `${existing}${sep}${START_MARKER}\n${AI_CODER_PROTOCOL_TEXT}\n${END_MARKER}\n`, "utf8");
|
|
4844
|
+
console.log(color("green", ` ✓ appended protocol (markered) → ${path.basename(t.path)} (${t.label})`));
|
|
4845
|
+
installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "installed" });
|
|
4846
|
+
}
|
|
4847
|
+
} catch (err) {
|
|
4848
|
+
console.log(color("red", ` ✗ ${t.path} — failed: ${err.message}`));
|
|
4849
|
+
installSummary.push({ component: "AI Coder Protocol", path: t.path, status: "failed", error: String(err?.message || err) });
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
|
|
4854
|
+
// -------------------------------------------------------------------------
|
|
4855
|
+
// Component 3: Git pre-push hook
|
|
4856
|
+
// -------------------------------------------------------------------------
|
|
4857
|
+
if (!skipHook) {
|
|
4858
|
+
const gitDir = path.join(process.cwd(), ".git");
|
|
4859
|
+
let isGitRepo = true;
|
|
4860
|
+
try { await fs.access(gitDir); } catch { isGitRepo = false; }
|
|
4861
|
+
if (!isGitRepo) {
|
|
4862
|
+
console.log(color("dim", ` ⊝ git pre-push hook — skipped (not a git repo: no .git dir in ${process.cwd()})`));
|
|
4863
|
+
installSummary.push({ component: "git pre-push hook", status: "skipped-not-git-repo" });
|
|
4864
|
+
} else {
|
|
4865
|
+
const hookPath = path.join(gitDir, "hooks", "pre-push");
|
|
4866
|
+
try {
|
|
4867
|
+
let existing = "";
|
|
4868
|
+
try { existing = await fs.readFile(hookPath, "utf8"); } catch { /* file may not exist */ }
|
|
4869
|
+
if (existing.includes("Cipherwake pre-push hook")) {
|
|
4870
|
+
console.log(color("dim", ` ⊝ .git/hooks/pre-push already contains the Cipherwake hook — skipping`));
|
|
4871
|
+
installSummary.push({ component: "git pre-push hook", path: hookPath, status: "skipped-already-present" });
|
|
4872
|
+
} else if (existing.trim() && !existing.startsWith("#!/")) {
|
|
4873
|
+
console.log(color("yellow", ` ⊝ .git/hooks/pre-push exists but doesn't look like our hook — skipped to avoid overwriting your script`));
|
|
4874
|
+
installSummary.push({ component: "git pre-push hook", path: hookPath, status: "skipped-conflicts" });
|
|
4875
|
+
} else {
|
|
4876
|
+
const hookScript = GIT_PREPUSH_HOOK_SCRIPT.replace("DOMAIN_PLACEHOLDER", domain);
|
|
4877
|
+
await fs.mkdir(path.dirname(hookPath), { recursive: true });
|
|
4878
|
+
await fs.writeFile(hookPath, hookScript, { mode: 0o755 });
|
|
4879
|
+
// Ensure executable bit set (writeFile mode is platform-dependent)
|
|
4880
|
+
try { await fs.chmod(hookPath, 0o755); } catch { /* best effort */ }
|
|
4881
|
+
console.log(color("green", ` ✓ installed .git/hooks/pre-push (catches manual \`git push origin main\` bypasses)`));
|
|
4882
|
+
installSummary.push({ component: "git pre-push hook", path: hookPath, status: "installed" });
|
|
4883
|
+
}
|
|
4884
|
+
} catch (err) {
|
|
4885
|
+
console.log(color("red", ` ✗ pre-push hook install failed: ${err.message}`));
|
|
4886
|
+
installSummary.push({ component: "git pre-push hook", path: hookPath, status: "failed", error: String(err?.message || err) });
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
}
|
|
4890
|
+
|
|
4891
|
+
// -------------------------------------------------------------------------
|
|
4892
|
+
// Component 4: Claude Code statusLine config
|
|
4893
|
+
// -------------------------------------------------------------------------
|
|
4894
|
+
// R74-confirm BLOCKING #10 (GPT 2026-05-22): backup before any settings.json
|
|
4895
|
+
// write. The user's ~/.claude/settings.json is theirs; we deep-merge our
|
|
4896
|
+
// entries but if the merge is ever wrong (existing key shape we didn't
|
|
4897
|
+
// anticipate), the backup gives them a one-second rollback path. The backup
|
|
4898
|
+
// is also surfaced in the install summary so the audit trail captures it.
|
|
4899
|
+
async function backupSettingsJson(settingsPath) {
|
|
4900
|
+
try {
|
|
4901
|
+
let raw;
|
|
4902
|
+
try { raw = await fs.readFile(settingsPath, "utf8"); } catch { return null; }
|
|
4903
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
4904
|
+
const backupPath = `${settingsPath}.bak.${ts}`;
|
|
4905
|
+
await fs.writeFile(backupPath, raw, "utf8");
|
|
4906
|
+
return backupPath;
|
|
4907
|
+
} catch (e) {
|
|
4908
|
+
console.log(color("yellow", ` ⚠ settings.json backup failed (${(e && e.message || e)}); proceeding anyway`));
|
|
4909
|
+
return null;
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
if (!skipStatusline) {
|
|
4914
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
4915
|
+
try {
|
|
4916
|
+
let settings = {};
|
|
4917
|
+
let existed = false;
|
|
4918
|
+
try {
|
|
4919
|
+
const raw = await fs.readFile(settingsPath, "utf8");
|
|
4920
|
+
settings = JSON.parse(raw);
|
|
4921
|
+
existed = true;
|
|
4922
|
+
} catch { /* will create */ }
|
|
4923
|
+
if (existed && settings.statusLine && typeof settings.statusLine === "object") {
|
|
4924
|
+
// Already has a statusLine config — don't overwrite.
|
|
4925
|
+
console.log(color("dim", ` ⊝ ~/.claude/settings.json already has a statusLine entry — leaving alone`));
|
|
4926
|
+
console.log(color("dim", ` To use the Cipherwake statusline instead, set: "command": "npx cipherwake-statusline"`));
|
|
4927
|
+
installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: "skipped-existing-config" });
|
|
4928
|
+
} else {
|
|
4929
|
+
const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
|
|
4930
|
+
if (backupPath) console.log(color("dim", ` backup: ${backupPath}`));
|
|
4931
|
+
settings.statusLine = { type: "command", command: "npx cipherwake-statusline" };
|
|
4932
|
+
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
4933
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
4934
|
+
console.log(color("green", ` ✓ added statusLine config → ~/.claude/settings.json`));
|
|
4935
|
+
installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
|
|
4936
|
+
}
|
|
4937
|
+
} catch (err) {
|
|
4938
|
+
console.log(color("red", ` ✗ statusLine config install failed: ${err.message}`));
|
|
4939
|
+
installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: "failed", error: String(err?.message || err) });
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
|
|
4943
|
+
// -------------------------------------------------------------------------
|
|
4944
|
+
// Component 4b: Claude Code chat-hook (PostToolUse on Bash → cipherwake-chat-hook)
|
|
4945
|
+
// Pushes a live "◆ Cipherwake just caught X" message into the chat scrollback
|
|
4946
|
+
// every time a pqcheck command runs. Merges with existing hook configs;
|
|
4947
|
+
// doesn't clobber other hooks per CLAUDE.md Rule 17.
|
|
4948
|
+
// -------------------------------------------------------------------------
|
|
4949
|
+
if (!skipStatusline) {
|
|
4950
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
4951
|
+
try {
|
|
4952
|
+
let settings = {};
|
|
4953
|
+
let existed = false;
|
|
4954
|
+
try {
|
|
4955
|
+
const raw = await fs.readFile(settingsPath, "utf8");
|
|
4956
|
+
settings = JSON.parse(raw);
|
|
4957
|
+
existed = true;
|
|
4958
|
+
} catch { /* will create */ }
|
|
4959
|
+
settings.hooks = settings.hooks || {};
|
|
4960
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse || [];
|
|
4961
|
+
|
|
4962
|
+
// Look for an existing matcher entry for Bash
|
|
4963
|
+
let bashEntry = settings.hooks.PostToolUse.find((e) => e?.matcher === "Bash");
|
|
4964
|
+
if (!bashEntry) {
|
|
4965
|
+
bashEntry = { matcher: "Bash", hooks: [] };
|
|
4966
|
+
settings.hooks.PostToolUse.push(bashEntry);
|
|
4967
|
+
}
|
|
4968
|
+
bashEntry.hooks = bashEntry.hooks || [];
|
|
4969
|
+
|
|
4970
|
+
const cipherwakeHookCmd = "npx cipherwake-chat-hook";
|
|
4971
|
+
const alreadyInstalled = bashEntry.hooks.some(
|
|
4972
|
+
(h) => h?.type === "command" && typeof h?.command === "string" && h.command.includes("cipherwake-chat-hook"),
|
|
4973
|
+
);
|
|
4974
|
+
|
|
4975
|
+
if (alreadyInstalled) {
|
|
4976
|
+
console.log(color("dim", ` ⊝ chat-hook already configured in ~/.claude/settings.json PostToolUse — skipping`));
|
|
4977
|
+
installSummary.push({ component: "Claude Code chat-hook", path: settingsPath, status: "skipped-already-present" });
|
|
4978
|
+
} else {
|
|
4979
|
+
const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
|
|
4980
|
+
if (backupPath) console.log(color("dim", ` backup: ${backupPath}`));
|
|
4981
|
+
bashEntry.hooks.push({ type: "command", command: cipherwakeHookCmd });
|
|
4982
|
+
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
4983
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
4984
|
+
console.log(color("green", ` ✓ added chat-hook (PostToolUse Bash) → ~/.claude/settings.json`));
|
|
4985
|
+
console.log(color("dim", ` Every \`pqcheck\` run will now push a live status message into Claude Code chat`));
|
|
4986
|
+
installSummary.push({ component: "Claude Code chat-hook", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
|
|
4987
|
+
}
|
|
4988
|
+
} catch (err) {
|
|
4989
|
+
console.log(color("red", ` ✗ chat-hook install failed: ${err.message}`));
|
|
4990
|
+
installSummary.push({ component: "Claude Code chat-hook", status: "failed", error: String(err?.message || err) });
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
|
|
4994
|
+
// -------------------------------------------------------------------------
|
|
4995
|
+
// Component 5: VS Code / Cursor extension (via `code` CLI if available)
|
|
4996
|
+
// -------------------------------------------------------------------------
|
|
4997
|
+
if (!skipVscode) {
|
|
4998
|
+
// Check if `code` CLI is on PATH
|
|
4999
|
+
const codeAvailable = await new Promise((resolve) => {
|
|
5000
|
+
const child = spawn("code", ["--version"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
5001
|
+
child.on("error", () => resolve(false));
|
|
5002
|
+
child.on("close", (rc) => resolve(rc === 0));
|
|
5003
|
+
});
|
|
5004
|
+
if (!codeAvailable) {
|
|
5005
|
+
console.log(color("dim", ` ⊝ VS Code extension — \`code\` CLI not on PATH. Install from Marketplace: search "Cipherwake Trust Status Bar"`));
|
|
5006
|
+
installSummary.push({ component: "VS Code / Cursor extension", status: "skipped-code-cli-not-available" });
|
|
5007
|
+
} else {
|
|
5008
|
+
// Note: until the extension is published to Marketplace, `code --install-extension cipherwakelabs.cipherwake-statusbar`
|
|
5009
|
+
// won't resolve. We attempt the install and treat failure as a soft skip.
|
|
5010
|
+
const installResult = await new Promise((resolve) => {
|
|
5011
|
+
const child = spawn("code", ["--install-extension", "cipherwakelabs.cipherwake-statusbar"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
5012
|
+
let out = "";
|
|
5013
|
+
child.stdout?.on("data", (d) => out += d.toString());
|
|
5014
|
+
child.stderr?.on("data", (d) => out += d.toString());
|
|
5015
|
+
child.on("close", (rc) => resolve({ rc, out }));
|
|
5016
|
+
child.on("error", (err) => resolve({ rc: -1, out: err.message }));
|
|
5017
|
+
});
|
|
5018
|
+
if (installResult.rc === 0) {
|
|
5019
|
+
console.log(color("green", ` ✓ installed VS Code extension: cipherwakelabs.cipherwake-statusbar`));
|
|
5020
|
+
installSummary.push({ component: "VS Code / Cursor extension", status: "installed" });
|
|
5021
|
+
} else {
|
|
5022
|
+
console.log(color("dim", ` ⊝ VS Code extension install attempted but not on Marketplace yet. Awaiting publish.`));
|
|
5023
|
+
installSummary.push({ component: "VS Code / Cursor extension", status: "skipped-not-on-marketplace-yet", attempted: true });
|
|
5024
|
+
}
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
// -------------------------------------------------------------------------
|
|
5029
|
+
// Audit trail
|
|
5030
|
+
// -------------------------------------------------------------------------
|
|
5031
|
+
try {
|
|
5032
|
+
const prefsDir = path.join(os.homedir(), ".config", "cipherwake");
|
|
5033
|
+
await fs.mkdir(prefsDir, { recursive: true });
|
|
5034
|
+
const prefsPath = path.join(prefsDir, "install-prefs.json");
|
|
5035
|
+
let prefs = { history: [] };
|
|
5036
|
+
try {
|
|
5037
|
+
const existing = await fs.readFile(prefsPath, "utf8");
|
|
5038
|
+
prefs = JSON.parse(existing);
|
|
5039
|
+
if (!Array.isArray(prefs.history)) prefs.history = [];
|
|
5040
|
+
} catch { /* first run */ }
|
|
5041
|
+
prefs.history.push({
|
|
5042
|
+
timestamp: new Date().toISOString(),
|
|
5043
|
+
command: "setup",
|
|
5044
|
+
mode: autoFlag ? "auto-flag" : "interactive",
|
|
5045
|
+
domain,
|
|
5046
|
+
consent_phrase: consentPhrase,
|
|
5047
|
+
invoked_by: invokedBy,
|
|
5048
|
+
cwd: process.cwd(),
|
|
5049
|
+
hostname: os.hostname(),
|
|
5050
|
+
pqcheck_version: VERSION,
|
|
5051
|
+
summary: installSummary,
|
|
5052
|
+
});
|
|
5053
|
+
await fs.writeFile(prefsPath, JSON.stringify(prefs, null, 2), "utf8");
|
|
5054
|
+
console.log("");
|
|
5055
|
+
console.log(color("dim", ` Audit trail: ${prefsPath}`));
|
|
5056
|
+
} catch (err) {
|
|
5057
|
+
console.log(color("dim", ` (audit trail write failed: ${err.message} — non-fatal)`));
|
|
5058
|
+
}
|
|
5059
|
+
|
|
5060
|
+
// R74-confirm BLOCKING #16 (GPT 2026-05-22): write an install manifest so
|
|
5061
|
+
// a Ctrl-C'd or partially-failed install can be diagnosed + resumed. The
|
|
5062
|
+
// manifest is the "what got done" record, separate from install-prefs.json
|
|
5063
|
+
// (which is the "who/why" audit trail). A future `pqcheck doctor --repair`
|
|
5064
|
+
// command will use this to skip already-completed steps. For now the file
|
|
5065
|
+
// is the artifact you can `cat` to see exactly what got written.
|
|
5066
|
+
try {
|
|
5067
|
+
const manifestPath = path.join(os.homedir(), ".config", "cipherwake", "install-manifest.json");
|
|
5068
|
+
let manifestList = [];
|
|
5069
|
+
try {
|
|
5070
|
+
manifestList = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
5071
|
+
if (!Array.isArray(manifestList)) manifestList = [];
|
|
5072
|
+
} catch { /* new file */ }
|
|
5073
|
+
manifestList.unshift({
|
|
5074
|
+
run_at: new Date().toISOString(),
|
|
5075
|
+
domain,
|
|
5076
|
+
auto_flag: autoFlag,
|
|
5077
|
+
invoked_by: invokedBy || null,
|
|
5078
|
+
consent_phrase: consentPhrase || null,
|
|
5079
|
+
cwd: process.cwd(),
|
|
5080
|
+
cli_version: VERSION,
|
|
5081
|
+
install_summary: installSummary,
|
|
5082
|
+
// Surface the backup paths separately so `pqcheck doctor --repair --rollback` can find them
|
|
5083
|
+
backups: installSummary.filter((s) => s.backup).map((s) => ({ component: s.component, backup: s.backup })),
|
|
5084
|
+
});
|
|
5085
|
+
// Keep last 20 install runs for forensics
|
|
5086
|
+
manifestList = manifestList.slice(0, 20);
|
|
5087
|
+
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
5088
|
+
await fs.writeFile(manifestPath, JSON.stringify(manifestList, null, 2), "utf8");
|
|
5089
|
+
console.log(color("dim", ` Install manifest: ${manifestPath}`));
|
|
5090
|
+
} catch (err) {
|
|
5091
|
+
console.log(color("dim", ` (manifest write failed: ${err.message} — non-fatal)`));
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5094
|
+
// -------------------------------------------------------------------------
|
|
5095
|
+
// Summary table
|
|
5096
|
+
// -------------------------------------------------------------------------
|
|
5097
|
+
console.log("");
|
|
5098
|
+
console.log(color("bold", "◆ Setup complete — summary:"));
|
|
5099
|
+
console.log("");
|
|
5100
|
+
for (const s of installSummary) {
|
|
5101
|
+
const icon = s.status === "installed" || s.status === "installed-created" || s.status === "installed-updated"
|
|
5102
|
+
? color("green", "✓")
|
|
5103
|
+
: s.status?.startsWith("skipped")
|
|
5104
|
+
? color("dim", "⊝")
|
|
5105
|
+
: color("red", "✗");
|
|
5106
|
+
const label = s.component;
|
|
5107
|
+
const detail = s.status === "installed" || s.status === "installed-created" || s.status === "installed-updated"
|
|
5108
|
+
? color("dim", `installed${s.path ? " → " + s.path.replace(os.homedir(), "~") : ""}`)
|
|
5109
|
+
: color("dim", s.status?.replace(/-/g, " "));
|
|
5110
|
+
console.log(` ${icon} ${label.padEnd(34, " ")} ${detail}`);
|
|
5111
|
+
}
|
|
5112
|
+
console.log("");
|
|
5113
|
+
console.log(color("dim", `Reference: https://cipherwake.io/methodology/ai-coder-protocol`));
|
|
5114
|
+
console.log("");
|
|
5115
|
+
|
|
5116
|
+
process.exit(0);
|
|
5117
|
+
}
|
|
5118
|
+
|
|
3544
5119
|
main().catch((err) => {
|
|
3545
5120
|
console.error(color("red", `fatal: ${err.message}`));
|
|
3546
5121
|
process.exit(2);
|