@westbayberry/dg 2.0.7 → 2.0.10

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 (53) hide show
  1. package/README.md +17 -12
  2. package/dist/api/analyze.js +134 -34
  3. package/dist/audit-ui/export.js +3 -4
  4. package/dist/auth/device-login.js +13 -9
  5. package/dist/auth/store.js +43 -26
  6. package/dist/bin/dg.js +5 -0
  7. package/dist/commands/audit.js +14 -4
  8. package/dist/commands/config.js +3 -5
  9. package/dist/commands/doctor.js +3 -3
  10. package/dist/commands/explain.js +138 -6
  11. package/dist/commands/licenses.js +37 -24
  12. package/dist/commands/login.js +12 -3
  13. package/dist/commands/logout.js +15 -4
  14. package/dist/commands/scan.js +1 -1
  15. package/dist/commands/service.js +76 -24
  16. package/dist/commands/status.js +38 -4
  17. package/dist/commands/types.js +1 -0
  18. package/dist/config/settings.js +102 -22
  19. package/dist/install-ui/prompt.js +5 -2
  20. package/dist/launcher/install-preflight.js +158 -0
  21. package/dist/launcher/live-install.js +11 -2
  22. package/dist/launcher/output-redaction.js +5 -3
  23. package/dist/launcher/pip-report.js +18 -2
  24. package/dist/launcher/preflight-prompt.js +31 -12
  25. package/dist/launcher/run.js +87 -8
  26. package/dist/proxy/ca.js +69 -29
  27. package/dist/proxy/enforcement.js +41 -3
  28. package/dist/proxy/worker.js +21 -9
  29. package/dist/runtime/first-run.js +33 -2
  30. package/dist/runtime/nudges.js +9 -2
  31. package/dist/scan/analyze-worker.js +18 -8
  32. package/dist/scan/collect.js +35 -28
  33. package/dist/scan/command.js +80 -40
  34. package/dist/scan/discovery.js +9 -3
  35. package/dist/scan/render.js +22 -6
  36. package/dist/scan/scanner-report.js +89 -12
  37. package/dist/scan/staged.js +69 -7
  38. package/dist/scan-ui/LegacyApp.js +10 -48
  39. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  40. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  41. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  42. package/dist/scan-ui/hooks/useScan.js +74 -27
  43. package/dist/scan-ui/launch.js +18 -4
  44. package/dist/service/state.js +15 -4
  45. package/dist/service/trust-store.js +23 -2
  46. package/dist/setup/git-hook.js +28 -17
  47. package/dist/setup/plan.js +302 -18
  48. package/dist/state/cleanup-registry.js +65 -8
  49. package/dist/state/locks.js +95 -9
  50. package/dist/state/sessions.js +66 -2
  51. package/dist/verify/package-check.js +22 -3
  52. package/dist/verify/preflight.js +328 -170
  53. package/package.json +1 -1
@@ -12,10 +12,57 @@ import { readProxySessionState } from "../proxy/server.js";
12
12
  import { cleanupSessionSync, createSessionSync, resolveDgPaths } from "../state/index.js";
13
13
  import { classifyPackageManagerInvocation } from "./classify.js";
14
14
  import { buildProxyChildEnv } from "./env.js";
15
+ import { cachedPipResolution } from "./install-preflight.js";
15
16
  import { parsePipReportInstallCount } from "./pip-report.js";
16
17
  import { createStreamRedactor, redactSecrets } from "./output-redaction.js";
17
18
  import { resolveRealBinary } from "./resolve-real-binary.js";
18
19
  export const EXIT_INSTALL_BLOCKED = 2;
20
+ const CMD_SCRIPT_PATTERN = /\.(cmd|bat)$/i;
21
+ const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
22
+ export function resolveSpawnInvocation(binary, args, platform = process.platform) {
23
+ if (platform !== "win32" || !CMD_SCRIPT_PATTERN.test(binary)) {
24
+ return { command: binary, args, windowsVerbatimArguments: false };
25
+ }
26
+ const commandLine = [escapeCmdCommand(binary), ...args.map(escapeCmdArgument)].join(" ");
27
+ return {
28
+ command: process.env.comspec ?? "cmd.exe",
29
+ args: ["/d", "/s", "/c", `"${commandLine}"`],
30
+ windowsVerbatimArguments: true
31
+ };
32
+ }
33
+ function escapeCmdCommand(command) {
34
+ return command.replace(CMD_META_CHARS, "^$1");
35
+ }
36
+ // cmd shims parse their command line twice, hence the doubled meta-char escape (cross-spawn's algorithm)
37
+ function escapeCmdArgument(argument) {
38
+ const quoted = `"${argument.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`;
39
+ return quoted.replace(CMD_META_CHARS, "^$1").replace(CMD_META_CHARS, "^$1");
40
+ }
41
+ export function shimDepth(env) {
42
+ const parsed = Number.parseInt(env.DG_SHIM_DEPTH ?? "", 10);
43
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
44
+ }
45
+ export function rootUnprotectedNotice(env, uid = process.getuid?.()) {
46
+ if (uid !== 0) {
47
+ return "";
48
+ }
49
+ if (existsSync(resolveDgPaths(env).stateDir)) {
50
+ return "";
51
+ }
52
+ return "dg: running as root without dg state — bare package-manager installs by root are not protected\n";
53
+ }
54
+ let rootNoticeWritten = false;
55
+ function maybeWarnRootWithoutState(env) {
56
+ if (rootNoticeWritten) {
57
+ return;
58
+ }
59
+ const notice = rootUnprotectedNotice(env);
60
+ if (!notice) {
61
+ return;
62
+ }
63
+ rootNoticeWritten = true;
64
+ process.stderr.write(notice);
65
+ }
19
66
  export function createLaunchPlan(manager, args, env = process.env) {
20
67
  const classification = classifyPackageManagerInvocation(manager, args);
21
68
  const realBinary = resolveRealBinary({
@@ -28,7 +75,8 @@ export function createLaunchPlan(manager, args, env = process.env) {
28
75
  startsProxy: classification.kind === "protected",
29
76
  childEnv: {
30
77
  ...env,
31
- DG_SHIM_ACTIVE: shimNonce(manager, env)
78
+ DG_SHIM_ACTIVE: shimNonce(manager, env),
79
+ DG_SHIM_DEPTH: String(shimDepth(env) + 1)
32
80
  }
33
81
  };
34
82
  }
@@ -41,6 +89,23 @@ export async function runPackageManager(manager, args, options = {}) {
41
89
  if (!plan.realBinary.path) {
42
90
  return unavailable(invoked, `real ${plan.classification.realBinaryName} binary was not found outside dg shims`);
43
91
  }
92
+ maybeWarnRootWithoutState(options.env ?? process.env);
93
+ const depth = shimDepth(options.env ?? process.env);
94
+ if (depth >= 2) {
95
+ return {
96
+ exitCode: EXIT_UNAVAILABLE,
97
+ stdout: "",
98
+ stderr: `dg: ${manager} shim exec loop detected (DG_SHIM_DEPTH=${depth}) — refusing to re-enter\n`
99
+ };
100
+ }
101
+ if (depth === 1 && plan.startsProxy) {
102
+ const child = await spawnPackageManager(plan, args, options);
103
+ return {
104
+ exitCode: child.exitCode,
105
+ stdout: streamedOut(child.stdout, options),
106
+ stderr: `dg: re-entered through its own shim — running the real ${plan.classification.realBinaryName} directly\n${streamedErr(child.stderr, options)}`
107
+ };
108
+ }
44
109
  if (plan.startsProxy) {
45
110
  if (!options.proxyVerdict) {
46
111
  return runWithProductionProxy(plan, args, options);
@@ -173,9 +238,11 @@ function resolvePipInstallTotal(binary, args, env, register) {
173
238
  };
174
239
  let child;
175
240
  try {
176
- child = spawn(binary, [...args, "--dry-run", "--report", "-", "--quiet"], {
241
+ const invocation = resolveSpawnInvocation(binary, [...args, "--dry-run", "--report", "-", "--quiet"]);
242
+ child = spawn(invocation.command, [...invocation.args], {
177
243
  env,
178
- stdio: ["ignore", "pipe", "ignore"]
244
+ stdio: ["ignore", "pipe", "ignore"],
245
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
179
246
  });
180
247
  }
181
248
  catch {
@@ -200,6 +267,7 @@ export async function runWithProductionProxyLive(plan, args, options, onView) {
200
267
  throw new Error("live install mode renders its own UI and owns the terminal; streaming output callbacks are not supported");
201
268
  }
202
269
  const env = options.env ?? process.env;
270
+ maybeWarnRootWithoutState(env);
203
271
  const proxy = await startProxyWorker(plan.classification, env, options.forceOverride);
204
272
  if ("decision" in proxy) {
205
273
  return { exitCode: EXIT_INSTALL_BLOCKED, stdout: "", stderr: redactSecrets(renderInstallDecision(proxy.decision)) };
@@ -217,9 +285,15 @@ export async function runWithProductionProxyLive(plan, args, options, onView) {
217
285
  let resolvedTotal;
218
286
  let dryRunChild;
219
287
  if (plan.classification.manager === "pip") {
220
- void resolvePipInstallTotal(plan.realBinary.path ?? "", args, childEnv, (child) => { dryRunChild = child; })
221
- .then((count) => { resolvedTotal = count; })
222
- .catch(() => undefined);
288
+ const cached = cachedPipResolution(plan.realBinary.path ?? "", args);
289
+ if (cached) {
290
+ resolvedTotal = cached.count;
291
+ }
292
+ else {
293
+ void resolvePipInstallTotal(plan.realBinary.path ?? "", args, childEnv, (child) => { dryRunChild = child; })
294
+ .then((count) => { resolvedTotal = count; })
295
+ .catch(() => undefined);
296
+ }
223
297
  }
224
298
  const poll = setInterval(() => {
225
299
  onView(deriveLiveView(readProxySessionState(proxy.session), "scanning", resolvedTotal));
@@ -292,9 +366,11 @@ async function spawnPackageManager(plan, args, options) {
292
366
  });
293
367
  }
294
368
  const defaultSpawner = (request) => new Promise((resolve) => {
295
- const child = spawn(request.binary, [...request.args], {
369
+ const invocation = resolveSpawnInvocation(request.binary, request.args);
370
+ const child = spawn(invocation.command, [...invocation.args], {
296
371
  env: request.env,
297
- stdio: ["inherit", "pipe", "pipe"]
372
+ stdio: ["inherit", "pipe", "pipe"],
373
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
298
374
  });
299
375
  const stdout = [];
300
376
  const stderr = [];
@@ -421,6 +497,9 @@ function stopProxyWorker(proxy) {
421
497
  function installProxySignalHandlers(proxy) {
422
498
  const registrations = ["SIGINT", "SIGTERM"].map((signal) => {
423
499
  const handler = () => {
500
+ if (process.stdin.isTTY) {
501
+ process.stdin.setRawMode(false);
502
+ }
424
503
  stopProxyWorker(proxy);
425
504
  process.exit(signal === "SIGINT" ? 130 : 143);
426
505
  };
package/dist/proxy/ca.js CHANGED
@@ -1,9 +1,43 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { isIP } from "node:net";
5
5
  import forge from "node-forge";
6
- export function createEphemeralCertificateAuthority(caPath) {
6
+ export const CA_LIFETIME_MS = 24 * 60 * 60 * 1_000;
7
+ export const CA_ROTATE_AFTER_FRACTION = 0.75;
8
+ const ROTATION_CHECK_INTERVAL_MS = 60_000;
9
+ export function createEphemeralCertificateAuthority(caPath, options = {}) {
10
+ const lifetimeMs = options.lifetimeMs ?? CA_LIFETIME_MS;
11
+ const now = options.now ?? Date.now;
12
+ let active = issueAuthority(caPath, lifetimeMs, now(), undefined);
13
+ const leafs = new Map();
14
+ const rotateIfDue = () => {
15
+ if (now() < active.rotateAtMs) {
16
+ return;
17
+ }
18
+ active = issueAuthority(caPath, lifetimeMs, now(), active.certPem);
19
+ leafs.clear();
20
+ };
21
+ setInterval(rotateIfDue, ROTATION_CHECK_INTERVAL_MS).unref();
22
+ return {
23
+ get caCertPem() {
24
+ return active.certPem;
25
+ },
26
+ caPath,
27
+ leafForHost(host) {
28
+ rotateIfDue();
29
+ const normalized = normalizeHost(host);
30
+ const existing = leafs.get(normalized);
31
+ if (existing) {
32
+ return existing;
33
+ }
34
+ const leaf = createLeafCertificate(normalized, active, lifetimeMs, now());
35
+ leafs.set(normalized, leaf);
36
+ return leaf;
37
+ }
38
+ };
39
+ }
40
+ function issueAuthority(caPath, lifetimeMs, nowMs, previousCertPem) {
7
41
  const keys = forge.pki.rsa.generateKeyPair({
8
42
  bits: 2048,
9
43
  workers: -1
@@ -11,8 +45,8 @@ export function createEphemeralCertificateAuthority(caPath) {
11
45
  const cert = forge.pki.createCertificate();
12
46
  cert.publicKey = keys.publicKey;
13
47
  cert.serialNumber = serialNumber();
14
- cert.validity.notBefore = new Date(Date.now() - 60_000);
15
- cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1_000);
48
+ cert.validity.notBefore = new Date(nowMs - 60_000);
49
+ cert.validity.notAfter = new Date(nowMs + lifetimeMs);
16
50
  const attrs = [{
17
51
  name: "commonName",
18
52
  value: "Dependency Guardian per-session proxy CA"
@@ -36,32 +70,38 @@ export function createEphemeralCertificateAuthority(caPath) {
36
70
  }
37
71
  ]);
38
72
  cert.sign(keys.privateKey, forge.md.sha256.create());
39
- const caCertPem = forge.pki.certificateToPem(cert);
73
+ const certPem = forge.pki.certificateToPem(cert);
74
+ writeCaBundleAtomic(caPath, previousCertPem ? `${certPem}${previousCertPem}` : certPem);
75
+ return {
76
+ cert,
77
+ privateKey: keys.privateKey,
78
+ certPem,
79
+ notAfterMs: nowMs + lifetimeMs,
80
+ rotateAtMs: nowMs + Math.floor(lifetimeMs * CA_ROTATE_AFTER_FRACTION)
81
+ };
82
+ }
83
+ function writeCaBundleAtomic(caPath, bundle) {
40
84
  mkdirSync(dirname(caPath), {
41
85
  recursive: true,
42
86
  mode: 0o700
43
87
  });
44
- writeFileSync(caPath, caCertPem, {
45
- encoding: "utf8",
46
- mode: 0o600
47
- });
48
- const leafs = new Map();
49
- return {
50
- caCertPem,
51
- caPath,
52
- leafForHost(host) {
53
- const normalized = normalizeHost(host);
54
- const existing = leafs.get(normalized);
55
- if (existing) {
56
- return existing;
57
- }
58
- const leaf = createLeafCertificate(normalized, cert, keys.privateKey);
59
- leafs.set(normalized, leaf);
60
- return leaf;
61
- }
62
- };
88
+ const tempPath = `${caPath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
89
+ try {
90
+ writeFileSync(tempPath, bundle, {
91
+ encoding: "utf8",
92
+ flag: "wx",
93
+ mode: 0o600
94
+ });
95
+ renameSync(tempPath, caPath);
96
+ }
97
+ catch (error) {
98
+ rmSync(tempPath, {
99
+ force: true
100
+ });
101
+ throw error;
102
+ }
63
103
  }
64
- function createLeafCertificate(host, issuerCert, issuerKey) {
104
+ function createLeafCertificate(host, issuer, lifetimeMs, nowMs) {
65
105
  const keys = forge.pki.rsa.generateKeyPair({
66
106
  bits: 2048,
67
107
  workers: -1
@@ -69,13 +109,13 @@ function createLeafCertificate(host, issuerCert, issuerKey) {
69
109
  const cert = forge.pki.createCertificate();
70
110
  cert.publicKey = keys.publicKey;
71
111
  cert.serialNumber = serialNumber();
72
- cert.validity.notBefore = new Date(Date.now() - 60_000);
73
- cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1_000);
112
+ cert.validity.notBefore = new Date(nowMs - 60_000);
113
+ cert.validity.notAfter = new Date(Math.min(nowMs + lifetimeMs, issuer.notAfterMs));
74
114
  cert.setSubject([{
75
115
  name: "commonName",
76
116
  value: host
77
117
  }]);
78
- cert.setIssuer(issuerCert.subject.attributes);
118
+ cert.setIssuer(issuer.cert.subject.attributes);
79
119
  cert.setExtensions([
80
120
  {
81
121
  name: "basicConstraints",
@@ -97,7 +137,7 @@ function createLeafCertificate(host, issuerCert, issuerKey) {
97
137
  altNames: subjectAlternativeNames(host)
98
138
  }
99
139
  ]);
100
- cert.sign(issuerKey, forge.md.sha256.create());
140
+ cert.sign(issuer.privateKey, forge.md.sha256.create());
101
141
  return {
102
142
  certPem: forge.pki.certificateToPem(cert),
103
143
  keyPem: forge.pki.privateKeyToPem(keys.privateKey)
@@ -83,9 +83,47 @@ function failClosedVerdict(classification) {
83
83
  reason: "per-invocation proxy enforcement is not available, so protected installs fail closed"
84
84
  };
85
85
  }
86
- function derivePackageName(classification) {
87
- const spec = classification.args.find((arg) => !arg.startsWith("-") && arg !== classification.action);
88
- return spec ?? `${classification.manager}:${classification.action || "install"}`;
86
+ const VALUE_CONSUMING_FLAGS = {
87
+ javascript: new Set([
88
+ "--registry", "--userconfig", "--globalconfig", "--cache", "--prefix", "--loglevel",
89
+ "--tag", "--workspace", "-w", "--filter", "-C", "--dir", "--cwd", "--modules-folder",
90
+ "--otp", "--script-shell", "--network-concurrency", "--mutex"
91
+ ]),
92
+ python: new Set([
93
+ "--index-url", "-i", "--extra-index-url", "--find-links", "-f", "--trusted-host",
94
+ "--proxy", "--requirement", "-r", "--constraint", "-c", "--target", "-t", "--prefix",
95
+ "--root", "--src", "--platform", "--python", "--python-version", "--implementation",
96
+ "--abi", "--cache-dir", "--timeout", "--retries", "--cert", "--client-cert", "--log",
97
+ "--progress-bar", "--upgrade-strategy", "--no-binary", "--only-binary", "--report",
98
+ "--editable", "-e", "--index", "--default-index"
99
+ ]),
100
+ rust: new Set([
101
+ "--registry", "--index", "--git", "--branch", "--tag", "--rev", "--path", "--vers",
102
+ "--version", "--features", "-F", "--package", "-p", "--manifest-path", "--target-dir",
103
+ "--profile", "--jobs", "-j", "--target", "--config", "-Z"
104
+ ]),
105
+ gated: new Set()
106
+ };
107
+ export function derivePackageName(classification) {
108
+ const valueFlags = VALUE_CONSUMING_FLAGS[classification.ecosystem];
109
+ const args = classification.args;
110
+ for (let index = 0; index < args.length; index += 1) {
111
+ const arg = args[index];
112
+ if (!arg) {
113
+ continue;
114
+ }
115
+ if (arg.startsWith("-")) {
116
+ if (valueFlags.has(arg)) {
117
+ index += 1;
118
+ }
119
+ continue;
120
+ }
121
+ if (arg === classification.action) {
122
+ continue;
123
+ }
124
+ return arg;
125
+ }
126
+ return `${classification.manager}:${classification.action || "install"}`;
89
127
  }
90
128
  function causeFromVerdict(verdict, action) {
91
129
  if (verdict === "pass" && action === "pass") {
@@ -1,6 +1,8 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { startProductionHttpProxy } from "./server.js";
3
3
  import { parseForceOverrideRequest } from "./enforcement.js";
4
+ import { cleanupSessionSync } from "../state/index.js";
5
+ const PARENT_POLL_MS = 500;
4
6
  const sessionPath = process.argv[2];
5
7
  const apiBaseUrl = process.argv[3];
6
8
  const classificationJson = process.env.DG_PROXY_CLASSIFICATION;
@@ -11,6 +13,10 @@ if (!sessionPath || !apiBaseUrl || !classificationJson) {
11
13
  const session = JSON.parse(readFileSync(sessionPath, "utf8"));
12
14
  const classification = JSON.parse(classificationJson);
13
15
  const forceOverride = parseForceOverrideRequest(process.env.DG_FORCE_OVERRIDE_REQUEST);
16
+ writeFileSync(session.files.pid, `${process.pid}\n`, {
17
+ encoding: "utf8",
18
+ mode: 0o600
19
+ });
14
20
  let handle = null;
15
21
  let closed = false;
16
22
  async function close() {
@@ -19,16 +25,22 @@ async function close() {
19
25
  }
20
26
  closed = true;
21
27
  await handle?.close();
28
+ cleanupSessionSync(session);
22
29
  }
23
- process.stdin.on("end", () => {
24
- close().finally(() => process.exit(0));
25
- });
26
- process.on("SIGTERM", () => {
27
- close().finally(() => process.exit(0));
28
- });
29
- process.on("SIGINT", () => {
30
+ function shutdown() {
30
31
  close().finally(() => process.exit(0));
31
- });
32
+ }
33
+ process.stdin.resume();
34
+ process.stdin.on("end", shutdown);
35
+ process.stdin.on("error", shutdown);
36
+ process.on("SIGTERM", shutdown);
37
+ process.on("SIGINT", shutdown);
38
+ const parentWatch = setInterval(() => {
39
+ if (process.ppid === 1) {
40
+ shutdown();
41
+ }
42
+ }, PARENT_POLL_MS);
43
+ parentWatch.unref();
32
44
  handle = await startProductionHttpProxy({
33
45
  session,
34
46
  apiBaseUrl,
@@ -1,7 +1,9 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { dgVersion } from "../commands/version.js";
3
4
  import { isCiEnv } from "../presentation/mode.js";
4
5
  import { createTheme } from "../presentation/theme.js";
6
+ import { sweepLegacyPythonHooks } from "../setup/plan.js";
5
7
  import { resolveDgPaths } from "../state/index.js";
6
8
  const SKIP_COMMANDS = new Set([
7
9
  "help",
@@ -18,17 +20,46 @@ const SKIP_COMMANDS = new Set([
18
20
  "upgrade",
19
21
  "uninstall"
20
22
  ]);
23
+ const MACHINE_OUTPUT_FLAGS = new Set([
24
+ "--json",
25
+ "--sarif",
26
+ "--csv",
27
+ "--markdown",
28
+ "--output",
29
+ "-o",
30
+ "--quiet"
31
+ ]);
21
32
  export function firstRunMarkerPath(env = process.env) {
22
33
  return join(resolveDgPaths(env).stateDir, "first-run-shown");
23
34
  }
35
+ export function lastRunVersionMarkerPath(env = process.env) {
36
+ return join(resolveDgPaths(env).stateDir, "last-run-version");
37
+ }
38
+ export function sweepLegacyHooksOnVersionChange(env = process.env, version = dgVersion()) {
39
+ try {
40
+ const marker = lastRunVersionMarkerPath(env);
41
+ const recorded = existsSync(marker) ? readFileSync(marker, "utf8").trim() : undefined;
42
+ if (recorded === version) {
43
+ return false;
44
+ }
45
+ sweepLegacyPythonHooks(resolveDgPaths(env).homeDir, [], []);
46
+ mkdirSync(dirname(marker), { recursive: true, mode: 0o700 });
47
+ writeFileSync(marker, `${version}\n`, { encoding: "utf8", mode: 0o600 });
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
24
54
  export function maybeShowFirstRun(args, options = {}) {
25
55
  const env = options.env ?? process.env;
26
56
  const stderr = options.stderr ?? process.stderr;
27
57
  const command = args[0] ?? "";
58
+ sweepLegacyHooksOnVersionChange(env);
28
59
  if (!stderr.isTTY || isCiEnv(env)) {
29
60
  return false;
30
61
  }
31
- if (SKIP_COMMANDS.has(command) || args.some((arg) => arg === "--json" || arg === "--sarif" || arg === "--quiet")) {
62
+ if (SKIP_COMMANDS.has(command) || args.some((arg) => MACHINE_OUTPUT_FLAGS.has(arg))) {
32
63
  return false;
33
64
  }
34
65
  const marker = firstRunMarkerPath(env);
@@ -46,11 +46,11 @@ export function maybeShowNudges(args, options = {}) {
46
46
  const lines = [];
47
47
  let stateChanged = false;
48
48
  if (due(state.updateCheckedAt, UPDATE_THROTTLE_MS, now)) {
49
- state.updateCheckedAt = now.toISOString();
50
- stateChanged = true;
51
49
  const latest = readLatestVersion(LATEST_LOOKUP_TIMEOUT_MS);
52
50
  if (latest) {
51
+ state.updateCheckedAt = now.toISOString();
53
52
  state.updateLatest = latest;
53
+ stateChanged = true;
54
54
  if (compareVersions(latest, dgVersion()) > 0) {
55
55
  lines.push(`${theme.paint("warn", "⚠")} ${theme.paint("muted", `Update available: ${dgVersion()} → ${latest}. Run`)} ${theme.paint("accent", "dg update")}${theme.paint("muted", ".")}`);
56
56
  }
@@ -68,6 +68,13 @@ export function maybeShowNudges(args, options = {}) {
68
68
  writeState(statePath, state);
69
69
  }
70
70
  }
71
+ export function pendingUpdate(env = process.env) {
72
+ const latest = readState(nudgeStatePath(env)).updateLatest;
73
+ if (!latest || compareVersions(latest, dgVersion()) <= 0) {
74
+ return null;
75
+ }
76
+ return { current: dgVersion(), latest };
77
+ }
71
78
  function isAuthenticated(env) {
72
79
  try {
73
80
  return authStatus(env).authenticated;
@@ -1,21 +1,31 @@
1
- import { analyzePackages, mergeAnalyzeResponses } from "../api/analyze.js";
1
+ import { analyzePackages, mergeAnalyzeResponses, scannerErrorFromUnknown } from "../api/analyze.js";
2
+ async function readStdin() {
3
+ process.stdin.setEncoding("utf8");
4
+ let raw = "";
5
+ for await (const chunk of process.stdin) {
6
+ raw += chunk;
7
+ }
8
+ return raw;
9
+ }
2
10
  async function main() {
3
- const raw = process.argv[2];
11
+ const raw = (await readStdin()).trim();
4
12
  if (!raw) {
5
- process.stderr.write("analyze-worker: missing input payload\n");
6
- process.exit(2);
13
+ throw new Error("analyze-worker: missing input payload");
7
14
  }
8
- const groups = JSON.parse(raw);
15
+ const payload = JSON.parse(raw);
9
16
  const responses = [];
10
- for (const group of groups) {
17
+ for (const group of payload.groups) {
11
18
  if (group.packages.length === 0) {
12
19
  continue;
13
20
  }
14
- responses.push(await analyzePackages(group.packages, { ecosystem: group.ecosystem }));
21
+ responses.push(await analyzePackages(group.packages, {
22
+ ecosystem: group.ecosystem,
23
+ ...(payload.scanId ? { scanId: payload.scanId } : {})
24
+ }));
15
25
  }
16
26
  process.stdout.write(JSON.stringify(mergeAnalyzeResponses(responses)));
17
27
  }
18
28
  main().catch((error) => {
19
- process.stderr.write(`analyze-worker: ${error instanceof Error ? error.message : "unknown error"}\n`);
29
+ process.stdout.write(JSON.stringify({ scannerError: scannerErrorFromUnknown(error) }));
20
30
  process.exit(1);
21
31
  });
@@ -1,6 +1,6 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
- import { verifyLockfile } from "../verify/preflight.js";
3
+ import { parseLockfilePackages } from "../verify/preflight.js";
4
4
  export const LOCKFILE_ECOSYSTEMS = {
5
5
  "package-lock.json": "npm",
6
6
  "npm-shrinkwrap.json": "npm",
@@ -8,6 +8,7 @@ export const LOCKFILE_ECOSYSTEMS = {
8
8
  "pnpm-lock.yaml": "npm",
9
9
  "Pipfile.lock": "pypi",
10
10
  "poetry.lock": "pypi",
11
+ "uv.lock": "pypi",
11
12
  "requirements.txt": "pypi"
12
13
  };
13
14
  export function isLockfileName(name) {
@@ -34,13 +35,19 @@ function readDirents(directory) {
34
35
  return [];
35
36
  }
36
37
  }
37
- function firstLockfile(entries) {
38
+ function lockfilesPerEcosystem(entries) {
39
+ const matches = [];
40
+ const claimed = new Set();
38
41
  for (const [lockfile, ecosystem] of Object.entries(LOCKFILE_ECOSYSTEMS)) {
42
+ if (claimed.has(ecosystem)) {
43
+ continue;
44
+ }
39
45
  if (entries.some((entry) => entry.name === lockfile && entry.isFile())) {
40
- return [lockfile, ecosystem];
46
+ matches.push([lockfile, ecosystem]);
47
+ claimed.add(ecosystem);
41
48
  }
42
49
  }
43
- return null;
50
+ return matches;
44
51
  }
45
52
  function shouldDescend(entry) {
46
53
  return entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name) && !entry.name.startsWith(".");
@@ -54,14 +61,13 @@ export function discoverScanProjects(root) {
54
61
  return;
55
62
  }
56
63
  const entries = readDirents(directory);
57
- const match = firstLockfile(entries);
58
- if (match) {
64
+ for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
59
65
  projects.push({
60
66
  path: directory,
61
67
  relativePath: relative(root, directory) || ".",
62
- ecosystem: match[1],
63
- depFile: match[0],
64
- packageCount: countLockfilePackages(join(directory, match[0]))
68
+ ecosystem,
69
+ depFile,
70
+ packageCount: countLockfilePackages(join(directory, depFile))
65
71
  });
66
72
  }
67
73
  for (const entry of entries) {
@@ -85,17 +91,16 @@ export async function discoverScanProjectsAsync(root, onProgress) {
85
91
  lastYield = Date.now();
86
92
  }
87
93
  const entries = readDirents(directory);
88
- const match = firstLockfile(entries);
89
- if (match) {
94
+ for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
90
95
  const relativePath = relative(root, directory) || ".";
91
96
  projects.push({
92
97
  path: directory,
93
98
  relativePath,
94
- ecosystem: match[1],
95
- depFile: match[0],
96
- packageCount: countLockfilePackages(join(directory, match[0]))
99
+ ecosystem,
100
+ depFile,
101
+ packageCount: countLockfilePackages(join(directory, depFile))
97
102
  });
98
- onProgress?.({ path: relativePath === "." ? match[0] : `${relativePath}/${match[0]}`, found: projects.length });
103
+ onProgress?.({ path: relativePath === "." ? depFile : `${relativePath}/${depFile}`, found: projects.length });
99
104
  }
100
105
  for (const entry of entries) {
101
106
  if (shouldDescend(entry)) {
@@ -110,27 +115,29 @@ function yieldToEventLoop() {
110
115
  });
111
116
  }
112
117
  function countLockfilePackages(lockfilePath) {
113
- try {
114
- return verifyLockfile(lockfilePath).packages.length;
115
- }
116
- catch {
117
- return 0;
118
- }
118
+ return parseLockfilePackages(lockfilePath).packages.length;
119
119
  }
120
120
  export function collectScanPackages(projects) {
121
121
  const byEcosystem = new Map();
122
122
  const seen = new Set();
123
+ const skippedPackages = [];
124
+ const parseErrors = [];
123
125
  let skipped = 0;
124
126
  for (const project of projects) {
125
- let identities;
126
- try {
127
- identities = verifyLockfile(join(project.path, project.depFile)).packages;
127
+ const depFilePath = project.relativePath === "." ? project.depFile : `${project.relativePath}/${project.depFile}`;
128
+ const parsed = parseLockfilePackages(join(project.path, project.depFile));
129
+ for (const skippedPackage of parsed.skipped) {
130
+ skippedPackages.push({ ...skippedPackage, location: `${depFilePath}: ${skippedPackage.location}` });
131
+ skipped += 1;
128
132
  }
129
- catch {
133
+ if (parsed.parseError) {
134
+ parseErrors.push({
135
+ file: parsed.parseError.file === project.depFile ? depFilePath : parsed.parseError.file,
136
+ reason: parsed.parseError.reason
137
+ });
130
138
  skipped += 1;
131
- continue;
132
139
  }
133
- for (const identity of identities) {
140
+ for (const identity of parsed.packages) {
134
141
  if (identity.ecosystem !== "npm" && identity.ecosystem !== "pypi") {
135
142
  skipped += 1;
136
143
  continue;
@@ -149,5 +156,5 @@ export function collectScanPackages(projects) {
149
156
  byEcosystem.set(identity.ecosystem, list);
150
157
  }
151
158
  }
152
- return { byEcosystem, skipped };
159
+ return { byEcosystem, skipped, skippedPackages, parseErrors };
153
160
  }