@westbayberry/dg 2.0.8 → 2.0.11

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 (50) 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/launcher/install-preflight.js +81 -12
  20. package/dist/launcher/output-redaction.js +5 -3
  21. package/dist/launcher/preflight-prompt.js +31 -12
  22. package/dist/launcher/run.js +87 -8
  23. package/dist/proxy/ca.js +69 -29
  24. package/dist/proxy/enforcement.js +41 -3
  25. package/dist/proxy/worker.js +21 -9
  26. package/dist/runtime/first-run.js +33 -2
  27. package/dist/runtime/nudges.js +9 -2
  28. package/dist/scan/analyze-worker.js +18 -8
  29. package/dist/scan/collect.js +45 -32
  30. package/dist/scan/command.js +80 -40
  31. package/dist/scan/discovery.js +75 -7
  32. package/dist/scan/render.js +22 -6
  33. package/dist/scan/scanner-report.js +89 -12
  34. package/dist/scan/staged.js +69 -7
  35. package/dist/scan-ui/LegacyApp.js +10 -48
  36. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  37. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  38. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  39. package/dist/scan-ui/hooks/useScan.js +74 -27
  40. package/dist/scan-ui/launch.js +21 -4
  41. package/dist/service/state.js +15 -4
  42. package/dist/service/trust-store.js +23 -2
  43. package/dist/setup/git-hook.js +28 -17
  44. package/dist/setup/plan.js +302 -18
  45. package/dist/state/cleanup-registry.js +65 -8
  46. package/dist/state/locks.js +95 -9
  47. package/dist/state/sessions.js +66 -2
  48. package/dist/verify/package-check.js +22 -3
  49. package/dist/verify/preflight.js +328 -170
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Dependency Guardian's command-line scanner. It checks the packages you are
4
4
  about to install or publish and tells you whether Dependency Guardian
5
- verified them, flagged them, or blocked them before they reach your machine.
5
+ verified them, flagged them, or blocked them before they reach your machine.
6
6
 
7
7
  ## Install
8
8
 
@@ -29,7 +29,7 @@ the project, 2 means DG blocked it.
29
29
  ## Core concepts
30
30
 
31
31
  **Verdicts.** Every result is one of three states: **PASS** (DG verified),
32
- **WARN** (DG flagged review advised), or **BLOCK** (DG blocked do not
32
+ **WARN** (DG flagged, review advised), or **BLOCK** (DG blocked, do not
33
33
  install). The exit code follows the verdict.
34
34
 
35
35
  **The server is the verdict source.** For authenticated registry checks and the
@@ -91,7 +91,7 @@ dg guard-commit install
91
91
  ### dg scan [path]
92
92
 
93
93
  Scans package manifests and supported lockfiles for the project at `path` (the
94
- current directory by default). Reads manifests only it never runs package
94
+ current directory by default). Reads manifests only; it never runs package
95
95
  scripts, installs dependencies, loads project-local config, or changes setup
96
96
  state.
97
97
 
@@ -118,8 +118,8 @@ Two paths, one exit-code contract.
118
118
 
119
119
  ### dg audit [path]
120
120
 
121
- Inspects exactly the resolved publish set of one package never the whole
122
- repo for leaked secrets, private keys, source-control and build-artifact
121
+ Inspects exactly the resolved publish set of one package, never the whole
122
+ repo, for leaked secrets, private keys, source-control and build-artifact
123
123
  leakage, and suspicious lifecycle-script strings. Basic checks run 100% locally
124
124
  and upload nothing. On a paid plan, with org policy allowing it and only after
125
125
  explicit consent, the deep audit uploads a packed copy of an npm package to the
@@ -184,7 +184,7 @@ Installs a per-repo, reversible git pre-commit hook that scans staged changes.
184
184
  Explicit, reversible service mode: `dg service start | stop | restart | status
185
185
  | doctor | uninstall`, plus consent-gated `dg service trust install |
186
186
  uninstall`. Trust records store only public certificate fingerprints, provider,
187
- target, and timestamps never private keys. `dg service status`/`doctor`
187
+ target, and timestamps, never private keys. `dg service status`/`doctor`
188
188
  detect stale runtime state and `dg service restart` clears it.
189
189
 
190
190
  ### dg login / dg logout
@@ -233,18 +233,23 @@ set`.
233
233
  Project-local allowlists never suppress an install firewall block unless
234
234
  user-global or org policy explicitly trusts them.
235
235
 
236
+ Telemetry is disabled by default. When enabled, the CLI appends command and
237
+ install-decision events to a local `telemetry.jsonl` file in the dg state
238
+ directory, limited to a fixed attribute allowlist. Nothing is transmitted
239
+ anywhere; the file never leaves your machine.
240
+
236
241
  ### Environment variables
237
242
 
238
- - `DG_API_TOKEN` auth token; overrides the token stored in the config file.
239
- - `DG_PRIVATE_REGISTRY_HOSTS` comma-separated hosts whose URL-fallback
243
+ - `DG_API_TOKEN`: auth token; overrides the token stored in the config file.
244
+ - `DG_PRIVATE_REGISTRY_HOSTS`: comma-separated hosts whose URL-fallback
240
245
  artifacts are eligible for private-registry scan upload.
241
- - `DG_SCAN_TARBALL_UPLOAD` set to `1` to enable private-registry artifact
246
+ - `DG_SCAN_TARBALL_UPLOAD`: set to `1` to enable private-registry artifact
242
247
  upload to `/v1/scan-tarball` (requires `DG_API_TOKEN`).
243
- - `DG_SERVICE_TRUST_STORE_BACKEND` / `DG_SERVICE_TRUST_STORE_DIR` for
248
+ - `DG_SERVICE_TRUST_STORE_BACKEND` / `DG_SERVICE_TRUST_STORE_DIR`: for
244
249
  CI/Docker, set the backend to `file` and a directory to copy the active
245
250
  public CA certificate into an image-local trust directory without touching
246
251
  the host OS trust store.
247
- - `NO_COLOR` / `FORCE_COLOR` disable or force ANSI color output.
252
+ - `NO_COLOR` / `FORCE_COLOR`: disable or force ANSI color output.
248
253
 
249
254
  ## Exit codes
250
255
 
@@ -272,7 +277,7 @@ Per-command specifics:
272
277
 
273
278
  ## Troubleshooting
274
279
 
275
- Run `dg doctor` first it reports Node version, package health, config
280
+ Run `dg doctor` first; it reports Node version, package health, config
276
281
  readability, shim/PATH state, and which optional gates are unavailable.
277
282
 
278
283
  - "Scanner unavailable" / `scannerUnavailable` in JSON: the API was
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
- import { readAuthState } from "../auth/store.js";
4
+ import { readAuthStateOrWarn } from "../auth/store.js";
5
5
  import { envAuthToken } from "../auth/env-token.js";
6
6
  import { loadUserConfig } from "../config/settings.js";
7
7
  import { sanitize, sanitizeResponse } from "../security/sanitize.js";
@@ -10,12 +10,81 @@ import { dgVersion } from "../commands/version.js";
10
10
  export class AnalyzeError extends Error {
11
11
  statusCode;
12
12
  body;
13
- constructor(message, statusCode, body) {
13
+ code;
14
+ scansUsed;
15
+ scansLimit;
16
+ constructor(message, statusCode, body, code) {
14
17
  super(message);
15
18
  this.statusCode = statusCode;
16
19
  this.body = body;
17
20
  this.name = "AnalyzeError";
21
+ const quota = quotaBodyFields(body);
22
+ if (quota?.scansUsed !== undefined) {
23
+ this.scansUsed = quota.scansUsed;
24
+ }
25
+ if (quota?.scansLimit !== undefined) {
26
+ this.scansLimit = quota.scansLimit;
27
+ }
28
+ this.code = code ?? classifyAnalyzeError(statusCode, body, quota !== undefined);
29
+ }
30
+ }
31
+ function quotaBodyFields(body) {
32
+ if (!body || typeof body !== "object") {
33
+ return undefined;
34
+ }
35
+ const candidate = body;
36
+ const quotaShaped = candidate.code === "quota_exceeded"
37
+ || candidate.reason === "monthly_limit"
38
+ || candidate.reason === "prefix_cap"
39
+ || typeof candidate.scansUsed === "number"
40
+ || typeof candidate.scansLimit === "number"
41
+ || typeof candidate.maxScans === "number";
42
+ if (!quotaShaped) {
43
+ return undefined;
44
+ }
45
+ const limit = typeof candidate.scansLimit === "number"
46
+ ? candidate.scansLimit
47
+ : typeof candidate.maxScans === "number"
48
+ ? candidate.maxScans
49
+ : undefined;
50
+ return {
51
+ ...(typeof candidate.scansUsed === "number" ? { scansUsed: candidate.scansUsed } : {}),
52
+ ...(limit !== undefined ? { scansLimit: limit } : {})
53
+ };
54
+ }
55
+ function classifyAnalyzeError(statusCode, body, quotaShaped) {
56
+ const bodyCode = body && typeof body === "object" ? body.code : undefined;
57
+ if (bodyCode === "rate_limited") {
58
+ return "rate_limited";
59
+ }
60
+ if (statusCode === 402 || (quotaShaped && (statusCode === 403 || statusCode === 429))) {
61
+ return "quota_exceeded";
62
+ }
63
+ if (statusCode === 429) {
64
+ return "rate_limited";
65
+ }
66
+ if (statusCode === 401 || statusCode === 403) {
67
+ return "auth";
68
+ }
69
+ if (statusCode === 0) {
70
+ return "network";
18
71
  }
72
+ return "server";
73
+ }
74
+ export function scannerErrorFromUnknown(error) {
75
+ if (error instanceof AnalyzeError) {
76
+ return {
77
+ kind: error.code,
78
+ message: error.message,
79
+ statusCode: error.statusCode,
80
+ ...(error.scansUsed !== undefined ? { scansUsed: error.scansUsed } : {}),
81
+ ...(error.scansLimit !== undefined ? { scansLimit: error.scansLimit } : {})
82
+ };
83
+ }
84
+ return {
85
+ kind: "worker",
86
+ message: error instanceof Error ? error.message : String(error)
87
+ };
19
88
  }
20
89
  const ANALYZE_PATHS = {
21
90
  npm: "/v1/analyze",
@@ -26,12 +95,16 @@ const DEFAULT_TIMEOUT_MS = 180_000;
26
95
  const BATCH_CONCURRENCY = Math.max(1, Number(process.env.DG_ANALYZE_CONCURRENCY) || 4);
27
96
  export async function analyzePackages(packages, options) {
28
97
  const env = options.env ?? process.env;
29
- const fetchImpl = options.fetchImpl ?? fetch;
30
98
  const baseUrl = resolveApiBaseUrl(env);
31
- const token = resolveToken(env);
32
- const deviceId = getOrCreateDeviceId(env);
33
- const url = `${baseUrl}${ANALYZE_PATHS[options.ecosystem]}`;
34
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
99
+ const context = {
100
+ url: `${baseUrl}${ANALYZE_PATHS[options.ecosystem]}`,
101
+ token: resolveToken(env),
102
+ deviceId: getOrCreateDeviceId(env),
103
+ scanId: options.scanId ?? randomUUID(),
104
+ fetchImpl: options.fetchImpl ?? fetch,
105
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
106
+ ...(options.signal ? { signal: options.signal } : {})
107
+ };
35
108
  const total = packages.length;
36
109
  const batches = [];
37
110
  for (let index = 0; index < packages.length; index += BATCH_SIZE) {
@@ -52,7 +125,7 @@ export async function analyzePackages(packages, options) {
52
125
  const batch = batches[i];
53
126
  if (!batch)
54
127
  continue;
55
- const response = await analyzeBatchWithRetry(url, batch, token, deviceId, fetchImpl, timeoutMs, (batchDone) => {
128
+ const response = await analyzeBatchWithRetry(context, batch, (batchDone) => {
56
129
  perBatchDone[i] = Math.min(batchDone, batch.length);
57
130
  reportProgress();
58
131
  });
@@ -67,21 +140,21 @@ export async function analyzePackages(packages, options) {
67
140
  return mergeAnalyzeResponses(responses);
68
141
  }
69
142
  const MAX_BATCH_ATTEMPTS = 3;
70
- async function analyzeBatchWithRetry(url, batch, token, deviceId, fetchImpl, timeoutMs, onBatchProgress) {
143
+ async function analyzeBatchWithRetry(context, batch, onBatchProgress) {
71
144
  let lastError;
72
145
  for (let attempt = 1; attempt <= MAX_BATCH_ATTEMPTS; attempt += 1) {
73
146
  try {
74
- return await analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs, onBatchProgress);
147
+ return await analyzeBatch(context, batch, onBatchProgress);
75
148
  }
76
149
  catch (error) {
77
150
  lastError = error;
78
- if (attempt === MAX_BATCH_ATTEMPTS || !isRetryableAnalyzeError(error)) {
151
+ if (context.signal?.aborted || attempt === MAX_BATCH_ATTEMPTS || !isRetryableAnalyzeError(error)) {
79
152
  break;
80
153
  }
81
154
  await delay(300 * attempt);
82
155
  }
83
156
  }
84
- if (lastError instanceof AnalyzeError) {
157
+ if (context.signal?.aborted || lastError instanceof AnalyzeError) {
85
158
  throw lastError;
86
159
  }
87
160
  if (isNetworkFailure(lastError)) {
@@ -104,9 +177,24 @@ function isRetryableAnalyzeError(error) {
104
177
  function delay(ms) {
105
178
  return new Promise((resolve) => setTimeout(resolve, ms));
106
179
  }
107
- async function analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs, onBatchProgress) {
180
+ async function analyzeBatch(context, batch, onBatchProgress) {
181
+ const { url, token, deviceId, scanId, fetchImpl, timeoutMs, signal } = context;
108
182
  const controller = new AbortController();
109
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
183
+ let timedOut = false;
184
+ let silenceTimer;
185
+ const armSilenceTimer = () => {
186
+ clearTimeout(silenceTimer);
187
+ silenceTimer = setTimeout(() => {
188
+ timedOut = true;
189
+ controller.abort();
190
+ }, timeoutMs);
191
+ };
192
+ const forwardAbort = () => controller.abort();
193
+ signal?.addEventListener("abort", forwardAbort, { once: true });
194
+ if (signal?.aborted) {
195
+ controller.abort();
196
+ }
197
+ armSilenceTimer();
110
198
  try {
111
199
  const response = await fetchImpl(url, {
112
200
  method: "POST",
@@ -114,6 +202,7 @@ async function analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs, o
114
202
  "Content-Type": "application/json",
115
203
  "Accept": "application/x-ndjson",
116
204
  "X-Device-Id": deviceId,
205
+ "X-Scan-Id": scanId,
117
206
  "X-Dg-Version": dgVersion(),
118
207
  ...(token ? { Authorization: `Bearer ${token}` } : {})
119
208
  },
@@ -122,31 +211,49 @@ async function analyzeBatch(url, batch, token, deviceId, fetchImpl, timeoutMs, o
122
211
  }),
123
212
  signal: controller.signal
124
213
  });
214
+ armSilenceTimer();
125
215
  if (!response.ok) {
126
216
  const body = await response.json().catch(() => undefined);
127
- const serverMessage = body && typeof body === "object" && "error" in body && typeof body.error === "string"
128
- ? sanitize(body.error)
129
- : `scanner returned ${response.status}`;
130
- throw new AnalyzeError(serverMessage, response.status, body);
217
+ throw analyzeErrorFromResponse(response.status, body);
131
218
  }
132
219
  const headers = response.headers;
133
220
  const contentType = headers?.get("content-type") ?? "";
134
221
  if (contentType.includes("application/x-ndjson") && response.body) {
135
- return await consumeAnalyzeStream(response.body, onBatchProgress);
222
+ return await consumeAnalyzeStream(response.body, onBatchProgress, armSilenceTimer);
136
223
  }
137
- return normalizeAnalyzeResponse(await response.json());
224
+ const payload = await response.json().catch(() => {
225
+ throw new AnalyzeError("scanner returned an unreadable response", response.status, undefined, "invalid_response");
226
+ });
227
+ return normalizeAnalyzeResponse(payload);
138
228
  }
139
229
  catch (error) {
140
- if (error instanceof Error && error.name === "AbortError") {
141
- throw new AnalyzeError(`scanner did not respond within ${timeoutMs}ms`, 0);
230
+ if (timedOut && error instanceof Error && error.name === "AbortError") {
231
+ throw new AnalyzeError(`scanner sent no data for ${timeoutMs}ms — scan timed out`, 0, undefined, "timeout");
142
232
  }
143
233
  throw error;
144
234
  }
145
235
  finally {
146
- clearTimeout(timeout);
236
+ clearTimeout(silenceTimer);
237
+ signal?.removeEventListener("abort", forwardAbort);
147
238
  }
148
239
  }
149
- async function consumeAnalyzeStream(body, onBatchProgress) {
240
+ function analyzeErrorFromResponse(status, body) {
241
+ const serverMessage = body && typeof body === "object" && "error" in body && typeof body.error === "string"
242
+ ? sanitize(body.error)
243
+ : undefined;
244
+ const probe = new AnalyzeError(serverMessage ?? `scanner returned ${status}`, status, body);
245
+ if (serverMessage) {
246
+ return probe;
247
+ }
248
+ if (probe.code === "quota_exceeded") {
249
+ return new AnalyzeError("scan limit reached", status, body);
250
+ }
251
+ if (probe.code === "rate_limited") {
252
+ return new AnalyzeError("scanner rate limit reached — wait a moment and retry", status, body);
253
+ }
254
+ return probe;
255
+ }
256
+ async function consumeAnalyzeStream(body, onBatchProgress, onActivity) {
150
257
  const reader = body.getReader();
151
258
  const decoder = new TextDecoder();
152
259
  let buffer = "";
@@ -177,6 +284,7 @@ async function consumeAnalyzeStream(body, onBatchProgress) {
177
284
  const { done, value } = await reader.read();
178
285
  if (done)
179
286
  break;
287
+ onActivity();
180
288
  buffer += decoder.decode(value, { stream: true });
181
289
  let newline;
182
290
  while ((newline = buffer.indexOf("\n")) >= 0) {
@@ -255,14 +363,14 @@ export function mergeAnalyzeResponses(responses) {
255
363
  }));
256
364
  }
257
365
  function resolveApiBaseUrl(env) {
258
- const auth = readAuthStateSafe(env);
366
+ const auth = readAuthStateOrWarn(env);
259
367
  if (auth?.apiBaseUrl) {
260
368
  return auth.apiBaseUrl;
261
369
  }
262
370
  return loadUserConfig(env).api.baseUrl;
263
371
  }
264
372
  function resolveToken(env) {
265
- return envAuthToken(env) ?? readAuthStateSafe(env)?.token;
373
+ return envAuthToken(env) ?? readAuthStateOrWarn(env)?.token;
266
374
  }
267
375
  export function identityHeaders(env) {
268
376
  const headers = { "X-Device-Id": getOrCreateDeviceId(env) };
@@ -272,14 +380,6 @@ export function identityHeaders(env) {
272
380
  }
273
381
  return headers;
274
382
  }
275
- function readAuthStateSafe(env) {
276
- try {
277
- return readAuthState(env);
278
- }
279
- catch {
280
- return undefined;
281
- }
282
- }
283
383
  function getOrCreateDeviceId(env) {
284
384
  const path = join(resolveDgPaths(env).stateDir, "device-id");
285
385
  try {
@@ -33,14 +33,13 @@ function buildMd(input) {
33
33
  `**Verdict:** ${input.action.toUpperCase()} · ${input.ecosystem}`,
34
34
  `**Files:** ${input.fileCount} · ${countSummaryLine(input.findings)}`,
35
35
  input.publishSetSource === "fallback" ? "**Publish set approximated**" : "",
36
- `**Deep behavioral scan:** ${deepSummary(input.deep)}`,
37
- ""
36
+ `**Deep behavioral scan:** ${deepSummary(input.deep)}`
38
37
  ].filter((line, index) => line !== "" || index === 1);
39
38
  if (input.findings.length === 0) {
40
- lines.push("> No findings — the publish set is clean.", "");
39
+ lines.push("", "> No findings — the publish set is clean.", "");
41
40
  return `${lines.join("\n")}\n`;
42
41
  }
43
- lines.push("| Severity | Location | Title | Evidence | Recommendation |", "|---|---|---|---|---|");
42
+ lines.push("", "| Severity | Location | Title | Evidence | Recommendation |", "|---|---|---|---|---|");
44
43
  for (const finding of input.findings) {
45
44
  const kind = severityKind(finding.severity);
46
45
  const sev = kind === "block" ? "BLOCK" : kind === "warn" ? "WARN" : "NOTE";
@@ -12,7 +12,8 @@ export function resolveWebBase(env) {
12
12
  if (override) {
13
13
  try {
14
14
  const url = new URL(override);
15
- if (url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1") {
15
+ const localhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
16
+ if (url.protocol === "https:" || (url.protocol === "http:" && localhost)) {
16
17
  return override.replace(/\/$/, "");
17
18
  }
18
19
  }
@@ -169,7 +170,7 @@ function waitForEnter() {
169
170
  closeSync(tty);
170
171
  }
171
172
  }
172
- export async function fetchAccountTier(token, env, fetchImpl) {
173
+ export async function fetchAccountStatus(token, env, fetchImpl, timeoutMs = 5_000) {
173
174
  let apiBase;
174
175
  try {
175
176
  apiBase = loadUserConfig(env).api.baseUrl.replace(/\/$/, "");
@@ -178,7 +179,7 @@ export async function fetchAccountTier(token, env, fetchImpl) {
178
179
  return null;
179
180
  }
180
181
  const controller = new AbortController();
181
- const timer = setTimeout(() => controller.abort(), 5_000);
182
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
182
183
  try {
183
184
  const response = await fetchImpl(`${apiBase}/v1/auth/status`, {
184
185
  headers: { Authorization: `Bearer ${token}` },
@@ -188,10 +189,11 @@ export async function fetchAccountTier(token, env, fetchImpl) {
188
189
  return null;
189
190
  }
190
191
  const body = (await response.json());
191
- if (typeof body.tier !== "string" || body.tier.length === 0) {
192
- return null;
193
- }
194
- return body.tier.toLowerCase();
192
+ return {
193
+ tier: typeof body.tier === "string" && body.tier.length > 0 ? body.tier.toLowerCase() : null,
194
+ scansUsed: typeof body.scansUsed === "number" && Number.isFinite(body.scansUsed) ? body.scansUsed : null,
195
+ scansLimit: typeof body.scansLimit === "number" && Number.isFinite(body.scansLimit) ? body.scansLimit : null
196
+ };
195
197
  }
196
198
  catch {
197
199
  return null;
@@ -200,6 +202,9 @@ export async function fetchAccountTier(token, env, fetchImpl) {
200
202
  clearTimeout(timer);
201
203
  }
202
204
  }
205
+ export async function fetchAccountTier(token, env, fetchImpl) {
206
+ return (await fetchAccountStatus(token, env, fetchImpl))?.tier ?? null;
207
+ }
203
208
  export async function runDeviceLogin(io = {}) {
204
209
  const env = io.env ?? process.env;
205
210
  const fetchImpl = io.fetchImpl ?? fetch;
@@ -254,8 +259,7 @@ export async function maybeDeviceLogin(args) {
254
259
  if (args[0] !== "login") {
255
260
  return { handled: false, result: noop };
256
261
  }
257
- const rest = args.slice(1);
258
- if (rest.some((arg) => arg === "--token" || arg.startsWith("--token=") || arg === "--help" || arg === "-h")) {
262
+ if (args.length > 1) {
259
263
  return { handled: false, result: noop };
260
264
  }
261
265
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join } from "node:path";
4
- import { loadUserConfig, saveUserConfig } from "../config/settings.js";
4
+ import { loadUserConfig, saveUserConfig, withUserConfigLock } from "../config/settings.js";
5
5
  import { resolveDgPaths } from "../state/index.js";
6
6
  import { envAuthToken } from "./env-token.js";
7
7
  export class AuthError extends Error {
@@ -41,38 +41,55 @@ export function readAuthState(env = process.env) {
41
41
  throw new AuthError(`Malformed dg auth state at ${path}: ${error instanceof Error ? error.message : "unknown error"}`);
42
42
  }
43
43
  }
44
+ const warnedAuthPaths = new Set();
45
+ export function readAuthStateOrWarn(env = process.env, options = {}) {
46
+ try {
47
+ return readAuthState(env);
48
+ }
49
+ catch {
50
+ const path = authPath(resolveDgPaths(env));
51
+ if (!warnedAuthPaths.has(path)) {
52
+ warnedAuthPaths.add(path);
53
+ const stderr = options.stderr ?? process.stderr;
54
+ stderr.write(`dg: auth state at ${path} is unreadable; continuing without your account. Run 'dg login' to repair it.\n`);
55
+ }
56
+ return undefined;
57
+ }
58
+ }
44
59
  export function writeAuthState(options, env = process.env) {
45
60
  const token = options.token.trim();
46
61
  if (token.length < 8) {
47
62
  throw new AuthError("token must be at least 8 characters");
48
63
  }
49
- const config = loadUserConfig(env);
50
- const apiBaseUrl = options.apiBaseUrl ?? config.api.baseUrl;
51
- const orgId = options.orgId ?? config.org.id;
52
64
  const email = typeof options.email === "string" && options.email.length > 0 ? options.email : undefined;
53
65
  const tier = typeof options.tier === "string" && options.tier.length > 0 ? options.tier : undefined;
54
- const state = {
55
- version: 1,
56
- token,
57
- tokenPreview: redactToken(token),
58
- apiBaseUrl,
59
- orgId,
60
- loggedInAt: (options.now ?? new Date()).toISOString(),
61
- ...(email ? { email } : {}),
62
- ...(tier ? { tier } : {})
63
- };
64
- const paths = resolveDgPaths(env);
65
- writeJsonAtomic(authPath(paths), state);
66
- saveUserConfig({
67
- ...config,
68
- api: {
69
- baseUrl: apiBaseUrl
70
- },
71
- org: {
72
- id: orgId
73
- }
74
- }, env);
75
- return state;
66
+ return withUserConfigLock(env, () => {
67
+ const config = loadUserConfig(env);
68
+ const apiBaseUrl = options.apiBaseUrl ?? config.api.baseUrl;
69
+ const orgId = options.orgId ?? config.org.id;
70
+ const state = {
71
+ version: 1,
72
+ token,
73
+ tokenPreview: redactToken(token),
74
+ apiBaseUrl,
75
+ orgId,
76
+ loggedInAt: (options.now ?? new Date()).toISOString(),
77
+ ...(email ? { email } : {}),
78
+ ...(tier ? { tier } : {})
79
+ };
80
+ const paths = resolveDgPaths(env);
81
+ writeJsonAtomic(authPath(paths), state);
82
+ saveUserConfig({
83
+ ...config,
84
+ api: {
85
+ baseUrl: apiBaseUrl
86
+ },
87
+ org: {
88
+ id: orgId
89
+ }
90
+ }, env);
91
+ return state;
92
+ });
76
93
  }
77
94
  export function clearAuthState(env = process.env) {
78
95
  const path = authPath(resolveDgPaths(env));
package/dist/bin/dg.js CHANGED
@@ -31,6 +31,11 @@ const { maybeShowFirstRun } = await import("../runtime/first-run.js");
31
31
  const { maybePreflightInstallPrompt } = await import("../launcher/preflight-prompt.js");
32
32
  const args = process.argv.slice(2);
33
33
  maybeShowFirstRun(args);
34
+ import("../state/index.js")
35
+ .then(({ pruneDeadSessionsSync, resolveDgPaths }) => {
36
+ pruneDeadSessionsSync(resolveDgPaths());
37
+ })
38
+ .catch(() => { });
34
39
  const { maybeDeviceLogin } = await import("../auth/device-login.js");
35
40
  const { maybeVerifyPackage } = await import("../verify/package-check.js");
36
41
  const { maybeAudit } = await import("../commands/audit.js");
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, resolve } from "node:path";
3
- import { EXIT_TOOL_ERROR, EXIT_USAGE } from "./types.js";
3
+ import { EXIT_TOOL_ERROR, EXIT_USAGE_VERDICT } from "./types.js";
4
4
  import { resolvePresentation } from "../presentation/mode.js";
5
5
  import { createTheme } from "../presentation/theme.js";
6
6
  import { actionForFindings, detectFindings, findingLocation } from "../audit/detectors.js";
@@ -30,7 +30,7 @@ export const auditCommand = {
30
30
  examples: ["dg audit", "dg audit ./packages/api", "dg audit --json", "dg audit --local"],
31
31
  details: [
32
32
  "Audits exactly what you're about to publish — the resolved publish set of one package, never the whole repo.",
33
- "Basic checks run 100% locally and never upload anything. If you're on a paid plan (and your org allows it), it also runs a deep behavioral scan of your package on the scanner; raw bytes are never retained. Exit codes: 0 clean (warn counts as clean under the default --fail-on block), 1 warn with --fail-on warn, 2 block, 3 deep audit required but unavailable (--require-deep), 4 analysis incomplete."
33
+ "Basic checks run 100% locally and never upload anything. If you're on a paid plan (and your org allows it), it also runs a deep behavioral scan of your package on the scanner; raw bytes are never retained. Exit codes: 0 clean (warn counts as clean under the default --fail-on block), 1 warn with --fail-on warn, 2 block, 3 deep audit required but unavailable (--require-deep), 4 analysis incomplete, 64 usage error."
34
34
  ],
35
35
  handler: (context) => runAuditCommand(context.args)
36
36
  };
@@ -82,7 +82,7 @@ function finalize(gathered, deep) {
82
82
  return { exitCode: exitCodeFor(report, parsed), stdout: rendered, stderr: "" };
83
83
  }
84
84
  function gatherError(gathered) {
85
- return gathered.usage ? usageError(gathered.error) : { exitCode: EXIT_USAGE, stdout: "", stderr: `dg audit: ${gathered.error}.\n` };
85
+ return gathered.usage ? usageError(gathered.error) : { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg audit: ${gathered.error}.\n` };
86
86
  }
87
87
  function runAuditCommand(args) {
88
88
  const gathered = gather(args);
@@ -327,6 +327,13 @@ function parseAuditArgs(args) {
327
327
  failOn = next;
328
328
  index += 1;
329
329
  }
330
+ else if (arg.startsWith("--fail-on=")) {
331
+ const value = arg.slice("--fail-on=".length);
332
+ if (value !== "warn" && value !== "block") {
333
+ return { error: "--fail-on requires 'warn' or 'block'" };
334
+ }
335
+ failOn = value;
336
+ }
330
337
  else if (arg.startsWith("-")) {
331
338
  return { error: `unknown option '${arg}'` };
332
339
  }
@@ -338,6 +345,9 @@ function parseAuditArgs(args) {
338
345
  sawTarget = true;
339
346
  }
340
347
  }
348
+ if (local && requireDeep) {
349
+ return { error: "--local skips the deep audit, so it cannot be combined with --require-deep" };
350
+ }
341
351
  return { target, format, outputPath, local, requireDeep, failOn };
342
352
  }
343
353
  export function displayTarget(root) {
@@ -355,7 +365,7 @@ function safeReadJson(path) {
355
365
  }
356
366
  function usageError(message) {
357
367
  return {
358
- exitCode: EXIT_USAGE,
368
+ exitCode: EXIT_USAGE_VERDICT,
359
369
  stdout: "",
360
370
  stderr: `dg audit: ${message}. Usage: ${auditCommand.usage}\n`
361
371
  };
@@ -1,5 +1,5 @@
1
1
  import { EXIT_USAGE } from "./types.js";
2
- import { ConfigError, getConfigValue, isConfigKey, listConfig, loadUserConfig, saveUserConfig, setConfigValue, unsetConfigValue } from "../config/settings.js";
2
+ import { ConfigError, getConfigValue, isConfigKey, listConfig, loadUserConfig, setConfigValue, unsetConfigValue, updateUserConfig } from "../config/settings.js";
3
3
  export const configCommand = {
4
4
  name: "config",
5
5
  summary: "Inspect or edit trusted dg configuration.",
@@ -54,8 +54,7 @@ function configHandler(args) {
54
54
  if (value === undefined) {
55
55
  return usageError("set requires a value");
56
56
  }
57
- const next = setConfigValue(config, key, value);
58
- saveUserConfig(next);
57
+ const next = updateUserConfig((current) => setConfigValue(current, key, value));
59
58
  return {
60
59
  exitCode: 0,
61
60
  stdout: `${key}=${getConfigValue(next, key)}\n`,
@@ -66,8 +65,7 @@ function configHandler(args) {
66
65
  if (value) {
67
66
  return usageError("unset accepts only a key");
68
67
  }
69
- const next = unsetConfigValue(config, key);
70
- saveUserConfig(next);
68
+ const next = updateUserConfig((current) => unsetConfigValue(current, key));
71
69
  return {
72
70
  exitCode: 0,
73
71
  stdout: `${key}=${getConfigValue(next, key)}\n`,
@@ -1,5 +1,5 @@
1
1
  import { EXIT_USAGE } from "./types.js";
2
- import { doctorReport, renderDoctorReport } from "../setup/plan.js";
2
+ import { doctorReportWithRemote, renderDoctorReport } from "../setup/plan.js";
3
3
  import { resolvePresentation } from "../presentation/mode.js";
4
4
  import { createTheme } from "../presentation/theme.js";
5
5
  export const doctorCommand = {
@@ -17,7 +17,7 @@ export const doctorCommand = {
17
17
  ],
18
18
  handler: (context) => doctorHandler(context.args)
19
19
  };
20
- function doctorHandler(args) {
20
+ async function doctorHandler(args) {
21
21
  const json = args.includes("--json");
22
22
  const verbose = args.includes("--verbose") || args.includes("-v");
23
23
  const unknown = args.find((arg) => arg !== "--json" && arg !== "--verbose" && arg !== "-v");
@@ -28,7 +28,7 @@ function doctorHandler(args) {
28
28
  stderr: `dg doctor: unknown option '${unknown}'. Run 'dg doctor --help'.\n`
29
29
  };
30
30
  }
31
- const report = doctorReport();
31
+ const report = await doctorReportWithRemote();
32
32
  const hasFailure = report.checks.some((check) => check.status === "fail");
33
33
  const theme = createTheme(resolvePresentation().color);
34
34
  return {