@westbayberry/dg 2.1.0 → 2.2.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 (138) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +1 -1
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +15 -7
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +23 -9
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +6 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +1 -1
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/types.js +1 -0
  38. package/dist/commands/update.js +17 -8
  39. package/dist/commands/verify.js +8 -5
  40. package/dist/config/settings.js +14 -44
  41. package/dist/decisions/remember-prompt.js +5 -10
  42. package/dist/export-ui/ExportDialog.js +198 -0
  43. package/dist/install-ui/LiveInstall.js +2 -2
  44. package/dist/install-ui/block-render.js +1 -1
  45. package/dist/install-ui/prep-spinner.js +32 -0
  46. package/dist/launcher/agent-check.js +466 -0
  47. package/dist/launcher/agent-hook-exec.js +70 -0
  48. package/dist/launcher/agent-hook-io.js +31 -0
  49. package/dist/launcher/cargo-cache.js +40 -0
  50. package/dist/launcher/classify.js +17 -6
  51. package/dist/launcher/env.js +71 -24
  52. package/dist/launcher/install-preflight.js +51 -14
  53. package/dist/launcher/live-install.js +25 -5
  54. package/dist/launcher/output-redaction.js +7 -4
  55. package/dist/launcher/preflight-prompt.js +14 -1
  56. package/dist/launcher/run.js +89 -86
  57. package/dist/launcher/spawn-invocation.js +21 -0
  58. package/dist/policy/cooldown.js +18 -5
  59. package/dist/policy/evaluate.js +5 -6
  60. package/dist/policy/pypi-name.js +17 -0
  61. package/dist/presentation/mode.js +3 -2
  62. package/dist/presentation/package-page.js +9 -0
  63. package/dist/presentation/theme.js +7 -7
  64. package/dist/project/dgfile.js +145 -6
  65. package/dist/proxy/auth.js +42 -0
  66. package/dist/proxy/ca.js +29 -9
  67. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  68. package/dist/proxy/enforcement.js +55 -16
  69. package/dist/proxy/metadata-map.js +41 -3
  70. package/dist/proxy/preverified.js +55 -0
  71. package/dist/proxy/server.js +448 -49
  72. package/dist/proxy/worker.js +16 -1
  73. package/dist/publish-set/collect.js +1 -4
  74. package/dist/publish-set/npm.js +8 -5
  75. package/dist/publish-set/pack.js +9 -3
  76. package/dist/runtime/cli.js +0 -4
  77. package/dist/runtime/fatal.js +31 -0
  78. package/dist/runtime/first-run.js +12 -11
  79. package/dist/runtime/node-version.js +43 -6
  80. package/dist/runtime/nudges.js +35 -2
  81. package/dist/sbom/cyclonedx.js +211 -0
  82. package/dist/sbom-ui/SbomApp.js +158 -0
  83. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  84. package/dist/sbom-ui/components/SbomList.js +52 -0
  85. package/dist/sbom-ui/inventory.js +128 -0
  86. package/dist/sbom-ui/launch.js +51 -0
  87. package/dist/sbom-ui/run.js +55 -0
  88. package/dist/sbom-ui/store.js +26 -0
  89. package/dist/scan/collect.js +10 -6
  90. package/dist/scan/command.js +17 -10
  91. package/dist/scan/discovery.js +11 -2
  92. package/dist/scan/render.js +28 -4
  93. package/dist/scan/scanner-report.js +15 -9
  94. package/dist/scan/staged.js +2 -1
  95. package/dist/scan-ui/LegacyApp.js +9 -13
  96. package/dist/scan-ui/alt-screen.js +5 -8
  97. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  98. package/dist/scan-ui/components/ProgressBar.js +3 -14
  99. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  100. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  101. package/dist/scan-ui/components/SetupBanner.js +0 -6
  102. package/dist/scan-ui/format-helpers.js +61 -5
  103. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  104. package/dist/scan-ui/hooks/useScan.js +16 -2
  105. package/dist/scan-ui/launch.js +7 -4
  106. package/dist/scan-ui/shims.js +11 -4
  107. package/dist/scripts/detect.js +7 -2
  108. package/dist/scripts/gate.js +1 -1
  109. package/dist/service/state.js +27 -8
  110. package/dist/service/trust-refresh.js +92 -0
  111. package/dist/service/worker.js +23 -1
  112. package/dist/setup/activate-shell.js +28 -0
  113. package/dist/setup/git-hook.js +49 -4
  114. package/dist/setup/plan.js +73 -39
  115. package/dist/setup-ui/gate.js +39 -0
  116. package/dist/setup-ui/offer.js +42 -0
  117. package/dist/setup-ui/selector.js +27 -0
  118. package/dist/setup-ui/tasks.js +56 -0
  119. package/dist/setup-ui/wizard.js +225 -0
  120. package/dist/state/cooldown-held.js +66 -0
  121. package/dist/state/index.js +1 -0
  122. package/dist/state/locks.js +4 -2
  123. package/dist/state/store.js +2 -1
  124. package/dist/util/external-tool.js +25 -0
  125. package/dist/util/git.js +10 -3
  126. package/dist/util/report-writer.js +57 -0
  127. package/dist/verify/local.js +240 -42
  128. package/dist/verify/package-check.js +84 -28
  129. package/dist/verify/preflight.js +242 -49
  130. package/dist/verify/render.js +15 -1
  131. package/npm-shrinkwrap.json +2383 -0
  132. package/package.json +6 -8
  133. package/NOTICE +0 -5
  134. package/dist/commands/completion.js +0 -117
  135. package/dist/commands/explain.js +0 -236
  136. package/dist/commands/unavailable.js +0 -11
  137. package/dist/scripts/rebuild.js +0 -28
  138. package/dist/telemetry/events.js +0 -40
@@ -8,6 +8,10 @@ const MAX_UNPACKED_BYTES = 250 * 1024 * 1024;
8
8
  const MAX_ARCHIVE_ENTRIES = 20000;
9
9
  const MAX_ARCHIVE_PATH_LENGTH = 240;
10
10
  const TAR_BLOCK_SIZE = 512;
11
+ const GNU_LONGNAME_TYPE = 0x4c;
12
+ const GNU_LONGLINK_TYPE = 0x4b;
13
+ const PAX_EXTENDED_TYPE = 0x78;
14
+ const PAX_GLOBAL_TYPE = 0x67;
11
15
  export function verifyLocalTarget(targetPath, cwd = process.cwd()) {
12
16
  const absoluteTarget = resolve(cwd, targetPath);
13
17
  if (!existsSync(absoluteTarget)) {
@@ -115,23 +119,73 @@ function scanTarball(bytes, path) {
115
119
  const entries = [];
116
120
  let offset = 0;
117
121
  let unpackedSizeBytes = 0;
122
+ let pendingLongName = null;
123
+ let pendingPax = null;
124
+ let globalPax = null;
118
125
  while (offset + TAR_BLOCK_SIZE <= tarBytes.length) {
119
126
  const header = tarBytes.subarray(offset, offset + TAR_BLOCK_SIZE);
120
127
  if (header.every((byte) => byte === 0)) {
121
128
  break;
122
129
  }
123
- const name = tarEntryName(header);
130
+ const rawName = tarEntryName(header);
124
131
  const size = tarEntrySize(header);
132
+ if (size === null) {
133
+ errors.push("tar archive has an unparseable entry size");
134
+ break;
135
+ }
125
136
  const bodyOffset = offset + TAR_BLOCK_SIZE;
126
137
  const nextOffset = bodyOffset + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
127
- if (name.length === 0 || nextOffset > tarBytes.length) {
138
+ if (rawName.length === 0 || nextOffset > tarBytes.length) {
128
139
  errors.push("tar archive has a malformed entry header");
129
140
  break;
130
141
  }
142
+ const body = tarBytes.subarray(bodyOffset, bodyOffset + size);
143
+ const typeflag = header.readUInt8(156);
144
+ if (typeflag === GNU_LONGNAME_TYPE) {
145
+ pendingLongName = readNullTerminated(body, 0, body.length);
146
+ if (pendingLongName.length === 0) {
147
+ errors.push("tar archive has an empty long-name header");
148
+ break;
149
+ }
150
+ offset = nextOffset;
151
+ continue;
152
+ }
153
+ if (typeflag === GNU_LONGLINK_TYPE) {
154
+ offset = nextOffset;
155
+ continue;
156
+ }
157
+ if (typeflag === PAX_EXTENDED_TYPE || typeflag === PAX_GLOBAL_TYPE) {
158
+ const records = parsePaxRecords(body);
159
+ if (records === null) {
160
+ errors.push("tar archive has an unparseable PAX extended header");
161
+ break;
162
+ }
163
+ if (typeflag === PAX_EXTENDED_TYPE) {
164
+ pendingPax = mergePaxRecords(pendingPax, records);
165
+ }
166
+ else {
167
+ globalPax = mergePaxRecords(globalPax, records);
168
+ }
169
+ offset = nextOffset;
170
+ continue;
171
+ }
172
+ const paxPath = pendingPax?.get("path") ?? globalPax?.get("path") ?? null;
173
+ if (paxPath !== null && pendingLongName !== null && paxPath !== pendingLongName) {
174
+ errors.push("tar entry name metadata is contradictory");
175
+ break;
176
+ }
177
+ const paxSize = pendingPax?.get("size") ?? null;
178
+ if (paxSize !== null && Number.parseInt(paxSize, 10) !== size) {
179
+ errors.push("tar entry size metadata is contradictory");
180
+ break;
181
+ }
182
+ const name = paxPath ?? pendingLongName ?? rawName;
183
+ pendingLongName = null;
184
+ pendingPax = null;
131
185
  unpackedSizeBytes += size;
132
186
  entries.push({
133
187
  name,
134
- body: tarBytes.subarray(bodyOffset, bodyOffset + size)
188
+ body
135
189
  });
136
190
  findings.push(...pathSafetyFindings(name));
137
191
  if (entries.length > MAX_ARCHIVE_ENTRIES) {
@@ -144,6 +198,9 @@ function scanTarball(bytes, path) {
144
198
  }
145
199
  offset = nextOffset;
146
200
  }
201
+ if (errors.length === 0 && (pendingLongName !== null || pendingPax !== null)) {
202
+ errors.push("tar archive ends with an unapplied extended header");
203
+ }
147
204
  findings.push(...packageManifestFindings(entries));
148
205
  return {
149
206
  findings,
@@ -155,56 +212,41 @@ function scanTarball(bytes, path) {
155
212
  }
156
213
  };
157
214
  }
215
+ const ZIP_LOCAL_HEADER_SIG = 0x04034b50;
216
+ const ZIP_CENTRAL_HEADER_SIG = 0x02014b50;
217
+ const ZIP_EOCD_SIG = 0x06054b50;
218
+ const ZIP_DATA_DESCRIPTOR_SIG = 0x08074b50;
219
+ const ZIP_EOCD_MIN_BYTES = 22;
220
+ const ZIP_EOCD_SEARCH_BYTES = ZIP_EOCD_MIN_BYTES + 0xffff;
158
221
  function scanZipLike(bytes, path) {
222
+ const central = readZipCentralDirectory(bytes);
223
+ if ("error" in central) {
224
+ return archiveError(`${basename(path)}: ${central.error}`);
225
+ }
226
+ const mismatch = zipLocalHeaderMismatch(bytes, central);
227
+ if (mismatch) {
228
+ return archiveError(`${basename(path)}: ${mismatch}`);
229
+ }
159
230
  const findings = [];
160
231
  const errors = [];
161
232
  const entries = [];
162
- let offset = 0;
163
233
  let unpackedSizeBytes = 0;
164
- while (offset + 30 <= bytes.length) {
165
- const signature = bytes.readUInt32LE(offset);
166
- if (signature !== 0x04034b50) {
167
- break;
168
- }
169
- const flags = bytes.readUInt16LE(offset + 6);
170
- const method = bytes.readUInt16LE(offset + 8);
171
- const compressedSize = bytes.readUInt32LE(offset + 18);
172
- const uncompressedSize = bytes.readUInt32LE(offset + 22);
173
- const fileNameLength = bytes.readUInt16LE(offset + 26);
174
- const extraLength = bytes.readUInt16LE(offset + 28);
175
- const nameStart = offset + 30;
176
- const dataStart = nameStart + fileNameLength + extraLength;
177
- const dataEnd = dataStart + compressedSize;
178
- if (dataEnd > bytes.length) {
179
- errors.push(`${basename(path)} has a malformed zip entry`);
180
- break;
181
- }
182
- const name = bytes.subarray(nameStart, nameStart + fileNameLength).toString("utf8");
183
- findings.push(...pathSafetyFindings(name));
184
- if ((flags & 0x1) !== 0) {
234
+ for (const entry of central.entries) {
235
+ findings.push(...pathSafetyFindings(entry.name));
236
+ if ((entry.flags & 0x1) !== 0) {
185
237
  findings.push({
186
238
  id: "encrypted-archive-entry",
187
239
  severity: "block",
188
240
  title: "Encrypted archive entry",
189
241
  message: "encrypted archive entries cannot be inspected locally",
190
- location: name
242
+ location: entry.name
191
243
  });
192
244
  }
193
- const body = readZipBody(bytes.subarray(dataStart, dataEnd), method, name, errors);
194
- unpackedSizeBytes += uncompressedSize;
245
+ unpackedSizeBytes += entry.uncompressedSize;
195
246
  entries.push({
196
- name,
197
- body
247
+ name: entry.name,
248
+ body: readZipEntryBody(bytes, entry, errors)
198
249
  });
199
- if ((flags & 0x8) !== 0) {
200
- findings.push({
201
- id: "zip-data-descriptor",
202
- severity: "warn",
203
- title: "Zip data descriptor",
204
- message: "zip entry uses a data descriptor, so byte-size validation is conservative",
205
- location: name
206
- });
207
- }
208
250
  if (entries.length > MAX_ARCHIVE_ENTRIES) {
209
251
  findings.push(limitFinding("archive has too many entries"));
210
252
  break;
@@ -213,7 +255,6 @@ function scanZipLike(bytes, path) {
213
255
  findings.push(limitFinding("archive expands beyond the local verification limit"));
214
256
  break;
215
257
  }
216
- offset = dataEnd;
217
258
  }
218
259
  findings.push(...packageManifestFindings(entries));
219
260
  return {
@@ -226,6 +267,121 @@ function scanZipLike(bytes, path) {
226
267
  }
227
268
  };
228
269
  }
270
+ function readZipCentralDirectory(bytes) {
271
+ const eocdOffset = findZipEndOfCentralDirectory(bytes);
272
+ if (eocdOffset === null) {
273
+ return { error: "zip end of central directory record not found" };
274
+ }
275
+ const entryCount = bytes.readUInt16LE(eocdOffset + 10);
276
+ const centralDirectorySize = bytes.readUInt32LE(eocdOffset + 12);
277
+ const centralDirectoryOffset = bytes.readUInt32LE(eocdOffset + 16);
278
+ if (entryCount === 0xffff || centralDirectorySize === 0xffffffff || centralDirectoryOffset === 0xffffffff) {
279
+ return { error: "zip64 archives are not supported by local verification" };
280
+ }
281
+ if (centralDirectoryOffset + centralDirectorySize > eocdOffset) {
282
+ return { error: "zip central directory extends past its end record" };
283
+ }
284
+ const entries = [];
285
+ let offset = centralDirectoryOffset;
286
+ for (let index = 0; index < entryCount; index += 1) {
287
+ if (offset + 46 > eocdOffset || bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER_SIG) {
288
+ return { error: "zip central directory is truncated or malformed" };
289
+ }
290
+ const nameLength = bytes.readUInt16LE(offset + 28);
291
+ const extraLength = bytes.readUInt16LE(offset + 30);
292
+ const commentLength = bytes.readUInt16LE(offset + 32);
293
+ if (offset + 46 + nameLength + extraLength + commentLength > eocdOffset) {
294
+ return { error: "zip central directory is truncated or malformed" };
295
+ }
296
+ entries.push({
297
+ name: bytes.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"),
298
+ flags: bytes.readUInt16LE(offset + 8),
299
+ method: bytes.readUInt16LE(offset + 10),
300
+ compressedSize: bytes.readUInt32LE(offset + 20),
301
+ uncompressedSize: bytes.readUInt32LE(offset + 24),
302
+ localHeaderOffset: bytes.readUInt32LE(offset + 42)
303
+ });
304
+ offset += 46 + nameLength + extraLength + commentLength;
305
+ }
306
+ if (offset !== centralDirectoryOffset + centralDirectorySize) {
307
+ return { error: "zip central directory size disagrees with its entries" };
308
+ }
309
+ return { entries, centralDirectoryOffset };
310
+ }
311
+ function findZipEndOfCentralDirectory(bytes) {
312
+ const searchStart = Math.max(0, bytes.length - ZIP_EOCD_SEARCH_BYTES);
313
+ for (let offset = bytes.length - ZIP_EOCD_MIN_BYTES; offset >= searchStart; offset -= 1) {
314
+ if (bytes.readUInt32LE(offset) === ZIP_EOCD_SIG) {
315
+ const commentLength = bytes.readUInt16LE(offset + 20);
316
+ if (offset + ZIP_EOCD_MIN_BYTES + commentLength === bytes.length) {
317
+ return offset;
318
+ }
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+ function zipLocalHeaderMismatch(bytes, central) {
324
+ const { entries, centralDirectoryOffset } = central;
325
+ const claimed = new Map();
326
+ for (const entry of entries) {
327
+ if (claimed.has(entry.localHeaderOffset)) {
328
+ return "zip central directory lists overlapping local entries";
329
+ }
330
+ claimed.set(entry.localHeaderOffset, entry);
331
+ }
332
+ for (const entry of entries) {
333
+ const offset = entry.localHeaderOffset;
334
+ if (offset + 30 > centralDirectoryOffset || bytes.readUInt32LE(offset) !== ZIP_LOCAL_HEADER_SIG) {
335
+ return "zip central directory points at a missing local file header";
336
+ }
337
+ const flags = bytes.readUInt16LE(offset + 6);
338
+ const compressedSize = bytes.readUInt32LE(offset + 18);
339
+ const nameLength = bytes.readUInt16LE(offset + 26);
340
+ const name = bytes.subarray(offset + 30, offset + 30 + nameLength).toString("utf8");
341
+ if (name !== entry.name) {
342
+ return "zip local file header name disagrees with the central directory";
343
+ }
344
+ if ((flags & 0x8) === 0 && compressedSize !== entry.compressedSize) {
345
+ return "zip local file header size disagrees with the central directory";
346
+ }
347
+ }
348
+ let offset = bytes.length >= 4 && bytes.readUInt32LE(0) === ZIP_LOCAL_HEADER_SIG
349
+ ? 0
350
+ : entries.length > 0
351
+ ? Math.min(...entries.map((entry) => entry.localHeaderOffset))
352
+ : centralDirectoryOffset;
353
+ while (offset + 4 <= centralDirectoryOffset && bytes.readUInt32LE(offset) === ZIP_LOCAL_HEADER_SIG) {
354
+ const entry = claimed.get(offset);
355
+ if (!entry) {
356
+ return "zip has a local file entry the central directory does not list";
357
+ }
358
+ const nameLength = bytes.readUInt16LE(offset + 26);
359
+ const extraLength = bytes.readUInt16LE(offset + 28);
360
+ let next = offset + 30 + nameLength + extraLength + entry.compressedSize;
361
+ if ((entry.flags & 0x8) !== 0) {
362
+ next += next + 4 <= centralDirectoryOffset && bytes.readUInt32LE(next) === ZIP_DATA_DESCRIPTOR_SIG ? 16 : 12;
363
+ }
364
+ if (next > centralDirectoryOffset) {
365
+ return "zip local entry data extends into the central directory";
366
+ }
367
+ offset = next;
368
+ }
369
+ return null;
370
+ }
371
+ function readZipEntryBody(bytes, entry, errors) {
372
+ if ((entry.flags & 0x1) !== 0) {
373
+ return Buffer.alloc(0);
374
+ }
375
+ const headerOffset = entry.localHeaderOffset;
376
+ const nameLength = bytes.readUInt16LE(headerOffset + 26);
377
+ const extraLength = bytes.readUInt16LE(headerOffset + 28);
378
+ const dataStart = headerOffset + 30 + nameLength + extraLength;
379
+ if (dataStart + entry.compressedSize > bytes.length) {
380
+ errors.push(`${entry.name}: zip entry data is truncated`);
381
+ return Buffer.alloc(0);
382
+ }
383
+ return readZipBody(bytes.subarray(dataStart, dataStart + entry.compressedSize), entry.method, entry.name, errors);
384
+ }
229
385
  function readZipBody(data, method, name, errors) {
230
386
  if (method === 0) {
231
387
  return data;
@@ -375,9 +531,51 @@ function tarEntryName(header) {
375
531
  return prefix.length > 0 ? `${prefix}/${name}` : name;
376
532
  }
377
533
  function tarEntrySize(header) {
534
+ if ((header.readUInt8(124) & 0x80) !== 0) {
535
+ return null;
536
+ }
378
537
  const value = readNullTerminated(header, 124, 12).trim();
379
- const parsed = Number.parseInt(value || "0", 8);
380
- return Number.isFinite(parsed) ? parsed : 0;
538
+ if (value.length === 0) {
539
+ return 0;
540
+ }
541
+ if (!/^[0-7]+$/u.test(value)) {
542
+ return null;
543
+ }
544
+ return Number.parseInt(value, 8);
545
+ }
546
+ function parsePaxRecords(body) {
547
+ const records = new Map();
548
+ let offset = 0;
549
+ while (offset < body.length) {
550
+ const space = body.indexOf(0x20, offset);
551
+ if (space === -1 || space === offset) {
552
+ return null;
553
+ }
554
+ const lengthText = body.subarray(offset, space).toString("ascii");
555
+ if (!/^\d+$/u.test(lengthText)) {
556
+ return null;
557
+ }
558
+ const length = Number.parseInt(lengthText, 10);
559
+ const recordEnd = offset + length;
560
+ if (length <= space - offset + 1 || recordEnd > body.length || body.readUInt8(recordEnd - 1) !== 0x0a) {
561
+ return null;
562
+ }
563
+ const text = body.subarray(space + 1, recordEnd - 1).toString("utf8");
564
+ const equals = text.indexOf("=");
565
+ if (equals <= 0) {
566
+ return null;
567
+ }
568
+ records.set(text.slice(0, equals), text.slice(equals + 1));
569
+ offset = recordEnd;
570
+ }
571
+ return records;
572
+ }
573
+ function mergePaxRecords(existing, incoming) {
574
+ const merged = new Map(existing ?? []);
575
+ for (const [key, value] of incoming) {
576
+ merged.set(key, value);
577
+ }
578
+ return merged;
381
579
  }
382
580
  function readNullTerminated(buffer, start, length) {
383
581
  const slice = buffer.subarray(start, start + length);
@@ -3,10 +3,11 @@ import { resolve } from "node:path";
3
3
  import { analyzePackages, AnalyzeError } from "../api/analyze.js";
4
4
  import { createTheme } from "../presentation/theme.js";
5
5
  import { provenanceLabel, provenanceDowngradeLine } from "../presentation/provenance.js";
6
+ import { packagePageUrl } from "../presentation/package-page.js";
6
7
  import { resolvePresentation } from "../presentation/mode.js";
7
8
  import { isRemotePackageSpec, isSupportedLockfilePath } from "./preflight.js";
8
9
  import { authStatus } from "../auth/store.js";
9
- import { EXIT_TOOL_ERROR, EXIT_UNAVAILABLE } from "../commands/types.js";
10
+ import { EXIT_TOOL_ERROR, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "../commands/types.js";
10
11
  const REGISTRIES = { npm: "npm", pypi: "pypi" };
11
12
  const DEEP_VERIFY_HINT = "deep verify supports npm and pypi: dg verify npm:<package> or pypi:<package>";
12
13
  function parseSpec(target) {
@@ -38,10 +39,10 @@ function parseSpec(target) {
38
39
  const version = rest.slice(at + separator.length).trim() || null;
39
40
  return { ecosystem, name, version };
40
41
  }
41
- async function resolveLatest(ecosystem, name, fetchImpl) {
42
+ export async function resolveLatest(ecosystem, name, fetchImpl) {
42
43
  try {
43
44
  if (ecosystem === "npm") {
44
- const response = await fetchImpl(`https://registry.npmjs.org/${name.replace("/", "%2F")}`);
45
+ const response = await fetchImpl(`https://registry.npmjs.org/${encodeNpmPackagePath(name)}`);
45
46
  if (!response.ok) {
46
47
  return null;
47
48
  }
@@ -59,24 +60,31 @@ async function resolveLatest(ecosystem, name, fetchImpl) {
59
60
  return null;
60
61
  }
61
62
  }
63
+ function provenanceLines(version, result, theme, withPredicate) {
64
+ if (!result.provenance) {
65
+ return [];
66
+ }
67
+ const label = withPredicate && result.provenance.predicateType
68
+ ? `provenance ${provenanceLabel(result.provenance)} · ${result.provenance.predicateType}`
69
+ : `provenance ${provenanceLabel(result.provenance)}`;
70
+ const lines = [` ${theme.paint("muted", label)}`];
71
+ const downgrade = provenanceDowngradeLine(version, result.provenance);
72
+ if (downgrade) {
73
+ lines.push(` ${theme.paint("warn", `⚠ ${downgrade}`)}`);
74
+ }
75
+ return lines;
76
+ }
77
+ function reasonGlyph(action, theme) {
78
+ return action === "block" ? theme.paint("block", "✘") : action === "warn" ? theme.paint("warn", "⚠") : theme.paint("muted", "·");
79
+ }
62
80
  function renderResult(spec, version, result, theme, verbose) {
63
81
  const action = result.action ?? "pass";
64
82
  const badge = theme.badge(action);
65
83
  const lines = [`${badge} ${result.name}@${version} (${spec.ecosystem}) ${theme.paint("muted", `score ${result.score}`)}`];
66
- if (result.provenance) {
67
- const label = verbose && result.provenance.predicateType
68
- ? `provenance ${provenanceLabel(result.provenance)} · ${result.provenance.predicateType}`
69
- : `provenance ${provenanceLabel(result.provenance)}`;
70
- lines.push(` ${theme.paint("muted", label)}`);
71
- const downgrade = provenanceDowngradeLine(version, result.provenance);
72
- if (downgrade) {
73
- lines.push(` ${theme.paint("warn", `⚠ ${downgrade}`)}`);
74
- }
75
- }
84
+ lines.push(...provenanceLines(version, result, theme, verbose));
76
85
  const reasons = verbose ? result.reasons : result.reasons.slice(0, 6);
77
86
  for (const reason of reasons) {
78
- const glyph = action === "block" ? theme.paint("block", "✘") : action === "warn" ? theme.paint("warn", "⚠") : theme.paint("muted", "·");
79
- lines.push(` ${glyph} ${reason}`);
87
+ lines.push(` ${reasonGlyph(action, theme)} ${reason}`);
80
88
  }
81
89
  if (!verbose && result.reasons.length > reasons.length) {
82
90
  lines.push(` ${theme.paint("muted", `… ${result.reasons.length - reasons.length} more — rerun with --verbose`)}`);
@@ -92,8 +100,43 @@ function renderResult(spec, version, result, theme, verbose) {
92
100
  if (result.recommendation) {
93
101
  lines.push(` ${theme.paint("muted", result.recommendation)}`);
94
102
  }
103
+ const page = packagePageUrl(spec.ecosystem, result.name);
104
+ if (page) {
105
+ lines.push(` ${theme.paint("muted", `→ ${page}`)}`);
106
+ }
107
+ return `${lines.join("\n")}\n`;
108
+ }
109
+ const FREE_REASON_CAP = 3;
110
+ const FREE_SCANS_FOOTER_THRESHOLD = 5_000;
111
+ function renderFreeResult(spec, version, result, theme, freeScansRemaining) {
112
+ const action = result.action ?? "pass";
113
+ const lines = [`${theme.badge(action)} ${result.name}@${version} (${spec.ecosystem})`];
114
+ lines.push(...provenanceLines(version, result, theme, false));
115
+ if (action === "pass") {
116
+ lines.push(` ${theme.paint("muted", result.reasons[0] ?? "no risk signals")}`);
117
+ }
118
+ else {
119
+ const reasons = result.reasons.slice(0, FREE_REASON_CAP);
120
+ for (const reason of reasons) {
121
+ lines.push(` ${reasonGlyph(action, theme)} ${reason}`);
122
+ }
123
+ if (result.reasons.length > reasons.length) {
124
+ lines.push(` ${theme.paint("muted", `… ${result.reasons.length - reasons.length} more — sign in to see all:`)} ${theme.paint("accent", "dg login")}`);
125
+ }
126
+ }
127
+ const page = packagePageUrl(spec.ecosystem, result.name);
128
+ if (page) {
129
+ lines.push(` ${theme.paint("muted", `→ ${page}`)}`);
130
+ }
131
+ lines.push(` ${theme.paint("muted", "full findings, license, JSON output:")} ${theme.paint("accent", "dg login")}`);
132
+ if (freeScansRemaining !== undefined && freeScansRemaining < FREE_SCANS_FOOTER_THRESHOLD) {
133
+ lines.push(` ${theme.paint("muted", `${freeScansRemaining.toLocaleString()} free package checks left this month`)}`);
134
+ }
95
135
  return `${lines.join("\n")}\n`;
96
136
  }
137
+ function encodeNpmPackagePath(name) {
138
+ return name.split("/").map((segment) => encodeURIComponent(segment)).join("%2f");
139
+ }
97
140
  function exitCodeFor(action) {
98
141
  if (action === "block") {
99
142
  return 2;
@@ -109,39 +152,41 @@ function exitCodeFor(action) {
109
152
  export async function runPackageCheck(target, io = {}, options = {}) {
110
153
  const fetchImpl = io.fetchImpl ?? fetch;
111
154
  const theme = createTheme(resolvePresentation().color);
112
- if (!authStatus(io.env).authenticated) {
113
- const accent = (text) => theme.paint("accent", text);
114
- const muted = (text) => theme.paint("muted", text);
155
+ const accent = (text) => theme.paint("accent", text);
156
+ const muted = (text) => theme.paint("muted", text);
157
+ const authenticated = authStatus(io.env).authenticated;
158
+ if (!authenticated && (options.format === "json" || options.outputPath)) {
115
159
  return {
116
160
  exitCode: EXIT_UNAVAILABLE,
117
161
  stdout: "",
118
- stderr: `\n ${theme.paint("warn", "⚠")} Checking a package before you install it requires ${accent("sign-in")}.\n` +
162
+ stderr: `\n ${theme.paint("warn", "⚠")} ${muted("--json and --output for registry checks require sign-in.")}\n` +
119
163
  ` ${accent("dg login")}${muted(" · see plans at")} ${accent("westbayberry.com/pricing")}\n\n`
120
164
  };
121
165
  }
122
166
  const parsed = parseSpec(target);
123
167
  if (parsed === null) {
124
168
  return {
125
- exitCode: 2,
169
+ exitCode: EXIT_USAGE_VERDICT,
126
170
  stdout: "",
127
171
  stderr: `dg verify: add a registry, e.g. dg verify npm:${target} or dg verify pypi:${target}\n`
128
172
  };
129
173
  }
130
174
  if ("error" in parsed) {
131
- return { exitCode: 2, stdout: "", stderr: `dg verify: ${parsed.error}\n` };
175
+ return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: ${parsed.error}\n` };
132
176
  }
133
177
  let version = parsed.version;
134
178
  if (!version) {
135
179
  version = await resolveLatest(parsed.ecosystem, parsed.name, fetchImpl);
136
180
  if (!version) {
137
181
  return {
138
- exitCode: 1,
182
+ exitCode: exitCodeFor("analysis_incomplete"),
139
183
  stdout: "",
140
184
  stderr: `dg verify: could not resolve the latest version of ${parsed.name} on ${parsed.ecosystem}\n`
141
185
  };
142
186
  }
143
187
  }
144
188
  let result;
189
+ let freeScansRemaining;
145
190
  try {
146
191
  const response = await analyzePackages([{ name: parsed.name, version }], {
147
192
  ecosystem: parsed.ecosystem,
@@ -149,13 +194,22 @@ export async function runPackageCheck(target, io = {}, options = {}) {
149
194
  ...(io.env ? { env: io.env } : {})
150
195
  });
151
196
  result = response.packages.find((entry) => entry.name === parsed.name) ?? response.packages[0];
197
+ freeScansRemaining = response.freeScansRemaining;
152
198
  }
153
199
  catch (error) {
200
+ if (error instanceof AnalyzeError && error.code === "quota_exceeded") {
201
+ const loginLine = authenticated ? "" : ` ${muted("Run")} ${accent("dg login")} ${muted("to connect your account.")}\n`;
202
+ return {
203
+ exitCode: exitCodeFor("analysis_incomplete"),
204
+ stdout: "",
205
+ stderr: `dg verify: ${error.message}\n${loginLine} ${muted("see plans at")} ${accent("westbayberry.com/pricing")}\n`
206
+ };
207
+ }
154
208
  const message = error instanceof AnalyzeError ? error.message : error instanceof Error ? error.message : "could not reach the scanner";
155
- return { exitCode: 1, stdout: "", stderr: `dg verify: ${message}\n` };
209
+ return { exitCode: exitCodeFor("analysis_incomplete"), stdout: "", stderr: `dg verify: ${message}\n` };
156
210
  }
157
211
  if (!result) {
158
- return { exitCode: 1, stdout: "", stderr: `dg verify: scanner returned no result for ${parsed.name}\n` };
212
+ return { exitCode: exitCodeFor("analysis_incomplete"), stdout: "", stderr: `dg verify: scanner returned no result for ${parsed.name}\n` };
159
213
  }
160
214
  const action = result.action ?? "pass";
161
215
  const rendered = options.format === "json"
@@ -171,7 +225,9 @@ export async function runPackageCheck(target, io = {}, options = {}) {
171
225
  ...(result.provenance ? { provenance: result.provenance } : {}),
172
226
  ...(result.recommendation ? { recommendation: result.recommendation } : {})
173
227
  }, null, 2)}\n`
174
- : renderResult(parsed, version, result, theme, options.verbose ?? false);
228
+ : authenticated
229
+ ? renderResult(parsed, version, result, theme, options.verbose ?? false)
230
+ : renderFreeResult(parsed, version, result, theme, freeScansRemaining);
175
231
  if (options.outputPath) {
176
232
  try {
177
233
  writeFileSync(resolve(options.outputPath), rendered, "utf8");
@@ -248,20 +304,20 @@ export async function maybeVerifyPackage(args) {
248
304
  if (outputFlagMissingPath) {
249
305
  return {
250
306
  handled: true,
251
- result: { exitCode: 2, stdout: "", stderr: `dg verify: ${outputFlagMissingPath} requires a path. Run 'dg verify --help'.\n` }
307
+ result: { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: ${outputFlagMissingPath} requires a path. Run 'dg verify --help'.\n` }
252
308
  };
253
309
  }
254
310
  if (unknownFlag) {
255
311
  return {
256
312
  handled: true,
257
- result: { exitCode: 2, stdout: "", stderr: `dg verify: unknown option '${unknownFlag}'. Run 'dg verify --help'.\n` }
313
+ result: { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg verify: unknown option '${unknownFlag}'. Run 'dg verify --help'.\n` }
258
314
  };
259
315
  }
260
316
  if (sarif) {
261
317
  return {
262
318
  handled: true,
263
319
  result: {
264
- exitCode: 2,
320
+ exitCode: EXIT_USAGE_VERDICT,
265
321
  stdout: "",
266
322
  stderr: "dg verify: --sarif applies to local artifacts and lockfiles; registry package checks support --json.\n"
267
323
  }