pqcheck 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/bin/pqcheck.js +1137 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,63 @@ The same scanner that powers [cipherwake.io](https://cipherwake.io), the browser
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## Get started in 60 seconds
|
|
16
|
+
|
|
17
|
+
Wire Cipherwake into your CI so every PR gets a Trust Diff comment when your domain's public trust posture changes.
|
|
18
|
+
|
|
19
|
+
**One command does almost everything:**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx pqcheck onboard cipherwake.io
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That runs in sequence: scan your domain → write the GitHub Action workflow → capture a vendor lockfile → generate a release checklist → open your browser to the API-key page. You finish by adding the API key as a repo secret + committing.
|
|
26
|
+
|
|
27
|
+
**Or step-by-step if you prefer:**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. Scaffold a GitHub Actions workflow (interactive prompts)
|
|
31
|
+
npx pqcheck init
|
|
32
|
+
|
|
33
|
+
# 2. Generate a free API key at https://cipherwake.io/account#api-keys
|
|
34
|
+
# (Free tier: 30 Trust Diff calls/month)
|
|
35
|
+
|
|
36
|
+
# 3. Add the key as a repo secret:
|
|
37
|
+
# GitHub → Settings → Secrets → Actions → New secret
|
|
38
|
+
# Name: CIPHERWAKE_API_KEY Value: qpk_...
|
|
39
|
+
|
|
40
|
+
# 4. Commit + push
|
|
41
|
+
git add .github/workflows/cipherwake.yml
|
|
42
|
+
git commit -m "ci: add Cipherwake Trust Diff gate"
|
|
43
|
+
git push
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That's it. Open a PR and Cipherwake comments inline when cert / SPKI / HSTS / CSP / DMARC / vendor scripts drift since your baseline.
|
|
47
|
+
|
|
48
|
+
**Want more?**
|
|
49
|
+
- Pre-commit hook: `npx pqcheck deploy-check <domain>` before every deploy
|
|
50
|
+
- Release ritual: `npx pqcheck release-checklist <domain>` for your release notes
|
|
51
|
+
- Vendor lockfile: `npx pqcheck vendors export <domain>` to commit `cipherwake.vendors.json` and fail PRs introducing new third-party scripts
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## What's new in 0.12.0
|
|
56
|
+
|
|
57
|
+
**Developer habit-loop bundle (locked 2026-05-16).** Five new subcommands that put Cipherwake where developers already work: PRs, CI, release notes, vendor allowlists. Free tier covers all of them within the 30 Trust Diff calls/month quota.
|
|
58
|
+
|
|
59
|
+
- `pqcheck init` — interactive scaffold for `.github/workflows/cipherwake.yml`. Prompts for domain, fail-on severity, baseline. No copy-paste from docs required.
|
|
60
|
+
- `pqcheck deploy-check <domain>` — pre-deploy Trust Diff gate with deploy-friendly framing. Uses last-scan as default baseline. Same exit semantics as `trust-diff`.
|
|
61
|
+
- `pqcheck release-checklist [domain]` — markdown checklist for release notes. Offline, no API call.
|
|
62
|
+
- `pqcheck vendors export <domain>` — write `cipherwake.vendors.json` from currently observed third-party origins. Like `package-lock.json` for vendor scripts.
|
|
63
|
+
- `pqcheck vendors check <domain>` — CI gate; exits **4** when new origins appear that aren't in the lockfile.
|
|
64
|
+
- `pqcheck vendors sync <domain>` — Starter+ only; pulls your dashboard-managed approved-vendor allowlist into the lockfile.
|
|
65
|
+
|
|
66
|
+
Plus: the GitHub Action v3.1 now posts a **sticky PR comment** with Trust Diff results when `comment-on-pr: true` is set, and `/r/<domain>` has a "Copy as GitHub issue" button on every finding.
|
|
67
|
+
|
|
68
|
+
## What's new in 0.11.0
|
|
69
|
+
|
|
70
|
+
**Trust Diff subcommand** — `npx pqcheck trust-diff <domain>` calls `/api/trust-diff` and gates CI on regression severity vs a configured baseline. SARIF output uploads to GitHub's Code Scanning. Pair with `cipherwakelabs/pqcheck@v3` action `mode: trust-diff` for one-line CI integration.
|
|
71
|
+
|
|
15
72
|
## What's new in 0.7.9
|
|
16
73
|
|
|
17
74
|
**CSP verdict + vendor labels on `pqcheck deps`.** The supply-chain table now shows a friendly vendor label (`New Relic · errors` / `Cloudflare · cdn` / `Adobe Fonts · fonts`) per host instead of raw `bam.nr-data.net`-style hostnames, plus a one-line site-wide CSP verdict above the table (`✗ No CSP enforcement` / `⚠ CSP is permissive` / `✓ Strict CSP enforced`). Same data shape ships on `/r/<domain>` and in the browser extension — cross-surface parity for the supply-chain story. See [CHANGELOG.md](./CHANGELOG.md).
|
|
@@ -48,6 +105,15 @@ npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2
|
|
|
48
105
|
npx pqcheck history <domain> Show 90-day score history (sparkline + samples)
|
|
49
106
|
npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
|
|
50
107
|
npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
|
|
108
|
+
npx pqcheck trust-diff <domain> Trust Diff vs configured baseline; CI gate (Free: 30/mo)
|
|
109
|
+
npx pqcheck deploy-check <domain> Pre-deploy gate (Trust Diff alias with last-scan baseline)
|
|
110
|
+
npx pqcheck onboard <domain> One-command setup wizard (scan + init + vendors + checklist)
|
|
111
|
+
npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
|
|
112
|
+
npx pqcheck release-checklist [domain] Pre-release trust checklist (markdown, offline)
|
|
113
|
+
npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
|
|
114
|
+
npx pqcheck vendors check <domain> CI gate; exit 4 on new origins not in lockfile
|
|
115
|
+
npx pqcheck vendors sync <domain> Pull dashboard allowlist into lockfile (Starter+, needs API key)
|
|
116
|
+
npx pqcheck watch <domain> Add domain to your watched list (needs CIPHERWAKE_API_KEY)
|
|
51
117
|
```
|
|
52
118
|
|
|
53
119
|
### Multi-domain
|
package/bin/pqcheck.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// =============================================================================
|
|
8
8
|
|
|
9
9
|
const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
|
|
10
|
-
const VERSION = "0.
|
|
10
|
+
const VERSION = "0.12.0";
|
|
11
11
|
|
|
12
12
|
// API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
|
|
13
13
|
// per-account monthly quotas instead of the per-IP rate limit. Set via:
|
|
@@ -88,6 +88,12 @@ async function main() {
|
|
|
88
88
|
if (args[0] === "diff") {
|
|
89
89
|
return runDiffCommand(args.slice(1));
|
|
90
90
|
}
|
|
91
|
+
if (args[0] === "trust-diff") {
|
|
92
|
+
// CLI v0.11.0 (locked 2026-05-16): new subcommand that calls /api/trust-diff
|
|
93
|
+
// and outputs verdict in selected format. Designed for CI use via the
|
|
94
|
+
// cipherwakelabs/pqcheck Action mode: trust-diff.
|
|
95
|
+
return runTrustDiffCommand(args.slice(1));
|
|
96
|
+
}
|
|
91
97
|
if (args[0] === "history") {
|
|
92
98
|
return runHistoryCommand(args.slice(1));
|
|
93
99
|
}
|
|
@@ -100,6 +106,21 @@ async function main() {
|
|
|
100
106
|
if (args[0] === "watch") {
|
|
101
107
|
return runWatchCommand(args.slice(1));
|
|
102
108
|
}
|
|
109
|
+
if (args[0] === "release-checklist") {
|
|
110
|
+
return runReleaseChecklistCommand(args.slice(1));
|
|
111
|
+
}
|
|
112
|
+
if (args[0] === "init") {
|
|
113
|
+
return runInitCommand(args.slice(1));
|
|
114
|
+
}
|
|
115
|
+
if (args[0] === "deploy-check") {
|
|
116
|
+
return runDeployCheckCommand(args.slice(1));
|
|
117
|
+
}
|
|
118
|
+
if (args[0] === "vendors") {
|
|
119
|
+
return runVendorsCommand(args.slice(1));
|
|
120
|
+
}
|
|
121
|
+
if (args[0] === "onboard") {
|
|
122
|
+
return runOnboardCommand(args.slice(1));
|
|
123
|
+
}
|
|
103
124
|
|
|
104
125
|
// Multi-domain support: positional args are domains.
|
|
105
126
|
// --file reads additional domains from a newline-delimited file.
|
|
@@ -653,6 +674,13 @@ ${color("bold", "Commands:")}
|
|
|
653
674
|
npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
|
|
654
675
|
npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
|
|
655
676
|
npx pqcheck watch <domain> Add a domain to your watched-domain list (requires CIPHERWAKE_API_KEY)
|
|
677
|
+
npx pqcheck onboard <domain> One-command setup wizard (scan + init + vendors + checklist + open browser)
|
|
678
|
+
npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
|
|
679
|
+
npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing)
|
|
680
|
+
npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
|
|
681
|
+
npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
|
|
682
|
+
npx pqcheck vendors check <domain> Compare current scan to lockfile; exit 4 on new origins (Free CI gate)
|
|
683
|
+
npx pqcheck vendors sync <domain> Pull approved-vendor list from your account (Starter+)
|
|
656
684
|
|
|
657
685
|
${color("bold", "Multi-domain:")}
|
|
658
686
|
npx pqcheck a.com b.com c.com Multi-domain scan (positional)
|
|
@@ -701,6 +729,7 @@ ${color("bold", "Exit codes:")}
|
|
|
701
729
|
${color("bold", "Examples:")}
|
|
702
730
|
npx pqcheck chase.com
|
|
703
731
|
npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
|
|
732
|
+
npx pqcheck mybank.com --watch 600 ${color("dim", "# poll locally every 10 min, log on change (no API key required)")}
|
|
704
733
|
npx pqcheck deps stripe.com --lock
|
|
705
734
|
npx pqcheck deps acme.com --allowlist allowed-vendors.txt ${color("dim", "# CI vendor-risk gate")}
|
|
706
735
|
npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --write-baseline ${color("dim", "# capture initial state")}
|
|
@@ -1971,6 +2000,175 @@ async function runChangesCommand(args) {
|
|
|
1971
2000
|
// `pqcheck diff` — diff two QXM lockfiles
|
|
1972
2001
|
// =============================================================================
|
|
1973
2002
|
|
|
2003
|
+
/**
|
|
2004
|
+
* `pqcheck trust-diff <domain>` — compare current public trust posture vs a
|
|
2005
|
+
* baseline via /api/trust-diff. Phase 2 launch feature (CLI v0.11.0).
|
|
2006
|
+
*
|
|
2007
|
+
* Inputs:
|
|
2008
|
+
* --baseline last-week | last-month | last-scan | <ISO date> (default: last-week)
|
|
2009
|
+
* --fail-on any | low | medium | high | critical (default: high)
|
|
2010
|
+
* --format pretty | json | sarif | github (default: pretty)
|
|
2011
|
+
*
|
|
2012
|
+
* Exit codes:
|
|
2013
|
+
* 0 = pass — no deltas at or above fail-on severity
|
|
2014
|
+
* 1 = warn — deltas observed but below fail-on threshold
|
|
2015
|
+
* 2 = fail — deltas observed at or above fail-on threshold
|
|
2016
|
+
* 3 = error — auth/quota/network failure
|
|
2017
|
+
*
|
|
2018
|
+
* Requires CIPHERWAKE_API_KEY env var (Free tier: 30 calls/mo at /account#api-keys).
|
|
2019
|
+
*/
|
|
2020
|
+
async function runTrustDiffCommand(args) {
|
|
2021
|
+
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
2022
|
+
if (positional.length === 0) {
|
|
2023
|
+
console.error(color("red", "error: pqcheck trust-diff requires a domain"));
|
|
2024
|
+
console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github]"));
|
|
2025
|
+
process.exit(3);
|
|
2026
|
+
}
|
|
2027
|
+
const domain = normalizeDomain(positional[0]);
|
|
2028
|
+
if (!domain) {
|
|
2029
|
+
console.error(color("red", `error: invalid domain "${positional[0]}"`));
|
|
2030
|
+
process.exit(3);
|
|
2031
|
+
}
|
|
2032
|
+
if (!QP_API_KEY) {
|
|
2033
|
+
console.error(color("red", "error: pqcheck trust-diff requires CIPHERWAKE_API_KEY"));
|
|
2034
|
+
console.error(color("dim", "Generate a free key (30 calls/mo) at https://cipherwake.io/account#api-keys"));
|
|
2035
|
+
console.error(color("dim", "Then: export CIPHERWAKE_API_KEY=qpk_<32-hex>"));
|
|
2036
|
+
process.exit(3);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
const baseline = parseFlag(args, "--baseline") || "last-week";
|
|
2040
|
+
const failOn = parseFlag(args, "--fail-on") || "high";
|
|
2041
|
+
const format = parseFlag(args, "--format") || "pretty";
|
|
2042
|
+
|
|
2043
|
+
let resp;
|
|
2044
|
+
try {
|
|
2045
|
+
resp = await fetch(`${API_BASE}/api/trust-diff`, {
|
|
2046
|
+
method: "POST",
|
|
2047
|
+
headers: {
|
|
2048
|
+
"Content-Type": "application/json",
|
|
2049
|
+
"Authorization": `Bearer ${QP_API_KEY}`,
|
|
2050
|
+
"User-Agent": `pqcheck-cli/${VERSION}`,
|
|
2051
|
+
},
|
|
2052
|
+
body: JSON.stringify({ domain, baseline, fail_on: failOn }),
|
|
2053
|
+
});
|
|
2054
|
+
} catch (err) {
|
|
2055
|
+
console.error(color("red", `error: network failure calling /api/trust-diff: ${err.message}`));
|
|
2056
|
+
process.exit(3);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
2060
|
+
await handleAuthError(resp);
|
|
2061
|
+
process.exit(3);
|
|
2062
|
+
}
|
|
2063
|
+
if (resp.status === 429) {
|
|
2064
|
+
const body = await safeJSON(resp);
|
|
2065
|
+
console.error(color("red", "error: Trust Diff API quota exceeded for this month"));
|
|
2066
|
+
if (body?.message) console.error(color("dim", body.message));
|
|
2067
|
+
process.exit(3);
|
|
2068
|
+
}
|
|
2069
|
+
if (!resp.ok) {
|
|
2070
|
+
const body = await safeJSON(resp);
|
|
2071
|
+
console.error(color("red", `error: /api/trust-diff returned ${resp.status}`));
|
|
2072
|
+
if (body?.message) console.error(color("dim", body.message));
|
|
2073
|
+
process.exit(3);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const result = await resp.json();
|
|
2077
|
+
const verdict = result.verdict || "pass";
|
|
2078
|
+
const deltas = Array.isArray(result.deltas) ? result.deltas : [];
|
|
2079
|
+
|
|
2080
|
+
// Format output
|
|
2081
|
+
if (format === "json") {
|
|
2082
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2083
|
+
} else if (format === "sarif") {
|
|
2084
|
+
console.log(JSON.stringify(trustDiffToSarif(result), null, 2));
|
|
2085
|
+
} else if (format === "github") {
|
|
2086
|
+
// GitHub Actions workflow command output
|
|
2087
|
+
for (const d of deltas) {
|
|
2088
|
+
const sev = d.severity === "critical" || d.severity === "high" ? "error" : d.severity === "medium" ? "warning" : "notice";
|
|
2089
|
+
const msg = `${d.title || d.type}: ${d.what_changed || ""}`.replace(/\n/g, "%0A").replace(/\r/g, "");
|
|
2090
|
+
console.log(`::${sev}::${msg}`);
|
|
2091
|
+
}
|
|
2092
|
+
console.log(`\nTrust Diff verdict: ${verdict.toUpperCase()} — ${deltas.length} delta${deltas.length === 1 ? "" : "s"} observed.`);
|
|
2093
|
+
console.log(`Quota: ${result.quota?.used_this_month || 0}/${result.quota?.monthly_limit || 0} used.`);
|
|
2094
|
+
} else {
|
|
2095
|
+
// pretty (default)
|
|
2096
|
+
console.log("");
|
|
2097
|
+
console.log(` ${color("bold", "Cipherwake Trust Diff")}`);
|
|
2098
|
+
console.log(` ${color("dim", `${domain} · baseline=${baseline} · fail-on=${failOn}`)}`);
|
|
2099
|
+
console.log("");
|
|
2100
|
+
if (deltas.length === 0) {
|
|
2101
|
+
console.log(` ${color("green", "✓ No deltas observed")}`);
|
|
2102
|
+
} else {
|
|
2103
|
+
const colorByLevel = (sev) => sev === "critical" ? "red" : sev === "high" ? "red" : sev === "medium" ? "yellow" : "dim";
|
|
2104
|
+
for (const d of deltas) {
|
|
2105
|
+
const sevTag = d.severity ? `[${d.severity.toUpperCase()}]` : "";
|
|
2106
|
+
console.log(` ${color(colorByLevel(d.severity), sevTag)} ${d.title || d.type}`);
|
|
2107
|
+
if (d.what_changed) console.log(` ${color("dim", d.what_changed)}`);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
console.log("");
|
|
2111
|
+
const verdictColor = verdict === "fail" ? "red" : verdict === "warn" ? "yellow" : "green";
|
|
2112
|
+
console.log(` Verdict: ${color(verdictColor, verdict.toUpperCase())}`);
|
|
2113
|
+
console.log(` Quota: ${result.quota?.used_this_month || 0}/${result.quota?.monthly_limit || 0} used this month`);
|
|
2114
|
+
if (result.upgrade_hint) {
|
|
2115
|
+
console.log("");
|
|
2116
|
+
console.log(` ${color("dim", "💡 " + result.upgrade_hint)}`);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// Exit code based on verdict
|
|
2121
|
+
if (verdict === "fail") process.exit(2);
|
|
2122
|
+
if (verdict === "warn") process.exit(1);
|
|
2123
|
+
process.exit(0);
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/**
|
|
2127
|
+
* Convert /api/trust-diff response to SARIF 2.1.0 for upload via
|
|
2128
|
+
* github/codeql-action/upload-sarif@v3. Each delta becomes a result with
|
|
2129
|
+
* level = error|warning|note based on severity.
|
|
2130
|
+
*/
|
|
2131
|
+
function trustDiffToSarif(result) {
|
|
2132
|
+
const deltas = Array.isArray(result.deltas) ? result.deltas : [];
|
|
2133
|
+
const rules = [...new Set(deltas.map((d) => d.type))].map((type) => ({
|
|
2134
|
+
id: type,
|
|
2135
|
+
name: type,
|
|
2136
|
+
shortDescription: { text: type.replace(/_/g, " ").toLowerCase() },
|
|
2137
|
+
helpUri: `https://cipherwake.io/methodology/change-briefs#${type.toLowerCase()}`,
|
|
2138
|
+
}));
|
|
2139
|
+
const results = deltas.map((d) => ({
|
|
2140
|
+
ruleId: d.type,
|
|
2141
|
+
level: d.severity === "critical" || d.severity === "high" ? "error" : d.severity === "medium" ? "warning" : "note",
|
|
2142
|
+
message: { text: `${d.title || d.type}: ${d.what_changed || ""}` },
|
|
2143
|
+
locations: [{
|
|
2144
|
+
physicalLocation: {
|
|
2145
|
+
artifactLocation: { uri: `cipherwake://${result.domain || "domain"}` },
|
|
2146
|
+
},
|
|
2147
|
+
}],
|
|
2148
|
+
}));
|
|
2149
|
+
return {
|
|
2150
|
+
$schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
|
|
2151
|
+
version: "2.1.0",
|
|
2152
|
+
runs: [{
|
|
2153
|
+
tool: {
|
|
2154
|
+
driver: {
|
|
2155
|
+
name: "Cipherwake Trust Diff",
|
|
2156
|
+
version: VERSION,
|
|
2157
|
+
informationUri: "https://cipherwake.io",
|
|
2158
|
+
rules,
|
|
2159
|
+
},
|
|
2160
|
+
},
|
|
2161
|
+
results,
|
|
2162
|
+
}],
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function parseFlag(args, name) {
|
|
2167
|
+
const idx = args.indexOf(name);
|
|
2168
|
+
if (idx === -1 || idx === args.length - 1) return null;
|
|
2169
|
+
return args[idx + 1];
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1974
2172
|
async function runDiffCommand(args) {
|
|
1975
2173
|
const fs = await import("node:fs/promises");
|
|
1976
2174
|
const json = args.includes("--json");
|
|
@@ -2098,6 +2296,10 @@ async function runWatchCommand(args) {
|
|
|
2098
2296
|
console.error(color("dim", ` ${API_BASE}/signin`));
|
|
2099
2297
|
console.error(color("dim", ` ${API_BASE}/account (rotate key)`));
|
|
2100
2298
|
console.error(color("dim", ` export CIPHERWAKE_API_KEY=qpk_...`));
|
|
2299
|
+
console.error("");
|
|
2300
|
+
console.error(color("dim", `Just want to poll locally without an account? Use --watch instead:`));
|
|
2301
|
+
console.error(color("dim", ` npx pqcheck ${looksLikeDomain ? rawDomain : "<your-domain>"} --watch 600`));
|
|
2302
|
+
console.error(color("dim", ` (No API key required. Polls every N seconds, logs on score change.)`));
|
|
2101
2303
|
process.exit(1);
|
|
2102
2304
|
}
|
|
2103
2305
|
|
|
@@ -2260,6 +2462,940 @@ async function runCertCommand(args) {
|
|
|
2260
2462
|
console.log("");
|
|
2261
2463
|
}
|
|
2262
2464
|
|
|
2465
|
+
// =============================================================================
|
|
2466
|
+
// `pqcheck release-checklist [domain]` — pre-release trust checklist generator
|
|
2467
|
+
// =============================================================================
|
|
2468
|
+
// Outputs a markdown checklist for teams to paste into release notes or run
|
|
2469
|
+
// as a pre-deploy gate. Pure offline (no API call). Domain is optional —
|
|
2470
|
+
// when present, the checklist is interpolated; when absent, a `<your-domain>`
|
|
2471
|
+
// placeholder is left for the user to fill.
|
|
2472
|
+
//
|
|
2473
|
+
// Habit-loop feature locked 2026-05-16: turns Cipherwake into part of the
|
|
2474
|
+
// release ritual without heavy integrations. See [[cipherwake-launch-plan-2026-05]].
|
|
2475
|
+
// Free tier — no API quota consumed.
|
|
2476
|
+
// =============================================================================
|
|
2477
|
+
|
|
2478
|
+
async function runReleaseChecklistCommand(args) {
|
|
2479
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
2480
|
+
const raw = positional[0];
|
|
2481
|
+
let target = "<your-domain>";
|
|
2482
|
+
if (raw) {
|
|
2483
|
+
const normalized = normalizeDomain(raw);
|
|
2484
|
+
if (!isValidDomain(normalized)) {
|
|
2485
|
+
console.error(color("red", `error: '${raw}' is not a valid domain`));
|
|
2486
|
+
process.exit(1);
|
|
2487
|
+
}
|
|
2488
|
+
target = normalized;
|
|
2489
|
+
}
|
|
2490
|
+
const out = renderReleaseChecklist(target, { generator: "release-checklist" });
|
|
2491
|
+
console.log(out);
|
|
2492
|
+
process.exit(0);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// R41 fix #3 (locked 2026-05-16): shared release-checklist helper used by
|
|
2496
|
+
// both `pqcheck release-checklist` (prints to stdout) and `pqcheck onboard`
|
|
2497
|
+
// (writes to CIPHERWAKE_CHECKLIST.md). Single source of truth for the 9
|
|
2498
|
+
// checklist items + verification commands + "where to look" links.
|
|
2499
|
+
//
|
|
2500
|
+
// generator: "release-checklist" or "onboard" — controls the intro paragraph
|
|
2501
|
+
// so the file written by `onboard` says "Generated by `pqcheck onboard`"
|
|
2502
|
+
// while the standalone `release-checklist` command emits the user-facing
|
|
2503
|
+
// "Run these in CI or paste..." intro. All other content is identical.
|
|
2504
|
+
function renderReleaseChecklist(domain, opts = {}) {
|
|
2505
|
+
const generator = opts.generator === "onboard" ? "onboard" : "release-checklist";
|
|
2506
|
+
const intro = generator === "onboard"
|
|
2507
|
+
? `Generated by \`pqcheck onboard\` — re-run \`pqcheck release-checklist ${domain}\` anytime.`
|
|
2508
|
+
: `Run these in CI or paste into your release-notes template. Each item maps to a Cipherwake check; recommended commands are below. Free tier covers all of these on 1 monitored domain.`;
|
|
2509
|
+
return [
|
|
2510
|
+
`## Pre-release trust checklist for ${domain}`,
|
|
2511
|
+
``,
|
|
2512
|
+
intro,
|
|
2513
|
+
``,
|
|
2514
|
+
`- [ ] Trust Diff passes vs last successful deploy`,
|
|
2515
|
+
`- [ ] No new unapproved vendor scripts observed since last release`,
|
|
2516
|
+
`- [ ] HSTS still present and unchanged`,
|
|
2517
|
+
`- [ ] CSP still present and unchanged`,
|
|
2518
|
+
`- [ ] DMARC policy unchanged`,
|
|
2519
|
+
`- [ ] Certificate issuer expected (no surprise CA rotation)`,
|
|
2520
|
+
`- [ ] SPKI / key rotation matches your deploy pipeline`,
|
|
2521
|
+
`- [ ] HNDL Decryption Blast Radius score within target range`,
|
|
2522
|
+
`- [ ] Cipherwake monitoring still active (last scan within 24h)`,
|
|
2523
|
+
``,
|
|
2524
|
+
`### How to verify`,
|
|
2525
|
+
``,
|
|
2526
|
+
`\`\`\`bash`,
|
|
2527
|
+
`# Trust posture vs last successful deploy (Free: 30 calls/mo)`,
|
|
2528
|
+
`npx pqcheck trust-diff ${domain} --baseline last-week --fail-on high`,
|
|
2529
|
+
``,
|
|
2530
|
+
`# Third-party origins on the page (vendor scripts)`,
|
|
2531
|
+
`npx pqcheck vendors check ${domain}`,
|
|
2532
|
+
``,
|
|
2533
|
+
`# Live grade + score components`,
|
|
2534
|
+
`npx pqcheck ${domain}`,
|
|
2535
|
+
`\`\`\``,
|
|
2536
|
+
``,
|
|
2537
|
+
`### Where to look`,
|
|
2538
|
+
``,
|
|
2539
|
+
`- Full dashboard: https://cipherwake.io/r/${encodeURIComponent(domain)}`,
|
|
2540
|
+
`- Methodology + what each check means: https://cipherwake.io/methodology/`,
|
|
2541
|
+
`- 30-day Trust Timeline + Change Briefs: https://cipherwake.io/account`,
|
|
2542
|
+
``,
|
|
2543
|
+
].join("\n");
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// =============================================================================
|
|
2547
|
+
// `pqcheck init` — interactive workflow scaffold (habit-loop #4, locked 2026-05-16)
|
|
2548
|
+
// =============================================================================
|
|
2549
|
+
// Writes a ready-to-commit .github/workflows/cipherwake.yml that calls
|
|
2550
|
+
// cipherwakelabs/pqcheck@v3 in trust-diff mode. Zero copy-paste docs friction.
|
|
2551
|
+
//
|
|
2552
|
+
// Flags:
|
|
2553
|
+
// --domain <d> Skip the domain prompt
|
|
2554
|
+
// --fail-on <level> Skip the severity prompt (any|low|medium|high|critical)
|
|
2555
|
+
// --baseline <ref> Skip the baseline prompt (last-week|last-month|last-scan|<ISO>)
|
|
2556
|
+
// --yes / -y Use defaults for everything not explicitly passed
|
|
2557
|
+
// --force Overwrite an existing workflow file without prompting
|
|
2558
|
+
// --stdout Print the workflow to stdout instead of writing files
|
|
2559
|
+
//
|
|
2560
|
+
// Free tier: no API call made by init itself. The generated workflow runs
|
|
2561
|
+
// against the user's CIPHERWAKE_API_KEY secret (30 free Trust Diff calls/mo).
|
|
2562
|
+
// =============================================================================
|
|
2563
|
+
|
|
2564
|
+
const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
|
|
2565
|
+
const VALID_BASELINES = ["last-week", "last-month", "last-scan"];
|
|
2566
|
+
|
|
2567
|
+
async function runInitCommand(args) {
|
|
2568
|
+
const fs = await import("node:fs/promises");
|
|
2569
|
+
const path = await import("node:path");
|
|
2570
|
+
const useDefaults = args.includes("--yes") || args.includes("-y");
|
|
2571
|
+
const stdout = args.includes("--stdout");
|
|
2572
|
+
const force = args.includes("--force");
|
|
2573
|
+
|
|
2574
|
+
const flagDomain = readFlagValue(args, "--domain");
|
|
2575
|
+
const flagFailOn = readFlagValue(args, "--fail-on");
|
|
2576
|
+
const flagBaseline = readFlagValue(args, "--baseline");
|
|
2577
|
+
|
|
2578
|
+
console.log("");
|
|
2579
|
+
console.log(` ${color("bold", "pqcheck init")} ${color("dim", "— scaffold a Cipherwake GitHub Action workflow")}`);
|
|
2580
|
+
console.log("");
|
|
2581
|
+
|
|
2582
|
+
let domain = flagDomain ? normalizeDomain(flagDomain) : null;
|
|
2583
|
+
if (!domain) {
|
|
2584
|
+
if (useDefaults) {
|
|
2585
|
+
console.error(color("red", "error: --yes requires --domain (no interactive prompt to fill from)"));
|
|
2586
|
+
process.exit(1);
|
|
2587
|
+
}
|
|
2588
|
+
const answer = await prompt(` Domain to monitor (e.g. cipherwake.io): `);
|
|
2589
|
+
domain = normalizeDomain((answer || "").trim());
|
|
2590
|
+
}
|
|
2591
|
+
if (!isValidDomain(domain)) {
|
|
2592
|
+
console.error(color("red", ` error: '${domain}' is not a valid hostname`));
|
|
2593
|
+
process.exit(1);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
let failOn = flagFailOn || "high";
|
|
2597
|
+
if (!flagFailOn && !useDefaults) {
|
|
2598
|
+
const answer = await prompt(` Fail CI on severity ${color("dim", "[any|low|medium|high(default)|critical]")}: `);
|
|
2599
|
+
if (answer && answer.trim()) failOn = answer.trim().toLowerCase();
|
|
2600
|
+
}
|
|
2601
|
+
if (!VALID_FAIL_ON.includes(failOn)) {
|
|
2602
|
+
console.error(color("red", ` error: --fail-on must be one of ${VALID_FAIL_ON.join("|")}`));
|
|
2603
|
+
process.exit(1);
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
let baseline = flagBaseline || "last-week";
|
|
2607
|
+
if (!flagBaseline && !useDefaults) {
|
|
2608
|
+
const answer = await prompt(` Baseline ${color("dim", "[last-week(default)|last-month|last-scan|<ISO date>]")}: `);
|
|
2609
|
+
if (answer && answer.trim()) baseline = answer.trim();
|
|
2610
|
+
}
|
|
2611
|
+
if (!isValidBaseline(baseline)) {
|
|
2612
|
+
console.error(color("red", ` error: --baseline must be last-week|last-month|last-scan or an ISO date (YYYY-MM-DD)`));
|
|
2613
|
+
process.exit(1);
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
const workflow = renderTrustDiffWorkflow({ domain, failOn, baseline });
|
|
2617
|
+
|
|
2618
|
+
if (stdout) {
|
|
2619
|
+
console.log(workflow);
|
|
2620
|
+
process.exit(0);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// Resolve target path: ./.github/workflows/cipherwake.yml in cwd
|
|
2624
|
+
const cwd = process.cwd();
|
|
2625
|
+
const workflowDir = path.join(cwd, ".github", "workflows");
|
|
2626
|
+
const workflowPath = path.join(workflowDir, "cipherwake.yml");
|
|
2627
|
+
|
|
2628
|
+
try {
|
|
2629
|
+
await fs.mkdir(workflowDir, { recursive: true });
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
console.error(color("red", ` error creating ${workflowDir}: ${err.message}`));
|
|
2632
|
+
process.exit(1);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// Check existing file
|
|
2636
|
+
let exists = false;
|
|
2637
|
+
try {
|
|
2638
|
+
await fs.access(workflowPath);
|
|
2639
|
+
exists = true;
|
|
2640
|
+
} catch { /* doesn't exist */ }
|
|
2641
|
+
|
|
2642
|
+
if (exists && !force) {
|
|
2643
|
+
if (useDefaults) {
|
|
2644
|
+
console.error(color("red", ` error: ${workflowPath} already exists (re-run with --force to overwrite)`));
|
|
2645
|
+
process.exit(1);
|
|
2646
|
+
}
|
|
2647
|
+
const answer = await prompt(` ${color("yellow", workflowPath + " already exists — overwrite?")} ${color("dim", "[y/N]")}: `);
|
|
2648
|
+
if (!/^y(es)?$/i.test((answer || "").trim())) {
|
|
2649
|
+
console.error(color("dim", " cancelled — workflow not written"));
|
|
2650
|
+
process.exit(1);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
try {
|
|
2655
|
+
await fs.writeFile(workflowPath, workflow, "utf8");
|
|
2656
|
+
} catch (err) {
|
|
2657
|
+
console.error(color("red", ` error writing ${workflowPath}: ${err.message}`));
|
|
2658
|
+
process.exit(1);
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
const relPath = path.relative(cwd, workflowPath);
|
|
2662
|
+
console.log("");
|
|
2663
|
+
console.log(color("green", ` ✓ Wrote ${relPath}`));
|
|
2664
|
+
console.log("");
|
|
2665
|
+
console.log(` ${color("bold", "Next steps:")}`);
|
|
2666
|
+
console.log("");
|
|
2667
|
+
console.log(` ${color("dim", "1.")} Generate a Cipherwake API key at ${color("violet", "https://cipherwake.io/account#api-keys")}`);
|
|
2668
|
+
console.log(` ${color("dim", "Free tier: 30 Trust Diff calls/month")}`);
|
|
2669
|
+
console.log("");
|
|
2670
|
+
console.log(` ${color("dim", "2.")} Add it as a repo secret:`);
|
|
2671
|
+
console.log(` ${color("dim", "Settings → Secrets and variables → Actions → New repository secret")}`);
|
|
2672
|
+
console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY")}`);
|
|
2673
|
+
console.log("");
|
|
2674
|
+
console.log(` ${color("dim", "3.")} Commit + push:`);
|
|
2675
|
+
console.log(` ${color("dim", "$")} git add ${relPath}`);
|
|
2676
|
+
console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
|
|
2677
|
+
console.log(` ${color("dim", "$")} git push`);
|
|
2678
|
+
console.log("");
|
|
2679
|
+
console.log(` Open a PR to see the gate run.`);
|
|
2680
|
+
console.log("");
|
|
2681
|
+
process.exit(0);
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function readFlagValue(args, name) {
|
|
2685
|
+
const idx = args.indexOf(name);
|
|
2686
|
+
if (idx === -1) return null;
|
|
2687
|
+
const v = args[idx + 1];
|
|
2688
|
+
return v && !v.startsWith("-") ? v : null;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
function isValidBaseline(value) {
|
|
2692
|
+
if (VALID_BASELINES.includes(value)) return true;
|
|
2693
|
+
// ISO date YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ
|
|
2694
|
+
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?Z?)?$/.test(value);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function renderTrustDiffWorkflow({ domain, failOn, baseline }) {
|
|
2698
|
+
return `# Cipherwake — Trust Diff gate
|
|
2699
|
+
# Generated by \`pqcheck init\` (v${VERSION}).
|
|
2700
|
+
# Runs on every PR and pushes to main: fails the build if your public trust
|
|
2701
|
+
# posture regresses vs the baseline (cert / SPKI / vendor scripts / HSTS / CSP /
|
|
2702
|
+
# DMARC / HNDL).
|
|
2703
|
+
#
|
|
2704
|
+
# Free tier: 30 Trust Diff calls/month per CIPHERWAKE_API_KEY.
|
|
2705
|
+
# Methodology: https://cipherwake.io/methodology/
|
|
2706
|
+
# Action source: https://github.com/cipherwakelabs/pqcheck
|
|
2707
|
+
|
|
2708
|
+
name: Cipherwake Trust Diff
|
|
2709
|
+
|
|
2710
|
+
on:
|
|
2711
|
+
pull_request:
|
|
2712
|
+
branches: [main]
|
|
2713
|
+
push:
|
|
2714
|
+
branches: [main]
|
|
2715
|
+
|
|
2716
|
+
permissions:
|
|
2717
|
+
contents: read
|
|
2718
|
+
security-events: write # required for SARIF upload to Code Scanning
|
|
2719
|
+
pull-requests: write # required for sticky PR comment (Action v3.1+)
|
|
2720
|
+
|
|
2721
|
+
jobs:
|
|
2722
|
+
trust-diff:
|
|
2723
|
+
runs-on: ubuntu-latest
|
|
2724
|
+
steps:
|
|
2725
|
+
- name: Run Cipherwake Trust Diff
|
|
2726
|
+
uses: cipherwakelabs/pqcheck@v3
|
|
2727
|
+
with:
|
|
2728
|
+
mode: trust-diff
|
|
2729
|
+
domain: ${domain}
|
|
2730
|
+
baseline: ${baseline}
|
|
2731
|
+
fail-on: ${failOn}
|
|
2732
|
+
env:
|
|
2733
|
+
CIPHERWAKE_API_KEY: \${{ secrets.CIPHERWAKE_API_KEY }}
|
|
2734
|
+
`;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// Tiny readline wrapper. We avoid pulling a CLI prompt library — this is the
|
|
2738
|
+
// only interactive path in pqcheck and Node's built-in readline is enough.
|
|
2739
|
+
async function prompt(question) {
|
|
2740
|
+
const readline = await import("node:readline/promises");
|
|
2741
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2742
|
+
try {
|
|
2743
|
+
const answer = await rl.question(question);
|
|
2744
|
+
return answer;
|
|
2745
|
+
} finally {
|
|
2746
|
+
rl.close();
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// =============================================================================
|
|
2751
|
+
// `pqcheck deploy-check <domain>` — pre-deploy trust gate (habit-loop #5)
|
|
2752
|
+
// =============================================================================
|
|
2753
|
+
// Thin alias for `pqcheck trust-diff` with deploy-friendly framing:
|
|
2754
|
+
// - Default baseline: last-scan (compares vs your most recent scan, which
|
|
2755
|
+
// usually correlates with the previous deploy)
|
|
2756
|
+
// - Default fail-on: high
|
|
2757
|
+
// - Cleaner output for shell-script use in deploy pipelines (Vercel
|
|
2758
|
+
// pre-build, Netlify build commands, custom CD scripts)
|
|
2759
|
+
//
|
|
2760
|
+
// Exit codes match trust-diff: 0 pass · 1 warn · 2 fail · 3 error.
|
|
2761
|
+
// Consumes the same Free 30 Trust Diff calls/month quota.
|
|
2762
|
+
// =============================================================================
|
|
2763
|
+
|
|
2764
|
+
async function runDeployCheckCommand(args) {
|
|
2765
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
2766
|
+
if (positional.length === 0) {
|
|
2767
|
+
console.error(color("red", "error: pqcheck deploy-check requires a domain"));
|
|
2768
|
+
console.error(color("dim", "Usage: npx pqcheck deploy-check <domain> [--baseline last-scan|last-week|<ISO>] [--fail-on high|medium|low|any]"));
|
|
2769
|
+
process.exit(1);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// Forward to trust-diff with deploy-tuned defaults if the user didn't specify.
|
|
2773
|
+
const forwarded = [...args];
|
|
2774
|
+
if (!args.includes("--baseline")) forwarded.push("--baseline", "last-scan");
|
|
2775
|
+
if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
|
|
2776
|
+
|
|
2777
|
+
// Pre-print a deploy-context header (only in text mode — JSON/SARIF users
|
|
2778
|
+
// are scripting and don't want our preamble polluting their pipe).
|
|
2779
|
+
const format = parseFormat(forwarded);
|
|
2780
|
+
if (format === "text") {
|
|
2781
|
+
console.log("");
|
|
2782
|
+
console.log(` ${color("bold", "🚀 Deploy gate")} ${color("dim", "— checking public trust posture vs last scan")}`);
|
|
2783
|
+
console.log("");
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
return runTrustDiffCommand(forwarded);
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
// =============================================================================
|
|
2790
|
+
// `pqcheck vendors <subcommand>` — vendor lockfile management (habit-loop #10)
|
|
2791
|
+
// =============================================================================
|
|
2792
|
+
// Free tier (Option A, locked 2026-05-16):
|
|
2793
|
+
// pqcheck vendors export <domain> — write cipherwake.vendors.json from
|
|
2794
|
+
// the current observed vendor scripts
|
|
2795
|
+
// (read-only snapshot, no CI enforce)
|
|
2796
|
+
// pqcheck vendors check <domain> — compare current scan to the lockfile,
|
|
2797
|
+
// exit 4 if new origins appeared
|
|
2798
|
+
// (free CI gate via the deps endpoint)
|
|
2799
|
+
//
|
|
2800
|
+
// Starter+ tier:
|
|
2801
|
+
// pqcheck vendors sync <domain> — pull approved-vendor list from
|
|
2802
|
+
// /api/vendor-allowlist and merge into
|
|
2803
|
+
// the lockfile (bidirectional)
|
|
2804
|
+
//
|
|
2805
|
+
// The lockfile is a developer artifact: commit it to the repo to track
|
|
2806
|
+
// vendor-surface drift in PR diffs. Like package-lock.json for third-party
|
|
2807
|
+
// scripts.
|
|
2808
|
+
//
|
|
2809
|
+
// Per [[quantapact-pricing-discipline]]: Free generates the lockfile and uses
|
|
2810
|
+
// it in CI; Starter+ adds the dashboard-sync layer + approved-vendor
|
|
2811
|
+
// enforcement. The dashboard CRUD UI is the Starter wall, not the lockfile.
|
|
2812
|
+
// =============================================================================
|
|
2813
|
+
|
|
2814
|
+
const VENDOR_LOCKFILE_NAME = "cipherwake.vendors.json";
|
|
2815
|
+
|
|
2816
|
+
async function runVendorsCommand(args) {
|
|
2817
|
+
const sub = args[0];
|
|
2818
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
2819
|
+
console.log(`
|
|
2820
|
+
${color("bold", "pqcheck vendors")} ${color("dim", `v${VERSION}`)}
|
|
2821
|
+
|
|
2822
|
+
Vendor lockfile management — track third-party scripts on your domain.
|
|
2823
|
+
|
|
2824
|
+
${color("bold", "Subcommands:")}
|
|
2825
|
+
pqcheck vendors export <domain> Write ${VENDOR_LOCKFILE_NAME} from current observed vendors (Free)
|
|
2826
|
+
pqcheck vendors check <domain> Compare current scan to the lockfile; exit 4 on new origins (Free CI gate)
|
|
2827
|
+
pqcheck vendors sync <domain> Pull approved-vendor list from your account (Starter+, requires CIPHERWAKE_API_KEY)
|
|
2828
|
+
|
|
2829
|
+
${color("bold", "Flags:")}
|
|
2830
|
+
-o <path> Output / input path (default: ./${VENDOR_LOCKFILE_NAME})
|
|
2831
|
+
|
|
2832
|
+
${color("bold", "Examples:")}
|
|
2833
|
+
npx pqcheck vendors export cipherwake.io ${color("dim", "# capture initial state")}
|
|
2834
|
+
npx pqcheck vendors check cipherwake.io ${color("dim", "# CI gate — fails on new origins")}
|
|
2835
|
+
CIPHERWAKE_API_KEY=qpk_... npx pqcheck vendors sync cipherwake.io ${color("dim", "# Starter+ dashboard sync")}
|
|
2836
|
+
|
|
2837
|
+
Methodology: ${color("violet", "https://cipherwake.io/methodology/vendor-lockfile")}
|
|
2838
|
+
`);
|
|
2839
|
+
process.exit(0);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
const rest = args.slice(1);
|
|
2843
|
+
const positional = rest.filter((a) => !a.startsWith("-"));
|
|
2844
|
+
const raw = positional[0];
|
|
2845
|
+
if (!raw) {
|
|
2846
|
+
console.error(color("red", `error: pqcheck vendors ${sub} requires a domain`));
|
|
2847
|
+
process.exit(1);
|
|
2848
|
+
}
|
|
2849
|
+
const domain = normalizeDomain(raw);
|
|
2850
|
+
if (!isValidDomain(domain)) {
|
|
2851
|
+
console.error(color("red", `error: '${raw}' is not a valid domain`));
|
|
2852
|
+
process.exit(1);
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
const outIdx = rest.indexOf("-o");
|
|
2856
|
+
const outPath = (outIdx >= 0 && rest[outIdx + 1] && !rest[outIdx + 1].startsWith("-"))
|
|
2857
|
+
? rest[outIdx + 1]
|
|
2858
|
+
: VENDOR_LOCKFILE_NAME;
|
|
2859
|
+
|
|
2860
|
+
if (sub === "export") {
|
|
2861
|
+
return runVendorsExport(domain, outPath);
|
|
2862
|
+
}
|
|
2863
|
+
if (sub === "check") {
|
|
2864
|
+
return runVendorsCheck(domain, outPath);
|
|
2865
|
+
}
|
|
2866
|
+
if (sub === "sync") {
|
|
2867
|
+
return runVendorsSync(domain, outPath);
|
|
2868
|
+
}
|
|
2869
|
+
console.error(color("red", `error: unknown subcommand 'vendors ${sub}'. Try: export | check | sync`));
|
|
2870
|
+
process.exit(1);
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
async function fetchVendorOrigins(domain) {
|
|
2874
|
+
// Calls /api/deps which returns the observed third-party origin list for
|
|
2875
|
+
// a domain. Same endpoint that powers `pqcheck deps <domain>`.
|
|
2876
|
+
//
|
|
2877
|
+
// R40 fix (Q2.6): add a 15-second timeout via AbortController. Previously
|
|
2878
|
+
// a hung /api/deps would block the CLI indefinitely — CI runs would
|
|
2879
|
+
// consume their full 6-hour budget waiting for a response that never
|
|
2880
|
+
// comes. 15s is generous enough for the slow tail but bounds the worst
|
|
2881
|
+
// case.
|
|
2882
|
+
const ac = new AbortController();
|
|
2883
|
+
const timer = setTimeout(() => ac.abort(), 15_000);
|
|
2884
|
+
let resp;
|
|
2885
|
+
try {
|
|
2886
|
+
resp = await fetch(`${API_BASE}/api/deps?domain=${encodeURIComponent(domain)}`, {
|
|
2887
|
+
method: "GET",
|
|
2888
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (vendors)` }),
|
|
2889
|
+
signal: ac.signal,
|
|
2890
|
+
});
|
|
2891
|
+
} catch (err) {
|
|
2892
|
+
if (err?.name === "AbortError") throw new Error("/api/deps timed out after 15s");
|
|
2893
|
+
throw err;
|
|
2894
|
+
} finally {
|
|
2895
|
+
clearTimeout(timer);
|
|
2896
|
+
}
|
|
2897
|
+
if (!resp.ok) {
|
|
2898
|
+
const body = await safeJSON(resp);
|
|
2899
|
+
throw new Error(`/api/deps returned ${resp.status} ${body?.error || resp.statusText}`);
|
|
2900
|
+
}
|
|
2901
|
+
const data = await resp.json();
|
|
2902
|
+
const thirds = Array.isArray(data.thirdParties) ? data.thirdParties : [];
|
|
2903
|
+
// R40 fix (Q2.7): preserve the observed protocol. Previously we
|
|
2904
|
+
// force-converted http://vendor.com into https://vendor.com which
|
|
2905
|
+
// mis-represented what we actually observed. Now: keep http:// when
|
|
2906
|
+
// the API gives an explicit http:// origin; default to https:// only
|
|
2907
|
+
// when the host comes without a protocol prefix.
|
|
2908
|
+
const origins = new Set();
|
|
2909
|
+
for (const t of thirds) {
|
|
2910
|
+
const host = typeof t === "string" ? t : (t.host ?? t.origin ?? "");
|
|
2911
|
+
if (!host) continue;
|
|
2912
|
+
const o = normalizeObservedOrigin(host);
|
|
2913
|
+
if (o) origins.add(o);
|
|
2914
|
+
}
|
|
2915
|
+
return Array.from(origins).sort();
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// R40 fix (Q2.7): preserve observed protocol. URL parser handles host
|
|
2919
|
+
// validation + canonicalization (lowercase, default-port stripping).
|
|
2920
|
+
function normalizeObservedOrigin(value) {
|
|
2921
|
+
const s = String(value).trim().toLowerCase();
|
|
2922
|
+
if (!s) return null;
|
|
2923
|
+
try {
|
|
2924
|
+
const u = s.startsWith("http://") || s.startsWith("https://")
|
|
2925
|
+
? new URL(s)
|
|
2926
|
+
: new URL("https://" + s);
|
|
2927
|
+
return u.origin;
|
|
2928
|
+
} catch {
|
|
2929
|
+
return null;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
function buildVendorLockfile(domain, origins) {
|
|
2934
|
+
return {
|
|
2935
|
+
schema_version: 1,
|
|
2936
|
+
generator: `pqcheck-cli/${VERSION}`,
|
|
2937
|
+
domain,
|
|
2938
|
+
generated_at: new Date().toISOString(),
|
|
2939
|
+
approved_script_origins: origins,
|
|
2940
|
+
// Soft tier marker — read by sync to know if the lockfile carries
|
|
2941
|
+
// dashboard-managed entries. Free-only lockfiles set this to null.
|
|
2942
|
+
synced_from_account: null,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
async function runVendorsExport(domain, outPath) {
|
|
2947
|
+
const fs = await import("node:fs/promises");
|
|
2948
|
+
console.log("");
|
|
2949
|
+
console.log(` ${color("bold", "Exporting vendor lockfile")} ${color("dim", `— ${domain}`)}`);
|
|
2950
|
+
console.log("");
|
|
2951
|
+
let origins;
|
|
2952
|
+
try {
|
|
2953
|
+
origins = await fetchVendorOrigins(domain);
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
console.error(color("red", ` error fetching vendor origins: ${err.message}`));
|
|
2956
|
+
process.exit(1);
|
|
2957
|
+
}
|
|
2958
|
+
const lockfile = buildVendorLockfile(domain, origins);
|
|
2959
|
+
try {
|
|
2960
|
+
await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
|
|
2961
|
+
} catch (err) {
|
|
2962
|
+
console.error(color("red", ` error writing ${outPath}: ${err.message}`));
|
|
2963
|
+
process.exit(1);
|
|
2964
|
+
}
|
|
2965
|
+
console.log(color("green", ` ✓ Wrote ${outPath} with ${origins.length} approved script origin${origins.length === 1 ? "" : "s"}.`));
|
|
2966
|
+
console.log("");
|
|
2967
|
+
console.log(` ${color("dim", "Commit this file to your repo to track vendor-surface drift in PR diffs.")}`);
|
|
2968
|
+
// R40 fix (Q2.12): nested template literal — the outer backticks are the
|
|
2969
|
+
// template literal; the inner string passed to color() must ALSO be a
|
|
2970
|
+
// template literal so ${domain} interpolates. Previously this printed
|
|
2971
|
+
// the literal text "${domain}" because color()'s arg was a plain string.
|
|
2972
|
+
console.log(` ${color("dim", `Run \`pqcheck vendors check ${domain}\` in CI to fail PRs that introduce new origins.`)}`);
|
|
2973
|
+
console.log("");
|
|
2974
|
+
process.exit(0);
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
async function runVendorsCheck(domain, lockPath) {
|
|
2978
|
+
const fs = await import("node:fs/promises");
|
|
2979
|
+
let lockfile;
|
|
2980
|
+
try {
|
|
2981
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
2982
|
+
lockfile = JSON.parse(raw);
|
|
2983
|
+
} catch (err) {
|
|
2984
|
+
console.error(color("red", ` error reading ${lockPath}: ${err.message}`));
|
|
2985
|
+
console.error(color("dim", ` Run: npx pqcheck vendors export ${domain} to generate one.`));
|
|
2986
|
+
process.exit(1);
|
|
2987
|
+
}
|
|
2988
|
+
if (lockfile.schema_version !== 1) {
|
|
2989
|
+
console.error(color("red", ` error: ${lockPath} schema_version=${lockfile.schema_version}, expected 1`));
|
|
2990
|
+
process.exit(1);
|
|
2991
|
+
}
|
|
2992
|
+
if (lockfile.domain && lockfile.domain !== domain) {
|
|
2993
|
+
console.error(color("yellow", ` warning: lockfile is for ${lockfile.domain} but checking against ${domain}`));
|
|
2994
|
+
}
|
|
2995
|
+
const baseline = new Set(Array.isArray(lockfile.approved_script_origins) ? lockfile.approved_script_origins : []);
|
|
2996
|
+
let observed;
|
|
2997
|
+
try {
|
|
2998
|
+
observed = new Set(await fetchVendorOrigins(domain));
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
console.error(color("red", ` error fetching current vendors: ${err.message}`));
|
|
3001
|
+
process.exit(1);
|
|
3002
|
+
}
|
|
3003
|
+
const newOrigins = [...observed].filter((o) => !baseline.has(o));
|
|
3004
|
+
const removed = [...baseline].filter((o) => !observed.has(o));
|
|
3005
|
+
|
|
3006
|
+
console.log("");
|
|
3007
|
+
console.log(` ${color("bold", "Vendor lockfile check")} ${color("dim", `— ${domain}`)}`);
|
|
3008
|
+
console.log("");
|
|
3009
|
+
if (newOrigins.length === 0 && removed.length === 0) {
|
|
3010
|
+
console.log(color("green", ` ✓ Vendor surface matches lockfile (${baseline.size} origins).`));
|
|
3011
|
+
console.log("");
|
|
3012
|
+
process.exit(0);
|
|
3013
|
+
}
|
|
3014
|
+
if (newOrigins.length > 0) {
|
|
3015
|
+
console.log(color("red", ` ⚠ ${newOrigins.length} new origin${newOrigins.length === 1 ? "" : "s"} observed (not in lockfile):`));
|
|
3016
|
+
for (const o of newOrigins) console.log(` + ${o}`);
|
|
3017
|
+
console.log("");
|
|
3018
|
+
}
|
|
3019
|
+
if (removed.length > 0) {
|
|
3020
|
+
console.log(color("dim", ` - ${removed.length} origin${removed.length === 1 ? "" : "s"} no longer observed:`));
|
|
3021
|
+
for (const o of removed) console.log(` - ${o}`);
|
|
3022
|
+
console.log("");
|
|
3023
|
+
}
|
|
3024
|
+
if (newOrigins.length > 0) {
|
|
3025
|
+
console.log(color("dim", ` To accept the additions, re-run: npx pqcheck vendors export ${domain}`));
|
|
3026
|
+
console.log(color("dim", ` Then commit the updated ${lockPath} to your repo.`));
|
|
3027
|
+
console.log("");
|
|
3028
|
+
process.exit(4); // New origin(s) detected — same exit code as `deps --fail-on-new`
|
|
3029
|
+
}
|
|
3030
|
+
// Only removals (cleanup), no failure
|
|
3031
|
+
process.exit(0);
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
async function runVendorsSync(domain, outPath) {
|
|
3035
|
+
const fs = await import("node:fs/promises");
|
|
3036
|
+
if (!QP_API_KEY) {
|
|
3037
|
+
console.error(color("red", " error: `vendors sync` requires CIPHERWAKE_API_KEY (Starter+ feature)"));
|
|
3038
|
+
console.error("");
|
|
3039
|
+
console.error(color("dim", " Free tier: use `vendors export` to generate a read-only lockfile."));
|
|
3040
|
+
console.error(color("dim", " Sign up + manage approved vendors at: " + API_BASE + "/account"));
|
|
3041
|
+
console.error(color("dim", " Pricing: " + API_BASE + "/pricing"));
|
|
3042
|
+
process.exit(1);
|
|
3043
|
+
}
|
|
3044
|
+
console.log("");
|
|
3045
|
+
console.log(` ${color("bold", "Syncing vendor lockfile with your account")} ${color("dim", `— ${domain}`)}`);
|
|
3046
|
+
console.log("");
|
|
3047
|
+
let resp;
|
|
3048
|
+
try {
|
|
3049
|
+
resp = await fetch(`${API_BASE}/api/vendor-allowlist?domain=${encodeURIComponent(domain)}`, {
|
|
3050
|
+
method: "GET",
|
|
3051
|
+
headers: {
|
|
3052
|
+
"user-agent": `pqcheck-cli/${VERSION} (vendors-sync)`,
|
|
3053
|
+
"authorization": "Bearer " + QP_API_KEY,
|
|
3054
|
+
},
|
|
3055
|
+
});
|
|
3056
|
+
} catch (err) {
|
|
3057
|
+
console.error(color("red", ` network error: ${err.message ?? err}`));
|
|
3058
|
+
process.exit(1);
|
|
3059
|
+
}
|
|
3060
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
3061
|
+
const body = await safeJSON(resp);
|
|
3062
|
+
console.error(color("red", ` authentication failed (HTTP ${resp.status})`));
|
|
3063
|
+
if (body?.error === "starter_required") {
|
|
3064
|
+
console.error(color("dim", ` Approved-vendor allowlist starts at Starter ($29/mo). ${body.message ?? ""}`));
|
|
3065
|
+
console.error(color("dim", ` ${API_BASE}/pricing?utm_source=cli_vendors_sync`));
|
|
3066
|
+
}
|
|
3067
|
+
process.exit(1);
|
|
3068
|
+
}
|
|
3069
|
+
if (!resp.ok) {
|
|
3070
|
+
console.error(color("red", ` /api/vendor-allowlist returned ${resp.status}`));
|
|
3071
|
+
process.exit(1);
|
|
3072
|
+
}
|
|
3073
|
+
const data = await resp.json();
|
|
3074
|
+
const allowlist = Array.isArray(data.allowlist) ? data.allowlist : [];
|
|
3075
|
+
// Filter to entries for this domain + extract vendor_origin
|
|
3076
|
+
const dashboardOrigins = new Set();
|
|
3077
|
+
for (const item of allowlist) {
|
|
3078
|
+
if (item && item.domain === domain && typeof item.vendor_origin === "string") {
|
|
3079
|
+
dashboardOrigins.add(item.vendor_origin);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// Merge with currently observed (so the lockfile covers everything we see + everything the user approved)
|
|
3084
|
+
let observed = new Set();
|
|
3085
|
+
try {
|
|
3086
|
+
observed = new Set(await fetchVendorOrigins(domain));
|
|
3087
|
+
} catch (err) {
|
|
3088
|
+
console.error(color("yellow", ` warning: could not fetch currently observed origins (${err.message}); using dashboard-only list`));
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
const merged = new Set([...observed, ...dashboardOrigins]);
|
|
3092
|
+
const origins = Array.from(merged).sort();
|
|
3093
|
+
const lockfile = buildVendorLockfile(domain, origins);
|
|
3094
|
+
lockfile.synced_from_account = new Date().toISOString();
|
|
3095
|
+
|
|
3096
|
+
try {
|
|
3097
|
+
await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
|
|
3098
|
+
} catch (err) {
|
|
3099
|
+
console.error(color("red", ` error writing ${outPath}: ${err.message}`));
|
|
3100
|
+
process.exit(1);
|
|
3101
|
+
}
|
|
3102
|
+
console.log(color("green", ` ✓ Synced ${outPath} — ${dashboardOrigins.size} dashboard-approved, ${observed.size} currently observed, ${origins.length} total.`));
|
|
3103
|
+
console.log("");
|
|
3104
|
+
console.log(` ${color("dim", "Commit the updated lockfile to your repo. `pqcheck vendors check` in CI will fail PRs that")}`);
|
|
3105
|
+
console.log(` ${color("dim", "introduce origins outside the merged set.")}`);
|
|
3106
|
+
console.log("");
|
|
3107
|
+
process.exit(0);
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// =============================================================================
|
|
3111
|
+
// `pqcheck onboard <domain>` — one-command setup wizard (locked 2026-05-16)
|
|
3112
|
+
// =============================================================================
|
|
3113
|
+
// Composes existing CLI subcommands into one happy-path flow:
|
|
3114
|
+
// 1. Quick public scan → show current grade so the user sees value first
|
|
3115
|
+
// 2. Scaffold the GitHub Action workflow (via runInitCommand)
|
|
3116
|
+
// 3. Generate a vendor lockfile snapshot (via runVendorsExport)
|
|
3117
|
+
// 4. Generate a release-checklist markdown
|
|
3118
|
+
// 5. Open the user's browser to /account#api-keys for API-key generation
|
|
3119
|
+
// 6. Print next-steps (secret name + commit commands + PR open)
|
|
3120
|
+
//
|
|
3121
|
+
// Design notes:
|
|
3122
|
+
// - Pure composition of already-reviewed subcommands; no new server endpoints.
|
|
3123
|
+
// - Browser-open uses platform default (`open`/`xdg-open`/`start`). When
|
|
3124
|
+
// headless or sandboxed, the URL is still printed so the user can copy.
|
|
3125
|
+
// - Each step is best-effort: a failed step prints a warning and continues
|
|
3126
|
+
// so a partial setup is still useful. Hard errors only stop early steps
|
|
3127
|
+
// where the rest can't proceed (invalid domain).
|
|
3128
|
+
// - --skip-scan / --skip-vendors / --skip-checklist let power users opt out.
|
|
3129
|
+
// - --no-open suppresses the browser launch (CI / SSH / headless friendly).
|
|
3130
|
+
// =============================================================================
|
|
3131
|
+
|
|
3132
|
+
async function runOnboardCommand(args) {
|
|
3133
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
3134
|
+
const raw = positional[0];
|
|
3135
|
+
if (!raw) {
|
|
3136
|
+
console.error(color("red", "error: pqcheck onboard requires a domain"));
|
|
3137
|
+
console.error(color("dim", "Usage: npx pqcheck onboard <domain> [--skip-scan] [--skip-vendors] [--skip-checklist] [--no-open] [--force] [--strict]"));
|
|
3138
|
+
process.exit(1);
|
|
3139
|
+
}
|
|
3140
|
+
const domain = normalizeDomain(raw);
|
|
3141
|
+
if (!isValidDomain(domain)) {
|
|
3142
|
+
console.error(color("red", `error: '${raw}' is not a valid domain`));
|
|
3143
|
+
process.exit(1);
|
|
3144
|
+
}
|
|
3145
|
+
const skipScan = args.includes("--skip-scan");
|
|
3146
|
+
const skipVendors = args.includes("--skip-vendors");
|
|
3147
|
+
const skipChecklist = args.includes("--skip-checklist");
|
|
3148
|
+
const noOpen = args.includes("--no-open");
|
|
3149
|
+
// R41 fix #1: --force lets users intentionally overwrite an existing
|
|
3150
|
+
// setup (idempotent re-runs). Without it, we abort if any of the
|
|
3151
|
+
// 3 output files already exists, so a user re-running onboard by
|
|
3152
|
+
// mistake doesn't lose hand-edited CIPHERWAKE_CHECKLIST.md / vendors
|
|
3153
|
+
// lockfile / workflow YAML.
|
|
3154
|
+
const force = args.includes("--force");
|
|
3155
|
+
// R41 fix #4: --strict makes onboard exit non-zero if any step fails.
|
|
3156
|
+
// For human-driven first-time setup, exit 0 (best-effort) is the right
|
|
3157
|
+
// default — printed warnings tell the user what to retry. For CI
|
|
3158
|
+
// automation around the wizard itself, --strict lets a build fail on
|
|
3159
|
+
// step errors. (Recommended usage in CI is still the individual
|
|
3160
|
+
// subcommands, not onboard.)
|
|
3161
|
+
const strict = args.includes("--strict");
|
|
3162
|
+
|
|
3163
|
+
// R41 fix #1: pre-flight overwrite check. We probe the 3 output paths
|
|
3164
|
+
// BEFORE running any step so an aborted run doesn't half-modify the
|
|
3165
|
+
// user's project. We use sync stat checks because we're not in a hot
|
|
3166
|
+
// path and the readability win is worth the tiny perf cost.
|
|
3167
|
+
if (!force) {
|
|
3168
|
+
const fsSync = await import("node:fs");
|
|
3169
|
+
const existing = [];
|
|
3170
|
+
try { fsSync.statSync(".github/workflows/cipherwake.yml"); existing.push(".github/workflows/cipherwake.yml"); } catch {}
|
|
3171
|
+
if (!skipVendors) {
|
|
3172
|
+
try { fsSync.statSync("cipherwake.vendors.json"); existing.push("cipherwake.vendors.json"); } catch {}
|
|
3173
|
+
}
|
|
3174
|
+
if (!skipChecklist) {
|
|
3175
|
+
try { fsSync.statSync("CIPHERWAKE_CHECKLIST.md"); existing.push("CIPHERWAKE_CHECKLIST.md"); } catch {}
|
|
3176
|
+
}
|
|
3177
|
+
if (existing.length > 0) {
|
|
3178
|
+
console.error("");
|
|
3179
|
+
console.error(color("red", ` error: refusing to overwrite existing files:`));
|
|
3180
|
+
for (const f of existing) console.error(color("dim", ` ${f}`));
|
|
3181
|
+
console.error("");
|
|
3182
|
+
console.error(color("dim", ` Re-run with --force to overwrite, or delete the files manually.`));
|
|
3183
|
+
console.error(color("dim", ` (--skip-vendors / --skip-checklist also bypass individual file checks.)`));
|
|
3184
|
+
process.exit(1);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
// R41 fix #4: track step failures for --strict mode
|
|
3189
|
+
let anyStepFailed = false;
|
|
3190
|
+
|
|
3191
|
+
console.log("");
|
|
3192
|
+
console.log(` ${color("bold", "🚀 Cipherwake onboarding")} ${color("dim", `— ${domain}`)}`);
|
|
3193
|
+
console.log("");
|
|
3194
|
+
console.log(` ${color("dim", "This will write ~3 files to your project and open your browser to grab an API key.")}`);
|
|
3195
|
+
console.log(` ${color("dim", "All steps are best-effort; you can re-run any individual subcommand later.")}`);
|
|
3196
|
+
console.log("");
|
|
3197
|
+
|
|
3198
|
+
// -------------------------------------------------------------------------
|
|
3199
|
+
// STEP 1 — quick scan (value-first; user sees their grade before any setup)
|
|
3200
|
+
// -------------------------------------------------------------------------
|
|
3201
|
+
if (!skipScan) {
|
|
3202
|
+
console.log(color("violet", ` ▸ Step 1 / 4 — scanning ${domain}…`));
|
|
3203
|
+
try {
|
|
3204
|
+
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}&source=onboard`, {
|
|
3205
|
+
method: "GET",
|
|
3206
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (onboard)` }),
|
|
3207
|
+
});
|
|
3208
|
+
if (resp.ok) {
|
|
3209
|
+
const report = await resp.json();
|
|
3210
|
+
const score = typeof report.score === "number" ? report.score.toFixed(1) : "?";
|
|
3211
|
+
const grade = report.grade || "?";
|
|
3212
|
+
const label = report.scoreLabel || "—";
|
|
3213
|
+
console.log(` ${color("bold", "Current grade:")} ${color("violet", grade)} (${score}/10 · ${label})`);
|
|
3214
|
+
console.log(` ${color("dim", `Full report: ${API_BASE}/r/${encodeURIComponent(domain)}`)}`);
|
|
3215
|
+
} else {
|
|
3216
|
+
console.log(color("yellow", ` skipped (scan returned HTTP ${resp.status})`));
|
|
3217
|
+
anyStepFailed = true;
|
|
3218
|
+
}
|
|
3219
|
+
} catch (err) {
|
|
3220
|
+
console.log(color("yellow", ` skipped (${err?.message ?? "scan failed"})`));
|
|
3221
|
+
anyStepFailed = true;
|
|
3222
|
+
}
|
|
3223
|
+
console.log("");
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
// -------------------------------------------------------------------------
|
|
3227
|
+
// STEP 2 — workflow scaffold
|
|
3228
|
+
// -------------------------------------------------------------------------
|
|
3229
|
+
console.log(color("violet", ` ▸ Step 2 / 4 — scaffolding GitHub Action workflow…`));
|
|
3230
|
+
try {
|
|
3231
|
+
// Call runInitCommand non-interactively. The function process.exit()'s on
|
|
3232
|
+
// its own; to compose it here we'd have to refactor. Pragmatic approach:
|
|
3233
|
+
// spawn a child node invoking ourselves with `init --yes --domain ...`.
|
|
3234
|
+
// That keeps each step idempotent and isolated.
|
|
3235
|
+
const { spawn } = await import("node:child_process");
|
|
3236
|
+
const result = await new Promise((resolve) => {
|
|
3237
|
+
const p = spawn(process.execPath, [
|
|
3238
|
+
process.argv[1],
|
|
3239
|
+
"init",
|
|
3240
|
+
"--yes",
|
|
3241
|
+
"--domain", domain,
|
|
3242
|
+
"--force",
|
|
3243
|
+
], { stdio: "inherit" });
|
|
3244
|
+
p.on("exit", (code) => resolve(code ?? 0));
|
|
3245
|
+
p.on("error", () => resolve(1));
|
|
3246
|
+
});
|
|
3247
|
+
if (result !== 0) {
|
|
3248
|
+
console.log(color("yellow", ` init exited ${result} — you can re-run \`pqcheck init\` later`));
|
|
3249
|
+
anyStepFailed = true;
|
|
3250
|
+
}
|
|
3251
|
+
} catch (err) {
|
|
3252
|
+
console.log(color("yellow", ` skipped init (${err?.message ?? "subprocess failed"})`));
|
|
3253
|
+
anyStepFailed = true;
|
|
3254
|
+
}
|
|
3255
|
+
console.log("");
|
|
3256
|
+
|
|
3257
|
+
// -------------------------------------------------------------------------
|
|
3258
|
+
// STEP 3 — vendor lockfile (skipped if --skip-vendors)
|
|
3259
|
+
// -------------------------------------------------------------------------
|
|
3260
|
+
if (!skipVendors) {
|
|
3261
|
+
console.log(color("violet", ` ▸ Step 3 / 4 — capturing vendor lockfile…`));
|
|
3262
|
+
try {
|
|
3263
|
+
const { spawn } = await import("node:child_process");
|
|
3264
|
+
const result = await new Promise((resolve) => {
|
|
3265
|
+
const p = spawn(process.execPath, [
|
|
3266
|
+
process.argv[1],
|
|
3267
|
+
"vendors",
|
|
3268
|
+
"export",
|
|
3269
|
+
domain,
|
|
3270
|
+
], { stdio: "inherit" });
|
|
3271
|
+
p.on("exit", (code) => resolve(code ?? 0));
|
|
3272
|
+
p.on("error", () => resolve(1));
|
|
3273
|
+
});
|
|
3274
|
+
if (result !== 0) {
|
|
3275
|
+
console.log(color("yellow", ` vendors export exited ${result} — you can re-run \`pqcheck vendors export ${domain}\` later`));
|
|
3276
|
+
anyStepFailed = true;
|
|
3277
|
+
}
|
|
3278
|
+
} catch (err) {
|
|
3279
|
+
console.log(color("yellow", ` skipped vendors export (${err?.message ?? "subprocess failed"})`));
|
|
3280
|
+
anyStepFailed = true;
|
|
3281
|
+
}
|
|
3282
|
+
console.log("");
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
// -------------------------------------------------------------------------
|
|
3286
|
+
// STEP 4 — release checklist (skipped if --skip-checklist)
|
|
3287
|
+
// -------------------------------------------------------------------------
|
|
3288
|
+
if (!skipChecklist) {
|
|
3289
|
+
console.log(color("violet", ` ▸ Step 4 / 4 — writing release checklist…`));
|
|
3290
|
+
try {
|
|
3291
|
+
const fs = await import("node:fs/promises");
|
|
3292
|
+
const checklist = buildReleaseChecklistMarkdown(domain);
|
|
3293
|
+
await fs.writeFile("CIPHERWAKE_CHECKLIST.md", checklist, "utf8");
|
|
3294
|
+
console.log(` ${color("green", "✓ Wrote CIPHERWAKE_CHECKLIST.md")}`);
|
|
3295
|
+
} catch (err) {
|
|
3296
|
+
console.log(color("yellow", ` skipped checklist (${err?.message ?? "write failed"})`));
|
|
3297
|
+
anyStepFailed = true;
|
|
3298
|
+
}
|
|
3299
|
+
console.log("");
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
// -------------------------------------------------------------------------
|
|
3303
|
+
// Browser open + final next-steps
|
|
3304
|
+
// -------------------------------------------------------------------------
|
|
3305
|
+
// Query MUST come before fragment per RFC 3986. The previous order
|
|
3306
|
+
// `#api-keys?utm_source=onboard` made utm_source part of the fragment
|
|
3307
|
+
// (which never reaches the server), so attribution analytics never fired.
|
|
3308
|
+
const apiKeyUrl = `${API_BASE}/account?utm_source=onboard#api-keys`;
|
|
3309
|
+
console.log(color("bold", " ✓ Setup files written. Three steps remain:"));
|
|
3310
|
+
console.log("");
|
|
3311
|
+
console.log(` ${color("dim", "1.")} ${color("bold", "Get a free API key")} (30 Trust Diff calls/month)`);
|
|
3312
|
+
console.log(` ${color("violet", apiKeyUrl)}`);
|
|
3313
|
+
if (!noOpen) {
|
|
3314
|
+
const opened = await tryOpenBrowser(apiKeyUrl);
|
|
3315
|
+
if (opened) {
|
|
3316
|
+
console.log(` ${color("dim", "(opened in your browser — sign in / sign up there)")}`);
|
|
3317
|
+
} else {
|
|
3318
|
+
console.log(` ${color("dim", "(copy the URL above; --no-open suppresses this hint)")}`);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
console.log("");
|
|
3322
|
+
console.log(` ${color("dim", "2.")} ${color("bold", "Add the key as a GitHub repo secret")}`);
|
|
3323
|
+
console.log(` ${color("dim", "GitHub → Settings → Secrets and variables → Actions → New repository secret")}`);
|
|
3324
|
+
console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY Value: qpk_... (from step 1)")}`);
|
|
3325
|
+
console.log("");
|
|
3326
|
+
console.log(` ${color("dim", "3.")} ${color("bold", "Commit + push")}`);
|
|
3327
|
+
// R41 Q1.15 (locked 2026-05-16): build the git-add file list as an array
|
|
3328
|
+
// and join, so we don't print trailing-space args when --skip flags are
|
|
3329
|
+
// used. Harmless bash semantics either way; cleaner output.
|
|
3330
|
+
const filesToAdd = [".github/workflows/cipherwake.yml"];
|
|
3331
|
+
if (!skipVendors) filesToAdd.push("cipherwake.vendors.json");
|
|
3332
|
+
if (!skipChecklist) filesToAdd.push("CIPHERWAKE_CHECKLIST.md");
|
|
3333
|
+
console.log(` ${color("dim", "$")} git add ${filesToAdd.join(" ")}`);
|
|
3334
|
+
console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
|
|
3335
|
+
console.log(` ${color("dim", "$")} git push`);
|
|
3336
|
+
console.log("");
|
|
3337
|
+
console.log(` ${color("dim", "Open a PR after pushing and Cipherwake will comment inline within ~60s of the workflow firing.")}`);
|
|
3338
|
+
console.log("");
|
|
3339
|
+
// R41 fix #4: --strict makes onboard exit non-zero if any step failed.
|
|
3340
|
+
// Default (best-effort) exit 0 keeps the wizard friendly for first-time
|
|
3341
|
+
// human setup — the visible yellow warnings tell them what to retry.
|
|
3342
|
+
if (strict && anyStepFailed) {
|
|
3343
|
+
console.log(color("dim", " (--strict: one or more steps failed; exiting non-zero)"));
|
|
3344
|
+
process.exit(1);
|
|
3345
|
+
}
|
|
3346
|
+
process.exit(0);
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
// R41 fix #3: buildReleaseChecklistMarkdown is now a thin alias to the shared
|
|
3350
|
+
// renderReleaseChecklist() helper defined alongside runReleaseChecklistCommand.
|
|
3351
|
+
// Single source of truth — when either subcommand's content changes, both
|
|
3352
|
+
// callers update automatically.
|
|
3353
|
+
function buildReleaseChecklistMarkdown(domain) {
|
|
3354
|
+
return renderReleaseChecklist(domain, { generator: "onboard" });
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
// Cross-platform browser launcher. Returns true if a launcher binary
|
|
3358
|
+
// dispatched successfully; false if no launcher is available (e.g. headless
|
|
3359
|
+
// server, sandboxed CI, broken xdg-open config).
|
|
3360
|
+
//
|
|
3361
|
+
// R41 fix #2 (locked 2026-05-16): use exit-event detection + longer timeout
|
|
3362
|
+
// so we don't falsely claim "(opened in your browser)" when xdg-open is
|
|
3363
|
+
// installed but the launcher exits non-zero (no graphical session, no
|
|
3364
|
+
// MIME handler). Previously a flat 200ms timeout resolved true even when
|
|
3365
|
+
// the launcher exited 3 because no display was available.
|
|
3366
|
+
async function tryOpenBrowser(url) {
|
|
3367
|
+
if (process.env.CI || process.env.CIPHERWAKE_NO_BROWSER) return false;
|
|
3368
|
+
const { spawn } = await import("node:child_process");
|
|
3369
|
+
const platform = process.platform;
|
|
3370
|
+
let cmd, cmdArgs;
|
|
3371
|
+
if (platform === "darwin") {
|
|
3372
|
+
cmd = "open"; cmdArgs = [url];
|
|
3373
|
+
} else if (platform === "win32") {
|
|
3374
|
+
cmd = "cmd"; cmdArgs = ["/c", "start", "", url];
|
|
3375
|
+
} else {
|
|
3376
|
+
cmd = "xdg-open"; cmdArgs = [url];
|
|
3377
|
+
}
|
|
3378
|
+
return await new Promise((resolve) => {
|
|
3379
|
+
let settled = false;
|
|
3380
|
+
let p;
|
|
3381
|
+
try {
|
|
3382
|
+
p = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
|
|
3383
|
+
} catch {
|
|
3384
|
+
resolve(false);
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
3387
|
+
p.on("error", () => { if (!settled) { settled = true; resolve(false); } });
|
|
3388
|
+
p.on("exit", (code) => { if (!settled) { settled = true; resolve(code === 0); } });
|
|
3389
|
+
p.unref();
|
|
3390
|
+
// Belt-and-suspenders: if the launcher takes >1s to exit AND no error
|
|
3391
|
+
// event has fired, assume it dispatched and went detached (open on
|
|
3392
|
+
// macOS does this — returns after AppleScript-asking Finder/Safari).
|
|
3393
|
+
setTimeout(() => {
|
|
3394
|
+
if (!settled) { settled = true; resolve(true); }
|
|
3395
|
+
}, 1000);
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
|
|
2263
3399
|
main().catch((err) => {
|
|
2264
3400
|
console.error(color("red", `fatal: ${err.message}`));
|
|
2265
3401
|
process.exit(2);
|