proof-of-commitment 1.33.0 → 1.35.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 (2) hide show
  1. package/index.js +136 -29
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * proof-of-commitment CLI v1.33.0
3
+ * proof-of-commitment CLI v1.34.0
4
4
  * Scores npm/PyPI/Cargo/Go packages on behavioral commitment signals.
5
5
  * Usage: npx proof-of-commitment [packages...] [options]
6
6
  */
@@ -90,14 +90,49 @@ async function handle429(res) {
90
90
  // Non-JSON fallback — leave data as {}
91
91
  }
92
92
 
93
+ const partial = Array.isArray(data.packages_already_scored)
94
+ ? data.packages_already_scored
95
+ : [];
96
+
97
+ // Forward-compat: if backend returns partial scoring on 429,
98
+ // print what we have BEFORE the rescue message. Falls back to JSON
99
+ // dump if the row shape isn't a complete table row.
100
+ // (auditBatched aggregates across batches and prints its own table — it
101
+ // calls renderRescueCta() directly, skipping this block.)
102
+ if (partial.length > 0) {
103
+ try {
104
+ console.log();
105
+ console.log(clr(c.dim, ` Partial results scored before the limit hit (${partial.length}):`));
106
+ printTable(partial, { totalScanned: partial.length });
107
+ } catch {
108
+ console.log(JSON.stringify(partial, null, 2));
109
+ }
110
+ }
111
+
112
+ await renderRescueCta(data);
113
+ }
114
+
115
+ /**
116
+ * Render the rate-limit rescue CTA from a parsed 429 response body.
117
+ * Separated from handle429() so auditBatched can aggregate partials across
118
+ * 17 parallel batches, print ONE combined table, then surface ONE rescue
119
+ * CTA — instead of letting the first batch to land kill the process with
120
+ * only 3 of N*3 partials visible (where N = #batches hitting overshoot).
121
+ *
122
+ * Exit semantics preserved: this function ALWAYS process.exit(1) at the
123
+ * tail, so any caller that's reached the rescue CTA leaves the program.
124
+ *
125
+ * The shape mirrors `data` from a 429 JSON response:
126
+ * { message?, instant_key_url?, upgrade_url?, retry_after_seconds?,
127
+ * retry_after?, overshoot?, tier_suggestion?, upgrade?, tier?,
128
+ * shared_ip_hint? }
129
+ */
130
+ async function renderRescueCta(data) {
93
131
  const message = data.message || 'Daily free audit limit reached on this network IP.';
94
132
  const instantKeyUrl =
95
133
  data.instant_key_url ||
96
134
  data.upgrade_url ||
97
135
  'https://getcommit.dev/get-started?ref=audit-cli-429';
98
- const partial = Array.isArray(data.packages_already_scored)
99
- ? data.packages_already_scored
100
- : [];
101
136
  // Authenticated keys: retry_after (seconds, used by worker auth-middleware quota path).
102
137
  // Anonymous IPs: retry_after_seconds (legacy / overshoot rescue path).
103
138
  // Read both so both paths surface a reset-time hint.
@@ -134,19 +169,6 @@ async function handle429(res) {
134
169
  typeof data.tier === 'string' &&
135
170
  data.tier !== 'anonymous';
136
171
 
137
- // Forward-compat: if backend ever returns partial scoring on 429,
138
- // print what we have BEFORE the rescue message. Falls back to JSON
139
- // dump if the row shape isn't a complete table row.
140
- if (partial.length > 0) {
141
- try {
142
- console.log();
143
- console.log(clr(c.dim, ` Partial results scored before the limit hit (${partial.length}):`));
144
- printTable(partial, { totalScanned: partial.length });
145
- } catch {
146
- console.log(JSON.stringify(partial, null, 2));
147
- }
148
- }
149
-
150
172
  console.error('');
151
173
  console.error(clr(c.yellow + c.bold, `⚠ ${message}`));
152
174
  if (data.shared_ip_hint) {
@@ -620,6 +642,12 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
620
642
  );
621
643
  console.log(breakdown);
622
644
  }
645
+
646
+ // Show WARN-level risk flags inline (dormant publishers, stale releases, etc.)
647
+ const warnFlags = (pkg.riskFlags || []).filter(f => f.startsWith('WARN:'));
648
+ for (const flag of warnFlags) {
649
+ console.log(clr(c.yellow, ` ⚠ ${flag}`));
650
+ }
623
651
  }
624
652
 
625
653
  console.log(divider);
@@ -989,7 +1017,7 @@ async function inlineSignup(results, opts = {}) {
989
1017
 
990
1018
  function printHelp() {
991
1019
  console.log(`
992
- ${clr(c.bold, 'proof-of-commitment')} v1.33.0 — supply chain risk scorer
1020
+ ${clr(c.bold, 'proof-of-commitment')} v1.34.0 — supply chain risk scorer
993
1021
 
994
1022
  ${clr(c.bold, 'Usage:')}
995
1023
  npx proof-of-commitment Auto-detect manifest in current dir
@@ -1416,27 +1444,81 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
1416
1444
  let batchedCta = null;
1417
1445
  // Resolve auth once so all parallel batches share the same key lookup.
1418
1446
  const headers = await auditHeaders();
1419
- const results = await Promise.all(
1447
+
1448
+ // Use Promise.allSettled so a single rate-limit hit on one batch doesn't
1449
+ // discard the work the other batches successfully completed. Pre-fix
1450
+ // (2026-06-16 dogfood): backend in overshoot returns 429 with
1451
+ // RATE_LIMIT_TASTE=3 packages_already_scored on EVERY parallel batch
1452
+ // (each batch increments the IP counter, all see auditCount > limit,
1453
+ // each gets trimmed to 3 + 429). The first batch's 429 to land called
1454
+ // handle429() which process.exit(1)'d — discarding the OTHER 16 batches'
1455
+ // 3-package partials (16×3=48 packages of scored value silently dropped).
1456
+ //
1457
+ // Post-fix: aggregate 200 results AND 429 partials across all batches.
1458
+ // User sees up to N×3 packages instead of 3 when fully overshoot, and
1459
+ // (16×20)+(1×3)=323 packages instead of 0 when limit hits mid-scan.
1460
+ // Rescue CTA is surfaced ONCE at the end via _rescue (handed to
1461
+ // renderRescueCta in the calling path), not per-batch via handle429.
1462
+ const settled = await Promise.allSettled(
1420
1463
  batches.map(async (batch) => {
1421
1464
  const res = await fetch(API, {
1422
1465
  method: 'POST',
1423
1466
  headers,
1424
1467
  body: JSON.stringify({ packages: batch, ecosystem }),
1425
1468
  });
1426
- if (!res.ok) {
1427
- if (res.status === 429) await handle429(res);
1428
- const text = await res.text();
1429
- throw new Error(`API error ${res.status}: ${text}`);
1430
- }
1431
- const data = await res.json();
1432
- if (data._cta) batchedCta = data._cta;
1469
+ let data = {};
1470
+ try { data = await res.json(); } catch {}
1433
1471
  completed += batch.length;
1434
1472
  if (onProgress) onProgress(completed, packages.length);
1435
- return data.results || [];
1473
+ if (res.ok) {
1474
+ return { kind: 'ok', data };
1475
+ }
1476
+ if (res.status === 429) {
1477
+ return { kind: '429', data };
1478
+ }
1479
+ return { kind: 'error', status: res.status, data };
1436
1480
  })
1437
1481
  );
1438
1482
 
1439
- const all = results.flat();
1483
+ const aggregated = [];
1484
+ let rescue = null;
1485
+ let nonRateErrorMsg = null;
1486
+ for (const r of settled) {
1487
+ if (r.status === 'rejected') {
1488
+ nonRateErrorMsg = nonRateErrorMsg || String(r.reason && r.reason.message || r.reason || 'unknown');
1489
+ continue;
1490
+ }
1491
+ const v = r.value;
1492
+ if (v.kind === 'ok') {
1493
+ if (v.data._cta) batchedCta = v.data._cta;
1494
+ aggregated.push(...(v.data.results || []));
1495
+ } else if (v.kind === '429') {
1496
+ // First 429's payload carries the rescue metadata; later 429s'
1497
+ // metadata is interchangeable (same overshoot/upgrade context),
1498
+ // so we don't need to merge it.
1499
+ if (!rescue) rescue = v.data;
1500
+ aggregated.push(...(Array.isArray(v.data.packages_already_scored) ? v.data.packages_already_scored : []));
1501
+ } else if (v.kind === 'error') {
1502
+ const errMsg = (v.data && v.data.error) ? v.data.error : `API error ${v.status}`;
1503
+ nonRateErrorMsg = nonRateErrorMsg || errMsg;
1504
+ }
1505
+ }
1506
+
1507
+ // Hard failure: zero packages scored AND a non-rate-limit error fired.
1508
+ // (If rescue is set but aggregated is empty, fall through — the caller
1509
+ // will render the rescue CTA and exit; no need to throw.)
1510
+ if (aggregated.length === 0 && !rescue && nonRateErrorMsg) {
1511
+ throw new Error(nonRateErrorMsg);
1512
+ }
1513
+
1514
+ // Dedup by name+ecosystem — defensive against any backend echoing.
1515
+ const seen = new Set();
1516
+ const all = aggregated.filter((p) => {
1517
+ const k = `${p && p.name}:${p && p.ecosystem}`;
1518
+ if (seen.has(k)) return false;
1519
+ seen.add(k);
1520
+ return true;
1521
+ });
1440
1522
 
1441
1523
  // Sort: CRITICAL first, then by score ascending
1442
1524
  all.sort((a, b) => {
@@ -1446,7 +1528,7 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
1446
1528
  return (a.score || 100) - (b.score || 100);
1447
1529
  });
1448
1530
 
1449
- return { results: all, _cta: batchedCta };
1531
+ return { results: all, _cta: batchedCta, _rescue: rescue };
1450
1532
  }
1451
1533
 
1452
1534
  /** Parse --fail-on=<level>. Returns one of 'critical' | 'risky' | 'none'. */
@@ -2930,6 +3012,7 @@ async function main() {
2930
3012
  if (!structuredOutput) process.stdout.write(clr(c.dim, `Scanning ${packages.length} packages (${batches} batches in parallel)...`));
2931
3013
 
2932
3014
  let lastPct = 0;
3015
+ let batchRescue = null;
2933
3016
  try {
2934
3017
  const batchResult = await auditBatched(packages, ecosystem, {
2935
3018
  onProgress: (done, total) => {
@@ -2942,6 +3025,7 @@ async function main() {
2942
3025
  });
2943
3026
  allResults = batchResult.results;
2944
3027
  apiCta = batchResult._cta;
3028
+ batchRescue = batchResult._rescue || null;
2945
3029
  } catch (err) {
2946
3030
  console.error(`\nError: ${err.message}`);
2947
3031
  process.exit(1);
@@ -2964,6 +3048,7 @@ async function main() {
2964
3048
  criticalCount,
2965
3049
  provenanceCount,
2966
3050
  failOn,
3051
+ rateLimited: !!batchRescue,
2967
3052
  results: allResults,
2968
3053
  }, null, 2));
2969
3054
  process.exit(shouldFail(allResults, failOn) ? 1 : 0);
@@ -2975,6 +3060,28 @@ async function main() {
2975
3060
  const criticalTotal = allResults.filter(r => hasCritical(r.riskFlags)).length;
2976
3061
  printTable(displayed, { totalScanned: allResults.length, totalCritical: criticalTotal, lockfile: true });
2977
3062
  if (apiCta) console.log(clr(c.dim + c.cyan, `\n ${apiCta}`));
3063
+ // Rate-limit aggregation (2026-06-16 fix): if ANY batch hit 429, the
3064
+ // aggregated table above ALREADY includes packages_already_scored from
3065
+ // every 429'd batch (auditBatched flattens 200 + 429 results). Surface
3066
+ // ONE rescue CTA here that ALSO short-circuits the normal inlineSignup
3067
+ // path — the rescue CTA itself runs the TTY email→key prompt for free
3068
+ // tier users (overshoot/keyUpgrade branches print upgrade URL and exit).
3069
+ // renderRescueCta() always process.exit(1)s at its tail, so this is the
3070
+ // terminal path for any rate-limited scan.
3071
+ if (batchRescue) {
3072
+ // Override the per-batch "Scored 3 of 20 packages" message with
3073
+ // an aggregate-aware one before rendering. Pre-fix: user sent 326,
3074
+ // saw a "3 of 20" rescue message that referenced one batch's slice.
3075
+ // Post-fix: reflect what the user actually saw in the aggregated table.
3076
+ const isOvershoot = batchRescue.overshoot === true || batchRescue.tier_suggestion === 'developer';
3077
+ const rescueWithAggregate = {
3078
+ ...batchRescue,
3079
+ message: isOvershoot
3080
+ ? `Scored ${allResults.length} of ${packages.length} packages — you're past the free-tier daily limit on this IP. A free key gives 200/day but a ${packages.length}-package lockfile would burn through it; Developer ($15/mo) gives 1,000/day + batch API.`
3081
+ : `Scored ${allResults.length} of ${packages.length} packages — free-tier daily limit reached on this IP (often shared via corporate NAT / CI / dev container). A free API key in 30 seconds lifts the limit to 200/day.`,
3082
+ };
3083
+ await renderRescueCta(rescueWithAggregate); // exits 1
3084
+ }
2978
3085
  await inlineSignup(displayed, { engagementSignal: !!apiCta });
2979
3086
  if (shouldFail(allResults, failOn)) {
2980
3087
  console.error(clr(c.red + c.bold, `\n✗ --fail-on=${failOn} threshold met. Exit 1.`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proof-of-commitment",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "mcpName": "io.github.piiiico/proof-of-commitment",
5
5
  "description": "Supply chain security risk scorer for npm, PyPI, Cargo, and Go packages — behavioral signals that can't be faked",
6
6
  "type": "module",