@westbayberry/dg 2.0.11 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +6 -4
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +26 -10
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +169 -0
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +8 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +3 -2
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/status.js +5 -2
  38. package/dist/commands/types.js +1 -0
  39. package/dist/commands/update.js +17 -8
  40. package/dist/commands/verify.js +8 -5
  41. package/dist/config/settings.js +154 -65
  42. package/dist/decisions/apply.js +128 -0
  43. package/dist/decisions/remember-prompt.js +92 -0
  44. package/dist/export-ui/ExportDialog.js +198 -0
  45. package/dist/install-ui/LiveInstall.js +2 -2
  46. package/dist/install-ui/block-render.js +21 -4
  47. package/dist/install-ui/prep-spinner.js +32 -0
  48. package/dist/install-ui/prompt.js +14 -0
  49. package/dist/launcher/agent-check.js +466 -0
  50. package/dist/launcher/agent-hook-exec.js +70 -0
  51. package/dist/launcher/agent-hook-io.js +31 -0
  52. package/dist/launcher/cargo-cache.js +40 -0
  53. package/dist/launcher/classify.js +17 -6
  54. package/dist/launcher/env.js +71 -24
  55. package/dist/launcher/install-preflight.js +167 -17
  56. package/dist/launcher/live-install.js +25 -5
  57. package/dist/launcher/output-redaction.js +7 -4
  58. package/dist/launcher/preflight-prompt.js +43 -3
  59. package/dist/launcher/run.js +100 -86
  60. package/dist/launcher/spawn-invocation.js +21 -0
  61. package/dist/policy/cooldown.js +117 -0
  62. package/dist/policy/evaluate.js +5 -21
  63. package/dist/policy/pypi-name.js +17 -0
  64. package/dist/presentation/mode.js +3 -2
  65. package/dist/presentation/package-page.js +9 -0
  66. package/dist/presentation/provenance.js +23 -0
  67. package/dist/presentation/theme.js +7 -7
  68. package/dist/project/dgfile.js +446 -0
  69. package/dist/proxy/auth.js +42 -0
  70. package/dist/proxy/ca.js +29 -9
  71. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  72. package/dist/proxy/enforcement.js +57 -17
  73. package/dist/proxy/metadata-map.js +66 -4
  74. package/dist/proxy/preverified.js +55 -0
  75. package/dist/proxy/server.js +473 -45
  76. package/dist/proxy/worker.js +16 -1
  77. package/dist/publish-set/collect.js +1 -4
  78. package/dist/publish-set/npm.js +8 -5
  79. package/dist/publish-set/pack.js +9 -3
  80. package/dist/runtime/cli.js +0 -4
  81. package/dist/runtime/fatal.js +31 -0
  82. package/dist/runtime/first-run.js +12 -11
  83. package/dist/runtime/node-version.js +43 -6
  84. package/dist/runtime/nudges.js +35 -2
  85. package/dist/sbom/cyclonedx.js +211 -0
  86. package/dist/sbom-ui/SbomApp.js +158 -0
  87. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  88. package/dist/sbom-ui/components/SbomList.js +52 -0
  89. package/dist/sbom-ui/inventory.js +128 -0
  90. package/dist/sbom-ui/launch.js +51 -0
  91. package/dist/sbom-ui/run.js +55 -0
  92. package/dist/sbom-ui/store.js +26 -0
  93. package/dist/scan/collect.js +10 -6
  94. package/dist/scan/command.js +51 -17
  95. package/dist/scan/discovery.js +11 -2
  96. package/dist/scan/render.js +63 -8
  97. package/dist/scan/scanner-report.js +42 -9
  98. package/dist/scan/staged.js +71 -11
  99. package/dist/scan-ui/LegacyApp.js +12 -16
  100. package/dist/scan-ui/alt-screen.js +5 -8
  101. package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
  102. package/dist/scan-ui/components/ProgressBar.js +3 -14
  103. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  104. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  105. package/dist/scan-ui/components/SetupBanner.js +0 -6
  106. package/dist/scan-ui/format-helpers.js +61 -5
  107. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  108. package/dist/scan-ui/hooks/useScan.js +46 -4
  109. package/dist/scan-ui/launch.js +7 -4
  110. package/dist/scan-ui/shims.js +14 -4
  111. package/dist/scripts/detect.js +158 -0
  112. package/dist/scripts/gate.js +170 -0
  113. package/dist/service/state.js +27 -8
  114. package/dist/service/trust-refresh.js +92 -0
  115. package/dist/service/worker.js +23 -1
  116. package/dist/setup/activate-shell.js +28 -0
  117. package/dist/setup/git-hook.js +49 -4
  118. package/dist/setup/plan.js +98 -29
  119. package/dist/setup-ui/gate.js +39 -0
  120. package/dist/setup-ui/offer.js +42 -0
  121. package/dist/setup-ui/selector.js +27 -0
  122. package/dist/setup-ui/tasks.js +56 -0
  123. package/dist/setup-ui/wizard.js +225 -0
  124. package/dist/state/cooldown-held.js +66 -0
  125. package/dist/state/index.js +1 -0
  126. package/dist/state/locks.js +4 -2
  127. package/dist/state/store.js +2 -1
  128. package/dist/util/external-tool.js +25 -0
  129. package/dist/util/git.js +10 -3
  130. package/dist/util/json-file.js +24 -0
  131. package/dist/util/report-writer.js +57 -0
  132. package/dist/util/tty-prompt.js +13 -6
  133. package/dist/verify/local.js +240 -42
  134. package/dist/verify/package-check.js +86 -18
  135. package/dist/verify/preflight.js +242 -49
  136. package/dist/verify/render.js +15 -1
  137. package/npm-shrinkwrap.json +2383 -0
  138. package/package.json +14 -8
  139. package/NOTICE +0 -5
  140. package/dist/commands/completion.js +0 -116
  141. package/dist/commands/explain.js +0 -232
  142. package/dist/commands/unavailable.js +0 -11
  143. package/dist/telemetry/events.js +0 -40
@@ -0,0 +1,158 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import chalk from "chalk";
5
+ import { useTerminalSize } from "../scan-ui/hooks/useTerminalSize.js";
6
+ import { useResizeRepaint } from "../scan-ui/hooks/useResizeRepaint.js";
7
+ import { leaveTui, showCursor, tuiIsActive } from "../scan-ui/alt-screen.js";
8
+ import { SbomHeader } from "./components/SbomHeader.js";
9
+ import { SbomList } from "./components/SbomList.js";
10
+ import { componentsCsv, componentsMarkdown, emptyFilterMessage, filterRows, tallyVerdicts } from "./inventory.js";
11
+ import { ExportDialog, loginRequiredToast } from "../export-ui/ExportDialog.js";
12
+ import { isLoggedIn } from "../scan-ui/shims.js";
13
+ import { resolvePresentation } from "../presentation/mode.js";
14
+ import { createTheme } from "../presentation/theme.js";
15
+ const HEADER_LINES = 9;
16
+ const FOOTER_LINES = 2;
17
+ const COUNTER_LINE = 1;
18
+ const SAFETY_LINE = 1;
19
+ const FILTER_CYCLE = ["all", "risky", "unlicensed"];
20
+ const FILTER_LABEL = { all: "all", risky: "risky", unlicensed: "unlicensed", unpinned: "unpinned" };
21
+ function ecosystemCounts(rows) {
22
+ const counts = new Map();
23
+ for (const row of rows) {
24
+ counts.set(row.ecosystem, (counts.get(row.ecosystem) ?? 0) + 1);
25
+ }
26
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
27
+ }
28
+ export const SbomApp = ({ store, document, cwd }) => {
29
+ const view = useSyncExternalStore(store.subscribe, store.get, store.get);
30
+ const { exit } = useApp();
31
+ const { rows: termRows, cols: termCols } = useTerminalSize();
32
+ useResizeRepaint();
33
+ const [selected, setSelected] = useState(0);
34
+ const [filter, setFilter] = useState("all");
35
+ const [query, setQuery] = useState("");
36
+ const [searching, setSearching] = useState(false);
37
+ const [exportDialog, setExportDialog] = useState(null);
38
+ const [toast, setToast] = useState(null);
39
+ const toastTimer = useRef(null);
40
+ const theme = useMemo(() => createTheme(resolvePresentation().color), []);
41
+ const showToast = useCallback((message) => {
42
+ setToast(message);
43
+ if (toastTimer.current) {
44
+ clearTimeout(toastTimer.current);
45
+ }
46
+ toastTimer.current = setTimeout(() => setToast(null), 4000);
47
+ }, []);
48
+ const leaveAltScreen = useCallback(() => {
49
+ if (tuiIsActive()) {
50
+ leaveTui();
51
+ }
52
+ else {
53
+ showCursor();
54
+ }
55
+ }, []);
56
+ useEffect(() => () => leaveAltScreen(), [leaveAltScreen]);
57
+ useEffect(() => () => {
58
+ if (toastTimer.current) {
59
+ clearTimeout(toastTimer.current);
60
+ }
61
+ }, []);
62
+ const visible = filterRows(view.rows, filter, query);
63
+ const clamped = Math.min(selected, Math.max(0, visible.length - 1));
64
+ const tally = tallyVerdicts(view.rows);
65
+ const cargoCount = view.rows.filter((row) => row.ecosystem === "cargo").length;
66
+ const quit = useCallback(() => {
67
+ process.exitCode = 0;
68
+ leaveAltScreen();
69
+ exit();
70
+ }, [exit, leaveAltScreen]);
71
+ const openExport = useCallback(() => {
72
+ if (!isLoggedIn()) {
73
+ showToast(loginRequiredToast());
74
+ return;
75
+ }
76
+ setToast(null);
77
+ setExportDialog([
78
+ { label: "CycloneDX JSON", defaultName: "sbom.cdx.json", render: () => document },
79
+ { label: "Components CSV", defaultName: "sbom-components.csv", render: () => componentsCsv(view.rows) },
80
+ { label: "Components Markdown", defaultName: "sbom-components.md", render: () => componentsMarkdown(view.rows) }
81
+ ]);
82
+ }, [document, view.rows, showToast]);
83
+ const handleExportDone = useCallback((result) => {
84
+ setExportDialog(null);
85
+ if (result === null)
86
+ return;
87
+ showToast("path" in result ? chalk.green(`✓ exported ${result.path}`) : chalk.red(`export failed: ${result.error}`));
88
+ }, [showToast]);
89
+ useInput((input, key) => {
90
+ if (exportDialog)
91
+ return;
92
+ if (searching) {
93
+ if (key.escape) {
94
+ setSearching(false);
95
+ setQuery("");
96
+ setSelected(0);
97
+ return;
98
+ }
99
+ if (key.return) {
100
+ setSearching(false);
101
+ return;
102
+ }
103
+ if (key.backspace || key.delete) {
104
+ setQuery((q) => q.slice(0, -1));
105
+ return;
106
+ }
107
+ if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && /^[\x20-\x7e]+$/.test(input)) {
108
+ setQuery((q) => q + input);
109
+ setSelected(0);
110
+ }
111
+ return;
112
+ }
113
+ if (key.escape && query) {
114
+ setQuery("");
115
+ setSelected(0);
116
+ return;
117
+ }
118
+ if (input === "q" || key.escape) {
119
+ quit();
120
+ return;
121
+ }
122
+ if (input === "j" || key.downArrow) {
123
+ setSelected((s) => Math.min(s + 1, Math.max(0, visible.length - 1)));
124
+ return;
125
+ }
126
+ if (input === "k" || key.upArrow) {
127
+ setSelected((s) => Math.max(0, s - 1));
128
+ return;
129
+ }
130
+ if (input === "g") {
131
+ setSelected(0);
132
+ return;
133
+ }
134
+ if (input === "G") {
135
+ setSelected(Math.max(0, visible.length - 1));
136
+ return;
137
+ }
138
+ if (input === "/") {
139
+ setSearching(true);
140
+ setToast(null);
141
+ return;
142
+ }
143
+ if (input === "f") {
144
+ const next = FILTER_CYCLE[(FILTER_CYCLE.indexOf(filter) + 1) % FILTER_CYCLE.length] ?? "all";
145
+ setFilter(next);
146
+ setSelected(0);
147
+ return;
148
+ }
149
+ if (input === "e") {
150
+ openExport();
151
+ }
152
+ });
153
+ const listHeight = Math.max(3, termRows - HEADER_LINES - FOOTER_LINES - COUNTER_LINE - SAFETY_LINE);
154
+ if (exportDialog) {
155
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SbomHeader, { total: view.rows.length, ecosystems: ecosystemCounts(view.rows), phase: view.phase, tally: tally, scannable: view.scannable, scanProgress: view.scanProgress, scanError: view.scanError, usage: view.usage, subject: view.subject, cargoCount: cargoCount }), _jsx(ExportDialog, { options: exportDialog, theme: theme, cwd: cwd, onDone: handleExportDone })] }));
156
+ }
157
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SbomHeader, { total: view.rows.length, ecosystems: ecosystemCounts(view.rows), phase: view.phase, tally: tally, scannable: view.scannable, scanProgress: view.scanProgress, scanError: view.scanError, usage: view.usage, subject: view.subject, cargoCount: cargoCount }), _jsx(SbomList, { rows: visible, selected: clamped, height: listHeight, width: termCols, emptyMessage: emptyFilterMessage(filter, query, view.phase, tally, view.scanError) }), _jsxs(Box, { paddingLeft: 1, flexDirection: "column", children: [toast ? _jsx(Text, { children: toast }) : null, searching ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(" esc to clear")] })) : query ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(` ${visible.length} of ${view.rows.length} components esc clear q quit`)] })) : (_jsxs(Text, { children: [chalk.dim("↑↓ move / search f "), chalk.dim(`filter: ${FILTER_LABEL[filter]}`), chalk.dim(" "), chalk.bold("e"), chalk.dim(" export q quit")] }))] })] }));
158
+ };
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+ import { renderLogo } from "../../scan-ui/logo.js";
5
+ import { useTerminalSize } from "../../scan-ui/hooks/useTerminalSize.js";
6
+ const LOGO_MIN_COLS = 76;
7
+ const LOGO_MIN_ROWS = 16;
8
+ function logoAction(phase, tally, scanError) {
9
+ if (phase !== "done" || scanError) {
10
+ return "analysis_incomplete";
11
+ }
12
+ if (tally.block > 0)
13
+ return "block";
14
+ if (tally.warn > 0)
15
+ return "warn";
16
+ if (tally.scanned > 0)
17
+ return "pass";
18
+ return "analysis_incomplete";
19
+ }
20
+ function verdictLine(tally, scanError) {
21
+ if (scanError) {
22
+ return _jsx(Text, { children: chalk.dim(scanError) });
23
+ }
24
+ return (_jsxs(Text, { children: [tally.block > 0 ? chalk.red.bold(`${tally.block} BLOCK`) : chalk.dim("0 BLOCK"), chalk.dim(" · "), tally.warn > 0 ? chalk.yellow(`${tally.warn} WARN`) : chalk.dim("0 WARN"), chalk.dim(" · "), chalk.dim(`${tally.pass} PASS`)] }));
25
+ }
26
+ export const SbomHeader = ({ total, ecosystems, phase, tally, scannable, scanProgress, scanError, usage, subject, cargoCount }) => {
27
+ const { cols, rows } = useTerminalSize();
28
+ const breakdown = ecosystems.map(([name, count]) => `${count} ${name}`).join(chalk.dim(" · "));
29
+ const logo = renderLogo(logoAction(phase, tally, scanError));
30
+ const showLogo = cols >= LOGO_MIN_COLS && rows >= LOGO_MIN_ROWS;
31
+ return (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingLeft: 1, paddingRight: 1, children: _jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { children: [chalk.bold("Dependency Guardian"), chalk.dim(" · SBOM")] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [chalk.bold(String(total)), " ", total === 1 ? "component" : "components", " ", chalk.dim(breakdown)] }), phase === "inventory" ? null : (_jsxs(_Fragment, { children: [phase === "scanning" && !scanError ? (_jsxs(Text, { children: [chalk.cyan(`scanning ${scanProgress} / ${scannable}`), chalk.dim(" npm + pypi")] })) : (verdictLine(tally, scanError)), cargoCount > 0 ? _jsx(Text, { children: chalk.dim(`${cargoCount} cargo: inventory only, not verdict-checked`) }) : null] })), _jsx(Text, { children: " " }), _jsx(Text, { children: chalk.dim(`CycloneDX 1.5 · ${subject}${usage ? ` · ${usage.used.toLocaleString()}${usage.limit === null ? "" : ` / ${usage.limit.toLocaleString()}`} scanned this month` : ""}`) })] }), showLogo ? (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: logo.map((line, index) => (_jsx(Text, { children: line }, index))) })) : null] }) }));
32
+ };
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+ import { pad, truncate } from "../../scan-ui/format-helpers.js";
5
+ export function verdictGlyph(row) {
6
+ if (!row.scannable) {
7
+ return chalk.dim("·");
8
+ }
9
+ if (!row.verdict) {
10
+ return chalk.dim("…");
11
+ }
12
+ switch (row.verdict.action) {
13
+ case "block":
14
+ return chalk.red.bold("✘");
15
+ case "warn":
16
+ return chalk.yellow("⚠");
17
+ case "pass":
18
+ return chalk.green("✓");
19
+ default:
20
+ return chalk.cyan("?");
21
+ }
22
+ }
23
+ function windowStart(selected, count, height) {
24
+ if (count <= height) {
25
+ return 0;
26
+ }
27
+ const half = Math.floor(height / 2);
28
+ return Math.min(Math.max(0, selected - half), count - height);
29
+ }
30
+ export const SbomList = ({ rows, selected, height, width, emptyMessage }) => {
31
+ if (rows.length === 0) {
32
+ return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: chalk.dim(emptyMessage) }) }));
33
+ }
34
+ const ecoWidth = 6;
35
+ const licenseWidth = Math.min(20, Math.max(10, Math.floor(width * 0.22)));
36
+ const hashWidth = 11;
37
+ const nameWidth = Math.max(12, width - 2 - 2 - ecoWidth - 1 - licenseWidth - 1 - hashWidth - 2);
38
+ const start = windowStart(selected, rows.length, height);
39
+ const shown = rows.slice(start, start + height);
40
+ return (_jsxs(Box, { flexDirection: "column", children: [shown.map((row, index) => {
41
+ const isSelected = start + index === selected;
42
+ const cursor = isSelected ? chalk.cyan("›") : " ";
43
+ const id = truncate(`${row.name}@${row.version}`, nameWidth);
44
+ const name = isSelected ? chalk.bold(pad(id, nameWidth)) : pad(id, nameWidth);
45
+ const eco = chalk.dim(pad(row.ecosystem, ecoWidth));
46
+ const license = row.license
47
+ ? chalk.dim(pad(truncate(row.license, licenseWidth), licenseWidth))
48
+ : chalk.yellow(pad("no license", licenseWidth));
49
+ const hash = row.hasHash ? chalk.dim(pad("checksum", hashWidth)) : chalk.yellow(pad("no checksum", hashWidth));
50
+ return (_jsxs(Text, { children: [cursor, " ", verdictGlyph(row), " ", name, " ", eco, " ", license, " ", hash] }, row.key));
51
+ }), rows.length > height ? (_jsx(Text, { children: chalk.dim(` ${start + 1}–${Math.min(start + height, rows.length)} of ${rows.length}`) })) : null] }));
52
+ };
@@ -0,0 +1,128 @@
1
+ const ROW_ECOSYSTEMS = { npm: "npm", pypi: "pypi", cargo: "cargo" };
2
+ export function rowKey(ecosystem, name, version) {
3
+ return `${ecosystem}:${name.toLowerCase()}@${version}`;
4
+ }
5
+ export function buildSbomRows(components) {
6
+ return components
7
+ .map((component) => {
8
+ const ecosystem = ROW_ECOSYSTEMS[component.ecosystem] ?? "other";
9
+ return {
10
+ key: rowKey(ecosystem, component.name, component.version),
11
+ name: component.name,
12
+ version: component.version,
13
+ ecosystem,
14
+ license: component.license ?? null,
15
+ hasHash: component.integrity != null && component.integrity.length > 0,
16
+ scannable: ecosystem === "npm" || ecosystem === "pypi"
17
+ };
18
+ })
19
+ .sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
20
+ }
21
+ export function verdictFromResult(result) {
22
+ if (!result.action) {
23
+ return undefined;
24
+ }
25
+ const reason = result.provenance?.downgrade
26
+ ? "provenance downgraded"
27
+ : result.cooldown?.status === "quarantine"
28
+ ? "in cooldown"
29
+ : result.reasons[0] ?? (result.action === "block" ? "malware" : result.action === "warn" ? "flagged" : "clean");
30
+ return {
31
+ action: result.action,
32
+ reason,
33
+ ...(result.provenance?.downgrade ? { provenanceFrom: result.provenance.downgrade.fromVersion } : {}),
34
+ ...(result.cooldown?.ageDays !== undefined ? { cooldownAgeDays: result.cooldown.ageDays } : {})
35
+ };
36
+ }
37
+ export function mergeVerdicts(rows, ecosystem, results) {
38
+ const byKey = new Map();
39
+ for (const result of results) {
40
+ const verdict = verdictFromResult(result);
41
+ if (verdict) {
42
+ byKey.set(rowKey(ecosystem, result.name, result.version), verdict);
43
+ }
44
+ }
45
+ if (byKey.size === 0) {
46
+ return rows;
47
+ }
48
+ return rows.map((row) => {
49
+ const verdict = byKey.get(row.key);
50
+ return verdict ? { ...row, verdict } : row;
51
+ });
52
+ }
53
+ const VERDICT_RANK = { block: 0, warn: 1, analysis_incomplete: 2, pass: 3 };
54
+ export function filterRows(rows, filter, query) {
55
+ const needle = query.trim().toLowerCase();
56
+ const matched = rows.filter((row) => {
57
+ if (needle && !row.name.toLowerCase().includes(needle)) {
58
+ return false;
59
+ }
60
+ if (filter === "risky") {
61
+ return row.verdict?.action === "block" || row.verdict?.action === "warn";
62
+ }
63
+ if (filter === "unlicensed") {
64
+ return row.license === null;
65
+ }
66
+ return true;
67
+ });
68
+ if (filter === "risky") {
69
+ return matched
70
+ .slice()
71
+ .sort((a, b) => (VERDICT_RANK[a.verdict.action] - VERDICT_RANK[b.verdict.action]) || a.name.localeCompare(b.name));
72
+ }
73
+ return matched;
74
+ }
75
+ export function emptyFilterMessage(filter, query, phase, tally, scanError) {
76
+ const trimmed = query.trim();
77
+ if (trimmed) {
78
+ return `no components match "${trimmed}"`;
79
+ }
80
+ if (filter === "risky") {
81
+ if (phase === "scanning") {
82
+ return "scanning… nothing flagged yet";
83
+ }
84
+ if (scanError) {
85
+ return scanError;
86
+ }
87
+ if (tally.scanned === 0) {
88
+ return "no components were verdict-checked";
89
+ }
90
+ return "nothing flagged — no malware, downgrades, or cooldowns";
91
+ }
92
+ if (filter === "unlicensed") {
93
+ return "every component declares a license";
94
+ }
95
+ return "no components";
96
+ }
97
+ function csvCell(value) {
98
+ return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
99
+ }
100
+ export function componentsCsv(rows) {
101
+ const lines = ["name,version,ecosystem,license,verdict"];
102
+ for (const row of rows) {
103
+ lines.push([row.name, row.version, row.ecosystem, row.license ?? "", row.verdict?.action ?? ""].map(csvCell).join(","));
104
+ }
105
+ return `${lines.join("\n")}\n`;
106
+ }
107
+ export function componentsMarkdown(rows) {
108
+ const cell = (value) => value.replace(/\|/g, "\\|");
109
+ const lines = ["| Name | Version | Ecosystem | License | Verdict |", "|---|---|---|---|---|"];
110
+ for (const row of rows) {
111
+ lines.push(`| ${cell(row.name)} | ${cell(row.version)} | ${row.ecosystem} | ${cell(row.license ?? "—")} | ${row.verdict?.action ?? ""} |`);
112
+ }
113
+ return `${lines.join("\n")}\n`;
114
+ }
115
+ export function tallyVerdicts(rows) {
116
+ let block = 0;
117
+ let warn = 0;
118
+ let pass = 0;
119
+ for (const row of rows) {
120
+ if (row.verdict?.action === "block")
121
+ block += 1;
122
+ else if (row.verdict?.action === "warn")
123
+ warn += 1;
124
+ else if (row.verdict?.action === "pass")
125
+ pass += 1;
126
+ }
127
+ return { block, warn, pass, scanned: block + warn + pass };
128
+ }
@@ -0,0 +1,51 @@
1
+ import { resolvePresentation } from "../presentation/mode.js";
2
+ import { createSbomStore } from "./store.js";
3
+ import { runSbomScan } from "./run.js";
4
+ export function shouldLaunchSbomTui(options) {
5
+ if (options.json || options.outputPath) {
6
+ return false;
7
+ }
8
+ if (process.env.TERM === "dumb") {
9
+ return false;
10
+ }
11
+ if (!process.stdin.isTTY) {
12
+ return false;
13
+ }
14
+ return resolvePresentation().mode === "rich";
15
+ }
16
+ export async function launchSbomTui(payload) {
17
+ const ci = process.env.CI;
18
+ const ciCleared = ci === "" || ci === "0" || ci === "false";
19
+ if (ciCleared) {
20
+ delete process.env.CI;
21
+ }
22
+ const [{ render }, react, app, altScreen] = await Promise.all([
23
+ import("ink"),
24
+ import("react"),
25
+ import("./SbomApp.js"),
26
+ import("../scan-ui/alt-screen.js")
27
+ ]);
28
+ const store = createSbomStore({
29
+ phase: "inventory",
30
+ rows: payload.rows,
31
+ subject: payload.subject,
32
+ dropped: payload.dropped,
33
+ scannable: payload.rows.filter((row) => row.scannable).length,
34
+ scanProgress: 0,
35
+ scanError: null,
36
+ usage: null
37
+ });
38
+ const controller = new AbortController();
39
+ altScreen.enterTui();
40
+ try {
41
+ const instance = render(react.default.createElement(app.SbomApp, { store, document: payload.document, cwd: payload.cwd }), { exitOnCtrlC: true });
42
+ void runSbomScan(store, payload.env, controller.signal);
43
+ await instance.waitUntilExit();
44
+ }
45
+ finally {
46
+ if (ciCleared)
47
+ process.env.CI = ci;
48
+ controller.abort();
49
+ altScreen.leaveTui();
50
+ }
51
+ }
@@ -0,0 +1,55 @@
1
+ import { AnalyzeError, analyzePackages } from "../api/analyze.js";
2
+ import { mergeVerdicts } from "./inventory.js";
3
+ const SCAN_ECOSYSTEMS = ["npm", "pypi"];
4
+ export async function runSbomScan(store, env, signal) {
5
+ const scannable = store.get().rows.filter((row) => row.scannable);
6
+ if (scannable.length === 0) {
7
+ store.update({ phase: "done" });
8
+ return;
9
+ }
10
+ store.update({ phase: "scanning", scannable: scannable.length, scanProgress: 0 });
11
+ let completed = 0;
12
+ try {
13
+ for (const ecosystem of SCAN_ECOSYSTEMS) {
14
+ const inputs = scannable.filter((row) => row.ecosystem === ecosystem).map((row) => ({ name: row.name, version: row.version }));
15
+ if (inputs.length === 0) {
16
+ continue;
17
+ }
18
+ const base = completed;
19
+ const response = await analyzePackages(inputs, {
20
+ ecosystem,
21
+ env,
22
+ ...(signal ? { signal } : {}),
23
+ onProgress: (progress) => store.update({ scanProgress: base + progress.done })
24
+ });
25
+ completed += inputs.length;
26
+ store.update({
27
+ rows: mergeVerdicts(store.get().rows, ecosystem, response.packages),
28
+ scanProgress: completed,
29
+ ...(response.usage ? { usage: response.usage } : {})
30
+ });
31
+ }
32
+ store.update({ phase: "done" });
33
+ }
34
+ catch (error) {
35
+ store.update({
36
+ phase: "done",
37
+ scanError: error instanceof AnalyzeError ? failOpenReason(error.code) : error instanceof Error ? error.message : "verdict scan failed"
38
+ });
39
+ }
40
+ }
41
+ function failOpenReason(code) {
42
+ switch (code) {
43
+ case "auth":
44
+ return "sign in with dg login to see verdicts";
45
+ case "quota_exceeded":
46
+ return "scan quota reached — showing inventory only";
47
+ case "rate_limited":
48
+ return "rate limited — showing inventory only";
49
+ case "network":
50
+ case "timeout":
51
+ return "offline — showing inventory only";
52
+ default:
53
+ return "scanner unavailable — showing inventory only";
54
+ }
55
+ }
@@ -0,0 +1,26 @@
1
+ export function createSbomStore(initial) {
2
+ let view = initial;
3
+ const listeners = new Set();
4
+ const emit = () => {
5
+ for (const listener of listeners) {
6
+ listener();
7
+ }
8
+ };
9
+ return {
10
+ get: () => view,
11
+ set: (next) => {
12
+ view = next;
13
+ emit();
14
+ },
15
+ update: (patch) => {
16
+ view = { ...view, ...patch };
17
+ emit();
18
+ },
19
+ subscribe: (listener) => {
20
+ listeners.add(listener);
21
+ return () => {
22
+ listeners.delete(listener);
23
+ };
24
+ }
25
+ };
26
+ }
@@ -12,6 +12,10 @@ export const LOCKFILE_ECOSYSTEMS = {
12
12
  "uv.lock": "pypi",
13
13
  "requirements.txt": "pypi"
14
14
  };
15
+ export const SBOM_LOCKFILE_ECOSYSTEMS = {
16
+ ...LOCKFILE_ECOSYSTEMS,
17
+ "Cargo.lock": "cargo"
18
+ };
15
19
  export function isLockfileName(name) {
16
20
  return Object.prototype.hasOwnProperty.call(LOCKFILE_ECOSYSTEMS, name);
17
21
  }
@@ -30,16 +34,16 @@ const IGNORED_DIRECTORIES = new Set([
30
34
  const MAX_DISCOVERY_DEPTH = 8;
31
35
  function readDirents(directory) {
32
36
  try {
33
- return readdirSync(directory, { withFileTypes: true });
37
+ return readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
34
38
  }
35
39
  catch {
36
40
  return [];
37
41
  }
38
42
  }
39
- function lockfilesPerEcosystem(entries) {
43
+ function lockfilesPerEcosystem(entries, ecosystems) {
40
44
  const matches = [];
41
45
  const claimed = new Set();
42
- for (const [lockfile, ecosystem] of Object.entries(LOCKFILE_ECOSYSTEMS)) {
46
+ for (const [lockfile, ecosystem] of Object.entries(ecosystems)) {
43
47
  if (claimed.has(ecosystem)) {
44
48
  continue;
45
49
  }
@@ -56,7 +60,7 @@ function shouldDescend(entry, directory, gitIgnored) {
56
60
  !entry.name.startsWith(".") &&
57
61
  !gitIgnored.has(join(directory, entry.name)));
58
62
  }
59
- export function discoverScanProjects(root) {
63
+ export function discoverScanProjects(root, ecosystems = LOCKFILE_ECOSYSTEMS) {
60
64
  const projects = [];
61
65
  const gitIgnored = gitIgnoredDirectories(root);
62
66
  walk(root, 0);
@@ -66,7 +70,7 @@ export function discoverScanProjects(root) {
66
70
  return;
67
71
  }
68
72
  const entries = readDirents(directory);
69
- for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
73
+ for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries, ecosystems)) {
70
74
  projects.push({
71
75
  path: directory,
72
76
  relativePath: relative(root, directory) || ".",
@@ -97,7 +101,7 @@ export async function discoverScanProjectsAsync(root, onProgress) {
97
101
  lastYield = Date.now();
98
102
  }
99
103
  const entries = readDirents(directory);
100
- for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
104
+ for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries, LOCKFILE_ECOSYSTEMS)) {
101
105
  const relativePath = relative(root, directory) || ".";
102
106
  projects.push({
103
107
  path: directory,