@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
@@ -5,53 +5,70 @@ import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.
5
5
  import { resolvePresentation } from "../presentation/mode.js";
6
6
  import { createTheme } from "../presentation/theme.js";
7
7
  import { launchScanTui, shouldLaunchScanTui } from "../scan-ui/launch.js";
8
- import { tryScannerScan } from "./scanner-report.js";
9
- import { runStagedScan } from "./staged.js";
8
+ import { runScannerScan } from "./scanner-report.js";
9
+ import { runStagedScan, stagedScanReport } from "./staged.js";
10
10
  import { scanExitCode } from "../scan-ui/shims.js";
11
11
  import { loadUserConfig } from "../config/settings.js";
12
- import { EXIT_USAGE } from "../commands/types.js";
12
+ import { EXIT_USAGE_VERDICT } from "../commands/types.js";
13
13
  export function runScanCommand(context) {
14
14
  const parsed = parseScanArgs(context.args);
15
15
  if ("error" in parsed) {
16
16
  return usageError(parsed.error);
17
17
  }
18
- if (parsed.staged) {
19
- return runStagedScan({ hook: parsed.hook });
20
- }
21
- if (shouldLaunchScanTui({
22
- targetPath: parsed.targetPath,
23
- format: parsed.format,
24
- outputPath: parsed.outputPath ?? undefined
25
- })) {
26
- void launchScanTui().catch((error) => {
27
- process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
28
- process.exitCode = 1;
29
- });
30
- return {
31
- exitCode: 0,
32
- stdout: "",
33
- stderr: ""
34
- };
18
+ const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
19
+ const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
20
+ if (parsed.staged && !machineOutput) {
21
+ return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget });
35
22
  }
36
23
  let report;
37
- try {
38
- report = scanProject({
39
- targetPath: parsed.targetPath
40
- });
24
+ let outcome;
25
+ if (parsed.staged) {
26
+ const staged = stagedScanReport({ targetPath: stagedTarget });
27
+ if ("result" in staged) {
28
+ return staged.result;
29
+ }
30
+ report = staged.report;
31
+ outcome = staged.outcome;
41
32
  }
42
- catch (error) {
43
- return {
44
- exitCode: 1,
45
- stdout: "",
46
- stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
47
- };
33
+ else {
34
+ if (shouldLaunchScanTui({
35
+ targetPath: parsed.targetPath,
36
+ format: parsed.format,
37
+ outputPath: parsed.outputPath ?? undefined
38
+ })) {
39
+ void launchScanTui().catch((error) => {
40
+ process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
41
+ process.exitCode = 1;
42
+ });
43
+ return {
44
+ exitCode: 0,
45
+ stdout: "",
46
+ stderr: ""
47
+ };
48
+ }
49
+ try {
50
+ report = scanProject({
51
+ targetPath: parsed.targetPath
52
+ });
53
+ }
54
+ catch (error) {
55
+ return {
56
+ exitCode: 1,
57
+ stdout: "",
58
+ stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
59
+ };
60
+ }
61
+ outcome = runScannerScan(parsed.targetPath, report);
62
+ }
63
+ if (outcome.kind === "report") {
64
+ report = outcome.report;
48
65
  }
49
- const scannerReport = tryScannerScan(parsed.targetPath, report);
50
- if (scannerReport) {
51
- report = scannerReport;
66
+ else if (outcome.kind === "failed") {
67
+ report = degradeReport(report, outcome.error);
52
68
  }
53
- const scannerUnavailable = scannerReport === null && report.summary.projectCount > 0;
54
- const rendered = renderReport(report, parsed.format, scannerUnavailable);
69
+ const skipNotice = skipNoticeFor(outcome, report);
70
+ const scannerUnavailable = !report.scanner && report.summary.projectCount > 0;
71
+ const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice);
55
72
  if (parsed.outputPath) {
56
73
  try {
57
74
  writeFileSync(resolve(parsed.outputPath), rendered, "utf8");
@@ -111,7 +128,7 @@ function parseScanArgs(args) {
111
128
  }
112
129
  if (arg === "--output" || arg === "-o") {
113
130
  const next = args[index + 1];
114
- if (!next) {
131
+ if (!next || next.startsWith("-")) {
115
132
  return { error: `${arg} requires a path` };
116
133
  }
117
134
  outputPath = next;
@@ -131,29 +148,52 @@ function parseScanArgs(args) {
131
148
  format,
132
149
  outputPath,
133
150
  targetPath,
151
+ sawTarget,
134
152
  staged,
135
153
  hook
136
154
  };
137
155
  }
138
156
  function usageError(message) {
139
157
  return {
140
- exitCode: EXIT_USAGE,
158
+ exitCode: EXIT_USAGE_VERDICT,
141
159
  stdout: "",
142
160
  stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>]\n`
143
161
  };
144
162
  }
145
- function renderReport(report, format, scannerUnavailable) {
163
+ function degradeReport(report, error) {
164
+ const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
165
+ return { ...report, status, scannerError: error };
166
+ }
167
+ function skipNoticeFor(outcome, report) {
168
+ if (outcome.kind !== "skipped") {
169
+ return undefined;
170
+ }
171
+ if (outcome.reason === "no_lockfiles") {
172
+ return report.summary.projectCount > 0 ? "no_lockfile" : undefined;
173
+ }
174
+ return "empty_lockfile";
175
+ }
176
+ function renderReport(report, format, scannerUnavailable, skipNotice) {
146
177
  if (format === "json") {
147
178
  return renderJsonReport(report, scannerUnavailable);
148
179
  }
149
180
  if (format === "sarif") {
150
181
  return renderSarifReport(report);
151
182
  }
152
- return renderTextReport(report, undefined, createTheme(resolvePresentation().color), scannerUnavailable);
183
+ return renderTextReport(report, undefined, createTheme(resolvePresentation().color), skipNotice);
153
184
  }
154
185
  function exitCodeForReport(report) {
155
186
  if (report.scanner) {
156
187
  return scanExitCode(report.scanner.action, loadUserConfig().policy.mode);
157
188
  }
158
- return report.status === "block" || report.status === "error" ? 1 : 0;
189
+ if (report.status === "block") {
190
+ return 2;
191
+ }
192
+ if (report.status === "warn") {
193
+ return 1;
194
+ }
195
+ if (report.status === "error" || report.status === "unknown") {
196
+ return 4;
197
+ }
198
+ return 0;
159
199
  }
@@ -82,9 +82,15 @@ function walk(directory, depth, manifests) {
82
82
  if (depth > MAX_DISCOVERY_DEPTH) {
83
83
  return;
84
84
  }
85
- const entries = readdirSync(directory, {
86
- withFileTypes: true
87
- }).sort((left, right) => left.name.localeCompare(right.name));
85
+ let entries;
86
+ try {
87
+ entries = readdirSync(directory, {
88
+ withFileTypes: true
89
+ }).sort((left, right) => left.name.localeCompare(right.name));
90
+ }
91
+ catch {
92
+ return;
93
+ }
88
94
  for (const entry of entries) {
89
95
  const absolutePath = resolve(directory, entry.name);
90
96
  if (entry.isDirectory()) {
@@ -9,7 +9,14 @@ const SCAN_STATUS_ROLE = {
9
9
  unknown: "unknown",
10
10
  error: "block"
11
11
  };
12
- export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(), theme = createTheme(false), scannerUnavailable = false) {
12
+ export function displayScanStatus(report) {
13
+ return report.scannerError && report.status === "unknown" ? "analysis_incomplete" : report.status;
14
+ }
15
+ const SCANNER_SKIP_MESSAGES = {
16
+ no_lockfile: "no lockfile found — server verification skipped (local heuristics only)",
17
+ empty_lockfile: "lockfile contains no scannable packages — server verification skipped (local heuristics only)"
18
+ };
19
+ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(), theme = createTheme(false), scannerNotice) {
13
20
  const width = Math.max(48, Math.min(terminalWidth, 140));
14
21
  const cleanProjects = report.projects.filter((project) => project.findings.length === 0);
15
22
  const findingProjects = report.projects.filter((project) => project.findings.length > 0);
@@ -18,7 +25,7 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
18
25
  "Dependency Guardian scan",
19
26
  `Target: ${report.target}`,
20
27
  `Scanning: checked ${report.summary.projectCount} project manifest${report.summary.projectCount === 1 ? "" : "s"}.`,
21
- `Status: ${theme.paint(SCAN_STATUS_ROLE[report.status], report.status)}`,
28
+ `Status: ${theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report))}`,
22
29
  `Projects: ${report.summary.projectCount}`,
23
30
  `Dependencies: ${report.summary.dependencyCount}`,
24
31
  `Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)`,
@@ -27,11 +34,20 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
27
34
  : []),
28
35
  ""
29
36
  ];
30
- if (scannerUnavailable) {
31
- lines.push("server scan unavailable — local heuristics only (run 'dg doctor' to diagnose)");
37
+ if (report.scannerError) {
38
+ const scannerError = report.scannerError;
39
+ lines.push(theme.paint(scannerError.kind === "quota_exceeded" ? "warn" : "block", `server scan failed: ${scannerError.message}`));
40
+ if (scannerError.scansUsed !== undefined || scannerError.scansLimit !== undefined) {
41
+ lines.push(`scans used: ${scannerError.scansUsed ?? "?"} of ${scannerError.scansLimit ?? "?"}`);
42
+ }
43
+ lines.push("local heuristics only — no server verdict (run 'dg doctor' to diagnose)");
44
+ lines.push("");
45
+ }
46
+ else if (scannerNotice) {
47
+ lines.push(SCANNER_SKIP_MESSAGES[scannerNotice]);
32
48
  lines.push("");
33
49
  }
34
- if (report.projects.length === 0 && report.errors.length === 0) {
50
+ if (report.projects.length === 0 && report.errors.length === 0 && !report.scanner && !report.scannerError) {
35
51
  lines.push("No supported project manifests found.");
36
52
  }
37
53
  if (findingProjects.length > 0) {
@@ -96,7 +112,7 @@ function formatProject(project, width) {
96
112
  return lines;
97
113
  }
98
114
  export function renderJsonReport(report, scannerUnavailable = false) {
99
- return `${JSON.stringify({ ...report, scannerUnavailable }, null, 2)}\n`;
115
+ return `${JSON.stringify({ ...report, status: displayScanStatus(report), scannerUnavailable }, null, 2)}\n`;
100
116
  }
101
117
  export function renderSarifReport(report) {
102
118
  const rules = uniqueFindings(report.findings).map((finding) => ({
@@ -1,43 +1,120 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { existsSync } from "node:fs";
3
4
  import { resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
6
+ import { sanitize } from "../security/sanitize.js";
5
7
  import { collectScanPackages, discoverScanProjects } from "./collect.js";
6
- const WORKER_TIMEOUT_MS = 180_000;
7
- export function tryScannerScan(targetPath, localReport, env = process.env) {
8
+ const WORKER_TIMEOUT_BASE_MS = 180_000;
9
+ const SERVER_PER_PACKAGE_WORST_CASE_MS = 660_000;
10
+ const SERVER_SCAN_CONCURRENCY = 64;
11
+ const WORKER_MAX_BUFFER = 64 * 1024 * 1024;
12
+ export function scanWorkerTimeoutMs(packageCount) {
13
+ return WORKER_TIMEOUT_BASE_MS + packageCount * Math.ceil(SERVER_PER_PACKAGE_WORST_CASE_MS / SERVER_SCAN_CONCURRENCY);
14
+ }
15
+ export function runScannerScan(targetPath, localReport, env = process.env) {
8
16
  const projects = discoverScanProjects(resolve(targetPath));
9
17
  if (projects.length === 0) {
10
- return null;
18
+ return { kind: "skipped", reason: "no_lockfiles" };
11
19
  }
12
- const { byEcosystem } = collectScanPackages(projects);
13
- const groups = [...byEcosystem.entries()].map(([ecosystem, packages]) => ({ ecosystem, packages }));
20
+ const collected = collectScanPackages(projects);
21
+ const groups = [...collected.byEcosystem.entries()].map(([ecosystem, packages]) => ({ ecosystem, packages }));
14
22
  const total = groups.reduce((sum, group) => sum + group.packages.length, 0);
15
23
  if (total === 0) {
16
- return null;
24
+ if (collected.skipped > 0) {
25
+ return { kind: "failed", error: lockfileParseError(projects, collected) };
26
+ }
27
+ return { kind: "skipped", reason: "no_packages" };
17
28
  }
18
29
  const workerPath = [
19
30
  fileURLToPath(new URL("./analyze-worker.js", import.meta.url)),
20
31
  fileURLToPath(new URL("../../dist/scan/analyze-worker.js", import.meta.url))
21
32
  ].find((candidate) => existsSync(candidate));
22
33
  if (!workerPath) {
23
- return null;
34
+ return {
35
+ kind: "failed",
36
+ error: { kind: "worker", message: "scanner worker is missing — reinstall @westbayberry/dg" }
37
+ };
24
38
  }
25
- const worker = spawnSync(process.execPath, [workerPath, JSON.stringify(groups)], {
39
+ const timeoutMs = scanWorkerTimeoutMs(total);
40
+ const worker = spawnSync(process.execPath, [workerPath], {
26
41
  encoding: "utf8",
27
42
  env,
28
- timeout: WORKER_TIMEOUT_MS
43
+ input: JSON.stringify({ scanId: randomUUID(), groups }),
44
+ maxBuffer: WORKER_MAX_BUFFER,
45
+ timeout: timeoutMs
29
46
  });
30
- if (worker.status !== 0 || !worker.stdout) {
31
- return null;
47
+ const failure = workerFailure(worker, timeoutMs, total);
48
+ if (failure) {
49
+ return { kind: "failed", error: failure };
32
50
  }
33
51
  let response;
34
52
  try {
35
53
  response = JSON.parse(worker.stdout);
36
54
  }
55
+ catch {
56
+ return {
57
+ kind: "failed",
58
+ error: { kind: "invalid_response", message: "scanner worker returned unreadable output" }
59
+ };
60
+ }
61
+ return { kind: "report", report: buildScannerReport(localReport, response, total) };
62
+ }
63
+ export function tryScannerScan(targetPath, localReport, env = process.env) {
64
+ const outcome = runScannerScan(targetPath, localReport, env);
65
+ return outcome.kind === "report" ? outcome.report : null;
66
+ }
67
+ export function workerFailure(worker, timeoutMs, packageCount) {
68
+ if (worker.error?.code === "ETIMEDOUT") {
69
+ return {
70
+ kind: "timeout",
71
+ message: `server scan timed out after ${Math.round(timeoutMs / 1000)}s without finishing ${packageCount} package${packageCount === 1 ? "" : "s"}`
72
+ };
73
+ }
74
+ if (worker.error) {
75
+ return { kind: "worker", message: `scanner worker failed to start: ${worker.error.message}` };
76
+ }
77
+ if (worker.status === 0 && worker.stdout) {
78
+ return null;
79
+ }
80
+ const reported = parseWorkerError(worker.stdout);
81
+ if (reported) {
82
+ return reported;
83
+ }
84
+ const stderrLine = (worker.stderr ?? "").trim().split("\n")[0] ?? "";
85
+ return {
86
+ kind: "worker",
87
+ message: stderrLine ? `scanner worker failed: ${sanitize(stderrLine)}` : "scanner worker exited without a result"
88
+ };
89
+ }
90
+ function parseWorkerError(stdout) {
91
+ if (!stdout) {
92
+ return null;
93
+ }
94
+ try {
95
+ const parsed = JSON.parse(stdout);
96
+ if (parsed.scannerError && typeof parsed.scannerError.message === "string" && typeof parsed.scannerError.kind === "string") {
97
+ return parsed.scannerError;
98
+ }
99
+ }
37
100
  catch {
38
101
  return null;
39
102
  }
40
- return buildScannerReport(localReport, response, total);
103
+ return null;
104
+ }
105
+ function lockfileParseError(projects, collected) {
106
+ const parseErrors = collected.parseErrors ?? [];
107
+ const detail = parseErrors
108
+ .map((entry) => [entry.path, entry.message].filter(Boolean).join(": "))
109
+ .filter((line) => line.length > 0)
110
+ .join("; ");
111
+ const lockfiles = [...new Set(projects.map((project) => project.depFile))].join(", ");
112
+ return {
113
+ kind: "lockfile_unparsed",
114
+ message: detail
115
+ ? `could not parse lockfile${parseErrors.length === 1 ? "" : "s"}: ${detail}`
116
+ : `found ${projects.length} lockfile${projects.length === 1 ? "" : "s"} (${lockfiles}) but no packages could be parsed`
117
+ };
41
118
  }
42
119
  export function buildScannerReport(localReport, response, analyzedCount) {
43
120
  const findings = response.packages
@@ -1,6 +1,6 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { basename, dirname, join } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { createTheme } from "../presentation/theme.js";
5
5
  import { resolvePresentation } from "../presentation/mode.js";
6
6
  import { loadUserConfig } from "../config/settings.js";
@@ -8,8 +8,8 @@ import { gitSync, gitTrimmed } from "../util/git.js";
8
8
  import { promptYesNo } from "../util/tty-prompt.js";
9
9
  import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
10
10
  import { isLockfileName } from "./collect.js";
11
- import { tryScannerScan } from "./scanner-report.js";
12
- import { EXIT_USAGE } from "../commands/types.js";
11
+ import { runScannerScan, tryScannerScan } from "./scanner-report.js";
12
+ import { EXIT_USAGE_VERDICT } from "../commands/types.js";
13
13
  function emptyLocalReport(target) {
14
14
  return {
15
15
  target,
@@ -34,6 +34,20 @@ export function stagedLockfilePaths(cwd, env) {
34
34
  }
35
35
  return diff.stdout.split("\0").filter(Boolean).filter((path) => isLockfileName(basename(path)));
36
36
  }
37
+ export function scopeStagedPaths(paths, root, cwd, targetPath) {
38
+ if (!targetPath) {
39
+ return [...paths];
40
+ }
41
+ const prefix = relative(safeRealpath(root), safeRealpath(resolve(safeRealpath(cwd), targetPath)));
42
+ if (prefix.startsWith("..") || isAbsolute(prefix)) {
43
+ return null;
44
+ }
45
+ if (prefix === "" || prefix === ".") {
46
+ return [...paths];
47
+ }
48
+ const normalized = prefix.split(sep).join("/");
49
+ return paths.filter((path) => path === normalized || path.startsWith(`${normalized}/`));
50
+ }
37
51
  export function materializeStaged(relPaths, cwd, env) {
38
52
  const dir = mkdtempSync(join(tmpdir(), "dg-staged-"));
39
53
  let count = 0;
@@ -58,16 +72,20 @@ export function runStagedScan(options) {
58
72
  }
59
73
  const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
60
74
  if (!root) {
61
- return { exitCode: EXIT_USAGE, stdout: "", stderr: "dg scan --staged: not a git repository.\n" };
75
+ return notARepoResult();
62
76
  }
63
77
  const lockfiles = stagedLockfilePaths(cwd, env);
64
78
  if (lockfiles === null) {
65
79
  return failOpen(theme, "could not read staged changes");
66
80
  }
67
- if (lockfiles.length === 0) {
81
+ const scoped = scopeStagedPaths(lockfiles, root, cwd, options.targetPath ?? null);
82
+ if (scoped === null) {
83
+ return outsideRepoResult(options.targetPath ?? "");
84
+ }
85
+ if (scoped.length === 0) {
68
86
  return { exitCode: 0, stdout: "", stderr: "" };
69
87
  }
70
- const { dir, count } = materializeStaged(lockfiles, cwd, env);
88
+ const { dir, count } = materializeStaged(scoped, cwd, env);
71
89
  try {
72
90
  if (count === 0) {
73
91
  return failOpen(theme, "could not read the staged lockfile contents");
@@ -82,6 +100,50 @@ export function runStagedScan(options) {
82
100
  rmSync(dir, { recursive: true, force: true });
83
101
  }
84
102
  }
103
+ export function stagedScanReport(options) {
104
+ const env = options.env ?? process.env;
105
+ const cwd = options.cwd ?? process.cwd();
106
+ const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
107
+ if (!root) {
108
+ return { result: notARepoResult() };
109
+ }
110
+ const base = { ...emptyLocalReport(root), status: "pass" };
111
+ const lockfiles = stagedLockfilePaths(cwd, env);
112
+ if (lockfiles === null) {
113
+ return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read staged changes" } } };
114
+ }
115
+ const scoped = scopeStagedPaths(lockfiles, root, cwd, options.targetPath ?? null);
116
+ if (scoped === null) {
117
+ return { result: outsideRepoResult(options.targetPath ?? "") };
118
+ }
119
+ if (scoped.length === 0) {
120
+ return { report: base, outcome: { kind: "skipped", reason: "no_lockfiles" } };
121
+ }
122
+ const { dir, count } = materializeStaged(scoped, cwd, env);
123
+ try {
124
+ if (count === 0) {
125
+ return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read the staged lockfile contents" } } };
126
+ }
127
+ return { report: base, outcome: runScannerScan(dir, base, env) };
128
+ }
129
+ finally {
130
+ rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ }
133
+ function safeRealpath(path) {
134
+ try {
135
+ return realpathSync(path);
136
+ }
137
+ catch {
138
+ return path;
139
+ }
140
+ }
141
+ function notARepoResult() {
142
+ return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: "dg scan --staged: not a git repository.\n" };
143
+ }
144
+ function outsideRepoResult(targetPath) {
145
+ return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg scan --staged: ${targetPath} is outside this repository.\n` };
146
+ }
85
147
  export function decideStagedVerdict(report, env = process.env, hook = false) {
86
148
  const theme = createTheme(resolvePresentation().color);
87
149
  const action = report.scanner?.action ?? report.status;
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useCallback, useRef } from "react";
2
+ import { useEffect, useCallback } from "react";
3
3
  import { Box, Text, useApp, useInput } from "ink";
4
- import { getStoredApiKey } from "./shims.js";
5
4
  import { useScan } from "./hooks/useScan.js";
6
5
  import { Spinner } from "./components/Spinner.js";
7
6
  import { ProgressBar } from "./components/ProgressBar.js";
@@ -11,56 +10,21 @@ import { ProjectSelector } from "./components/ProjectSelector.js";
11
10
  import { SetupBanner } from "./components/SetupBanner.js";
12
11
  import { useTerminalSize } from "./hooks/useTerminalSize.js";
13
12
  import { scanExitCode } from "./shims.js";
14
- import { enterTui, leaveTui, showCursor } from "./alt-screen.js";
13
+ import { leaveTui, showCursor, tuiIsActive } from "./alt-screen.js";
15
14
  import { formatResetDate } from "../install-ui/block-render.js";
16
- export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialView }) => {
15
+ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialView, updateAvailable }) => {
17
16
  const { state, scanSelectedProjects, restartSelection } = useScan(config);
18
17
  const { exit } = useApp();
19
18
  useTerminalSize();
20
- const prevPhaseRef = useRef(state.phase);
21
- const altScreenActiveRef = useRef(false);
22
- // Enter the alternate screen ONLY after we leave the discovering phase.
23
- // The spinner stays inline on the user's main terminal during discovery so
24
- // they see "Searching for dependencies..." in their normal scrollback rather
25
- // than a cleared screen. useLayoutEffect runs synchronously after React
26
- // commits the new tree but before Ink writes the next frame to stdout, so
27
- // post-discovery content lands directly in the alt buffer (avoiding the
28
- // "blank until keypress" diff-tracker mismatch documented in alt-screen.ts).
29
- useLayoutEffect(() => {
30
- if (state.phase === "discovering")
31
- return;
32
- if (!process.stdout.isTTY)
33
- return;
34
- if (altScreenActiveRef.current)
35
- return;
36
- enterTui();
37
- altScreenActiveRef.current = true;
38
- }, [state.phase]);
39
- // Cleanup on unmount: leave alt screen, restore cursor.
40
- useEffect(() => {
41
- return () => {
42
- if (altScreenActiveRef.current) {
43
- leaveTui();
44
- altScreenActiveRef.current = false;
45
- }
46
- else {
47
- showCursor();
48
- }
49
- };
50
- }, []);
51
- // Track phase transitions (Ink handles repainting automatically)
52
- useEffect(() => {
53
- prevPhaseRef.current = state.phase;
54
- }, [state.phase]);
55
19
  const leaveAltScreen = useCallback(() => {
56
- if (altScreenActiveRef.current) {
20
+ if (tuiIsActive()) {
57
21
  leaveTui();
58
- altScreenActiveRef.current = false;
59
22
  }
60
23
  else {
61
24
  showCursor();
62
25
  }
63
26
  }, []);
27
+ useEffect(() => () => leaveAltScreen(), [leaveAltScreen]);
64
28
  const handleResultsExit = useCallback(() => {
65
29
  if (state.phase === "results") {
66
30
  process.exitCode = scanExitCode(state.result.action, config.mode);
@@ -139,12 +103,7 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
139
103
  case "error":
140
104
  return _jsx(ErrorView, { error: state.error });
141
105
  case "free_cap_reached": {
142
- let hasKey = false;
143
- try {
144
- hasKey = !!getStoredApiKey();
145
- }
146
- catch { /* ignore — best-effort */ }
147
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [hasKey ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", bold: true, children: "Your session expired." }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: "cyan", bold: true, children: "dg logout" }), " then ", _jsx(Text, { color: "cyan", bold: true, children: "dg login" }), " to re-authenticate."] })] })) : state.capReason === "prefix_cap" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", bold: true, children: "Too many anonymous devices from your network this month." }), _jsxs(Text, { children: ["Sign in with ", _jsx(Text, { color: "cyan", bold: true, children: "dg login" }), " to keep scanning."] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Free monthly limit reached (", state.scansUsed.toLocaleString(), "/", state.maxScans.toLocaleString(), " packages)."] }), formatResetDate(state.resetsAt) ? _jsxs(Text, { dimColor: true, children: ["Resets ", formatResetDate(state.resetsAt), "."] }) : null, _jsxs(Text, { children: ["Upgrade to Pro with ", _jsx(Text, { color: "cyan", bold: true, children: "dg upgrade" }), " for 250k packages/month."] })] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "[q] quit" })] }));
106
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [state.capReason === "prefix_cap" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", bold: true, children: "Too many anonymous devices from your network this month." }), _jsxs(Text, { children: ["Sign in with ", _jsx(Text, { color: "cyan", bold: true, children: "dg login" }), " to keep scanning."] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Free monthly limit reached (", state.scansUsed.toLocaleString(), "/", state.maxScans.toLocaleString(), " packages)."] }), formatResetDate(state.resetsAt) ? _jsxs(Text, { dimColor: true, children: ["Resets ", formatResetDate(state.resetsAt), "."] }) : null, _jsxs(Text, { children: ["Upgrade to Pro at ", _jsx(Text, { color: "cyan", bold: true, children: "westbayberry.com/pricing" }), " for 250k packages/month."] })] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "[q] quit" })] }));
148
107
  }
149
108
  }
150
109
  })();
@@ -153,5 +112,8 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
153
112
  // actually needs to read.
154
113
  const showBanner = state.phase === "selecting" &&
155
114
  setupIssues.length > 0;
156
- return (_jsxs(Box, { flexDirection: "column", children: [showBanner ? _jsx(SetupBanner, { issues: setupIssues }) : null, content] }));
115
+ // Results fill the terminal height exactly; an extra line there overflows the alt screen.
116
+ const showUpdateLine = updateAvailable !== undefined &&
117
+ (state.phase === "selecting" || state.phase === "scanning");
118
+ return (_jsxs(Box, { flexDirection: "column", children: [showBanner ? _jsx(SetupBanner, { issues: setupIssues }) : null, content, showUpdateLine ? _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { dimColor: true, children: updateAvailable }) }) : null] }));
157
119
  };