proof-of-commitment 1.14.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/index.js +130 -19
  4. package/package.json +4 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Håkon Åmdal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -56,7 +56,7 @@ npx proof-of-commitment --file go.sum # full transitive set
56
56
  # Install once, then use the `poc` alias:
57
57
  npm install -g proof-of-commitment
58
58
 
59
- # Get a free API key at https://getcommit.dev/get-started, then:
59
+ # Get a free API key at https://getcommit.dev/get-started?utm_source=cli, then:
60
60
  poc login sk_commit_your_key_here
61
61
  # ✓ Authenticated — Tier: Free — Usage: 0/200 requests (daily)
62
62
 
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * proof-of-commitment CLI v1.14.0
3
+ * proof-of-commitment CLI v1.18.0
4
4
  * Scores npm/PyPI/Cargo/Go packages on behavioral commitment signals.
5
5
  * Usage: npx proof-of-commitment [packages...] [options]
6
6
  */
@@ -10,6 +10,18 @@ const KEYS_API = 'https://poc-backend.amdal-dev.workers.dev/api/keys';
10
10
  const WATCHLIST_API = 'https://poc-backend.amdal-dev.workers.dev/api/watchlist';
11
11
  const WEB = 'https://getcommit.dev/audit';
12
12
 
13
+ // Backend uses Accept header to decide JSON vs plain-text body on 429
14
+ // (added 2026-05-22 so v1.14.0 CLI, which sends the fetch default `*/*`,
15
+ // gets a readable text body inside its Error wrapper instead of a JSON
16
+ // dump). v1.17.0+ explicitly opts into JSON so handle429() can parse
17
+ // shared_ip_hint, retry_after_seconds, etc. Default fetch in Node 20+
18
+ // sends `Accept: */*` — without this header the backend would assume
19
+ // the legacy raw-dump path.
20
+ const JSON_API_HEADERS = {
21
+ 'Content-Type': 'application/json',
22
+ 'Accept': 'application/json',
23
+ };
24
+
13
25
  // ANSI color helpers
14
26
  const c = {
15
27
  reset: '\x1b[0m',
@@ -42,6 +54,76 @@ function clr(code, text) {
42
54
  return `${code}${text}${c.reset}`;
43
55
  }
44
56
 
57
+ /**
58
+ * Renders a human-readable rate-limit message to stderr and exits with code 1.
59
+ * Parses JSON body from a 429 response.
60
+ *
61
+ * v1.17.0: reads structured `shared_ip_hint` / `instant_key_url` /
62
+ * `packages_already_scored` / `retry_after_seconds` fields (see /api/audit
63
+ * 429 response). Older backends just return `message` + `upgrade_url`;
64
+ * the helper degrades gracefully. Single CTA only — paid upgrade is removed
65
+ * here because dogfood (2026-05-21) found that splitting attention between
66
+ * "free key" and "paid upgrade" lowers free-key conversion on the rescue
67
+ * step. The user is hitting the *free* wall — surface the free fix.
68
+ */
69
+ async function handle429(res) {
70
+ let data = {};
71
+ try {
72
+ data = await res.json();
73
+ } catch {
74
+ // Non-JSON fallback — leave data as {}
75
+ }
76
+
77
+ const message = data.message || 'Daily free audit limit reached on this network IP.';
78
+ const instantKeyUrl =
79
+ data.instant_key_url ||
80
+ data.upgrade_url ||
81
+ 'https://getcommit.dev/get-started?ref=audit-cli-429';
82
+ const partial = Array.isArray(data.packages_already_scored)
83
+ ? data.packages_already_scored
84
+ : [];
85
+ const retryAfter = Number.isFinite(data.retry_after_seconds)
86
+ ? data.retry_after_seconds
87
+ : null;
88
+
89
+ // Forward-compat: if backend ever returns partial scoring on 429,
90
+ // print what we have BEFORE the rescue message. Falls back to JSON
91
+ // dump if the row shape isn't a complete table row.
92
+ if (partial.length > 0) {
93
+ try {
94
+ console.log();
95
+ console.log(clr(c.dim, ` Partial results scored before the limit hit (${partial.length}):`));
96
+ printTable(partial, { totalScanned: partial.length });
97
+ } catch {
98
+ console.log(JSON.stringify(partial, null, 2));
99
+ }
100
+ }
101
+
102
+ console.error('');
103
+ console.error(clr(c.yellow + c.bold, `⚠ ${message}`));
104
+ if (data.shared_ip_hint) {
105
+ console.error(
106
+ clr(
107
+ c.dim,
108
+ ' Heads up: corporate NAT, CI runners, and dev containers all share egress IPs,'
109
+ )
110
+ );
111
+ console.error(
112
+ clr(c.dim, ' so the free-tier counter ticks faster than your personal usage suggests.')
113
+ );
114
+ }
115
+ console.error('');
116
+ console.error(clr(c.cyan + c.bold, ` → Free API key in 30 seconds (no card): ${instantKeyUrl}`));
117
+ if (retryAfter && retryAfter > 0) {
118
+ const hours = Math.floor(retryAfter / 3600);
119
+ const mins = Math.floor((retryAfter % 3600) / 60);
120
+ const resetIn = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
121
+ console.error(clr(c.dim, ` or wait — free-tier resets in ${resetIn} (00:00 UTC).`));
122
+ }
123
+ console.error('');
124
+ process.exit(1);
125
+ }
126
+
45
127
  /** Check if riskFlags array contains a CRITICAL-level flag (handles both "CRITICAL" and "CRITICAL: ..." formats) */
46
128
  function hasCritical(flags) {
47
129
  return flags && flags.some(f => typeof f === 'string' && f.startsWith('CRITICAL'));
@@ -197,10 +279,10 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
197
279
  }
198
280
  }
199
281
 
200
- // Contextual upsell — show when findings make monitoring relevant
201
- // In TTY mode, inlineSignup() handles the upsell interactively — skip static text
282
+ // Contextual upsell — show when findings make monitoring relevant.
283
+ // In TTY mode, inlineSignup() handles the CRITICAL/risky upsell interactively — skip static text there.
284
+ const hasKey = !!process.env.COMMIT_API_KEY || _cachedHasKey;
202
285
  if (effectiveCritical > 0) {
203
- const hasKey = !!process.env.COMMIT_API_KEY || _cachedHasKey;
204
286
  if (hasKey) {
205
287
  console.log(clr(c.dim, `\n 📊 Monitor ${effectiveCritical === 1 ? 'this package' : 'these packages'}: `) +
206
288
  clr(c.cyan, `poc watch ${results.find(r => hasCritical(r.riskFlags))?.name || results[0]?.name}`));
@@ -211,6 +293,19 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
211
293
  console.log(clr(c.dim, ' Then run: ') + clr(c.cyan, 'poc login'));
212
294
  }
213
295
  // else: TTY mode — inlineSignup() will prompt interactively after printTable
296
+ } else if (!hasKey) {
297
+ // HEALTHY case + no saved key: soft watchlist CTA. The all-healthy
298
+ // footer previously surfaced only CI-shaped CTAs (Action, `poc init`)
299
+ // which both require active commitment — workflow change + repo edit.
300
+ // The lowest-friction conversion (email → API key → watchlist) was
301
+ // hidden behind the CRITICAL gate of inlineSignup(). Buyer-journey
302
+ // dogfood 2026-05-24 found 1472 weekly downloads → 0 organic signups;
303
+ // the watchlist value prop ("alert me when these degrade") is real
304
+ // for healthy packages too — that's exactly when monitoring matters.
305
+ // ref=audit-baseline distinguishes this funnel from audit-cli-429
306
+ // (rate-limit rescue) and from the static utm_source=cli help-line.
307
+ console.log(clr(c.dim, '\n 📊 Lock in this baseline — get alerted if any package degrades:'));
308
+ console.log(clr(c.dim, ' ') + clr(c.cyan, 'https://getcommit.dev/get-started?ref=audit-baseline&utm_source=cli') + clr(c.dim, ' (free, no card, 10s)'));
214
309
  }
215
310
  console.log();
216
311
  }
@@ -221,16 +316,19 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
221
316
  * into a single CLI prompt.
222
317
  */
223
318
  async function inlineSignup(results) {
224
- // Only prompt in interactive TTY when CRITICAL packages found and no key saved
319
+ // Only prompt in interactive TTY when findings make monitoring relevant and no key saved
225
320
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
226
321
  const hasKey = !!process.env.COMMIT_API_KEY || _cachedHasKey;
227
322
  if (hasKey) return;
228
323
  const critPkgs = results.filter(r => hasCritical(r.riskFlags));
229
- if (critPkgs.length === 0) return;
324
+ const lowScorePkgs = results.filter(r => typeof r.score === 'number' && r.score < 60);
325
+ // Gate: ≥1 CRITICAL, OR ≥2 packages with score<60, OR large scan (≥50 packages)
326
+ const shouldPrompt = critPkgs.length >= 1 || lowScorePkgs.length >= 2 || results.length >= 50;
327
+ if (!shouldPrompt) return;
230
328
 
231
329
  console.log(clr(c.dim, ' ─────────────────────────────────────────────'));
232
- console.log(clr(c.bold, ' 🔔 Get alerts when these scores change?'));
233
- console.log(clr(c.dim, ' Free API key — no credit card, 10 seconds.\n'));
330
+ console.log(clr(c.bold, ' 🔔 Lock in this audit. Get alerts if these packages get worse.'));
331
+ console.log(clr(c.dim, ' Free, no card, 10 seconds. Saves to ~/.commit/config.\n'));
234
332
 
235
333
  const { createInterface } = await import('readline');
236
334
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -286,7 +384,7 @@ async function inlineSignup(results) {
286
384
 
287
385
  function printHelp() {
288
386
  console.log(`
289
- ${clr(c.bold, 'proof-of-commitment')} v1.14.0 — supply chain risk scorer
387
+ ${clr(c.bold, 'proof-of-commitment')} v1.18.0 — supply chain risk scorer
290
388
 
291
389
  ${clr(c.bold, 'Usage:')}
292
390
  npx proof-of-commitment Auto-detect manifest in current dir
@@ -323,7 +421,7 @@ ${clr(c.bold, 'Monitoring (Pro):')}
323
421
  poc watchlist List monitored packages with current scores + risk
324
422
  poc unwatch <pkg> Remove a package from monitoring
325
423
 
326
- Get a free key: https://getcommit.dev/get-started
424
+ Get a free key: https://getcommit.dev/get-started?utm_source=cli
327
425
  Upgrade to Pro: https://getcommit.dev/pricing
328
426
 
329
427
  ${clr(c.bold, 'Options:')}
@@ -699,18 +797,21 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
699
797
  }
700
798
 
701
799
  let completed = 0;
800
+ let batchedCta = null;
702
801
  const results = await Promise.all(
703
802
  batches.map(async (batch) => {
704
803
  const res = await fetch(API, {
705
804
  method: 'POST',
706
- headers: { 'Content-Type': 'application/json' },
805
+ headers: JSON_API_HEADERS,
707
806
  body: JSON.stringify({ packages: batch, ecosystem }),
708
807
  });
709
808
  if (!res.ok) {
809
+ if (res.status === 429) await handle429(res);
710
810
  const text = await res.text();
711
811
  throw new Error(`API error ${res.status}: ${text}`);
712
812
  }
713
813
  const data = await res.json();
814
+ if (data._cta) batchedCta = data._cta;
714
815
  completed += batch.length;
715
816
  if (onProgress) onProgress(completed, packages.length);
716
817
  return data.results || [];
@@ -727,7 +828,7 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
727
828
  return (a.score || 100) - (b.score || 100);
728
829
  });
729
830
 
730
- return all;
831
+ return { results: all, _cta: batchedCta };
731
832
  }
732
833
 
733
834
  /** Parse --fail-on=<level>. Returns one of 'critical' | 'risky' | 'none'. */
@@ -842,7 +943,7 @@ async function cmdLogin(keyArg) {
842
943
 
843
944
  if (!key || !key.startsWith('sk_commit_')) {
844
945
  console.error(clr(c.red, '\n Invalid API key format. Keys start with sk_commit_'));
845
- console.error(clr(c.dim, ' Get one at https://getcommit.dev/get-started\n'));
946
+ console.error(clr(c.dim, ' Get one at https://getcommit.dev/get-started?utm_source=cli\n'));
846
947
  process.exit(1);
847
948
  }
848
949
 
@@ -884,7 +985,7 @@ async function cmdStatus() {
884
985
  if (!key) {
885
986
  console.log(clr(c.dim, '\n Not logged in.'));
886
987
  console.log(clr(c.dim, ' Run ') + clr(c.cyan, 'poc login') + clr(c.dim, ' to authenticate.'));
887
- console.log(clr(c.dim, ' Get a free key at https://getcommit.dev/get-started\n'));
988
+ console.log(clr(c.dim, ' Get a free key at https://getcommit.dev/get-started?utm_source=cli\n'));
888
989
  return;
889
990
  }
890
991
 
@@ -1285,14 +1386,17 @@ async function cmdReport(packages, ecosystem, { filePath, isLockfile, totalScann
1285
1386
  if (packages.length <= 20) {
1286
1387
  const res = await fetch(API, {
1287
1388
  method: 'POST',
1288
- headers: { 'Content-Type': 'application/json' },
1389
+ headers: JSON_API_HEADERS,
1289
1390
  body: JSON.stringify({ packages, ecosystem }),
1290
1391
  });
1291
- if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
1392
+ if (!res.ok) {
1393
+ if (res.status === 429) await handle429(res);
1394
+ throw new Error(`API error ${res.status}: ${await res.text()}`);
1395
+ }
1292
1396
  const data = await res.json();
1293
1397
  allResults = data.results || [];
1294
1398
  } else {
1295
- allResults = await auditBatched(packages, ecosystem);
1399
+ allResults = (await auditBatched(packages, ecosystem)).results;
1296
1400
  }
1297
1401
  } catch (err) {
1298
1402
  console.error(`\nError: ${err.message}`);
@@ -1674,6 +1778,7 @@ async function main() {
1674
1778
  const t0 = Date.now();
1675
1779
 
1676
1780
  let allResults;
1781
+ let apiCta = null;
1677
1782
 
1678
1783
  if (packages.length <= 20) {
1679
1784
  if (!jsonOutput) process.stdout.write(clr(c.dim, `Scoring ${packages.length} ${ecosystem} package${packages.length > 1 ? 's' : ''}...`));
@@ -1681,15 +1786,17 @@ async function main() {
1681
1786
  try {
1682
1787
  const res = await fetch(API, {
1683
1788
  method: 'POST',
1684
- headers: { 'Content-Type': 'application/json' },
1789
+ headers: JSON_API_HEADERS,
1685
1790
  body: JSON.stringify({ packages, ecosystem }),
1686
1791
  });
1687
1792
  if (!res.ok) {
1793
+ if (res.status === 429) await handle429(res);
1688
1794
  const text = await res.text();
1689
1795
  throw new Error(`API error ${res.status}: ${text}`);
1690
1796
  }
1691
1797
  const data = await res.json();
1692
1798
  allResults = data.results || [];
1799
+ apiCta = data._cta || null;
1693
1800
  } catch (err) {
1694
1801
  console.error(`\nError: ${err.message}`);
1695
1802
  process.exit(1);
@@ -1704,7 +1811,7 @@ async function main() {
1704
1811
 
1705
1812
  let lastPct = 0;
1706
1813
  try {
1707
- allResults = await auditBatched(packages, ecosystem, {
1814
+ const batchResult = await auditBatched(packages, ecosystem, {
1708
1815
  onProgress: (done, total) => {
1709
1816
  const pct = Math.round((done / total) * 100);
1710
1817
  if (pct >= lastPct + 20) {
@@ -1713,6 +1820,8 @@ async function main() {
1713
1820
  }
1714
1821
  }
1715
1822
  });
1823
+ allResults = batchResult.results;
1824
+ apiCta = batchResult._cta;
1716
1825
  } catch (err) {
1717
1826
  console.error(`\nError: ${err.message}`);
1718
1827
  process.exit(1);
@@ -1739,6 +1848,7 @@ async function main() {
1739
1848
  const displayed = allResults.slice(0, MAX_DISPLAY);
1740
1849
  const criticalTotal = allResults.filter(r => hasCritical(r.riskFlags)).length;
1741
1850
  printTable(displayed, { totalScanned: allResults.length, totalCritical: criticalTotal, lockfile: true });
1851
+ if (apiCta) console.log(clr(c.dim + c.cyan, `\n ${apiCta}`));
1742
1852
  await inlineSignup(displayed);
1743
1853
  if (shouldFail(allResults, failOn)) {
1744
1854
  console.error(clr(c.red + c.bold, `\n✗ --fail-on=${failOn} threshold met. Exit 1.`));
@@ -1770,6 +1880,7 @@ async function main() {
1770
1880
  }
1771
1881
 
1772
1882
  printTable(allResults);
1883
+ if (apiCta) console.log(clr(c.dim + c.cyan, `\n ${apiCta}`));
1773
1884
  await inlineSignup(allResults);
1774
1885
  if (shouldFail(allResults, failOn)) {
1775
1886
  console.error(clr(c.red + c.bold, `✗ --fail-on=${failOn} threshold met. Exit 1.`));
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "proof-of-commitment",
3
- "version": "1.14.0",
3
+ "version": "1.18.0",
4
+ "mcpName": "io.github.piiiico/proof-of-commitment",
4
5
  "description": "Supply chain risk scorer for npm, PyPI, Cargo, and Go packages — behavioral signals that can't be faked",
5
6
  "type": "module",
6
7
  "bin": {
@@ -10,7 +11,8 @@
10
11
  "main": "./index.js",
11
12
  "files": [
12
13
  "index.js",
13
- "README.md"
14
+ "README.md",
15
+ "LICENSE"
14
16
  ],
15
17
  "keywords": [
16
18
  "supply-chain",