@westbayberry/dg 1.3.3 → 2.0.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 (126) hide show
  1. package/LICENSE +1 -201
  2. package/NOTICE +1 -4
  3. package/README.md +293 -0
  4. package/dist/api/analyze.js +210 -0
  5. package/dist/audit/deep.js +180 -0
  6. package/dist/audit/detectors.js +247 -0
  7. package/dist/audit/events.js +41 -0
  8. package/dist/audit/rules.js +426 -0
  9. package/dist/audit-ui/AuditApp.js +39 -0
  10. package/dist/audit-ui/components/AuditHeader.js +24 -0
  11. package/dist/audit-ui/components/AuditResultsView.js +307 -0
  12. package/dist/audit-ui/components/DeepStatusRow.js +11 -0
  13. package/dist/audit-ui/export.js +85 -0
  14. package/dist/audit-ui/format.js +34 -0
  15. package/dist/audit-ui/launch.js +34 -0
  16. package/dist/auth/device-login.js +271 -0
  17. package/dist/auth/env-token.js +6 -0
  18. package/dist/auth/login-app.js +156 -0
  19. package/dist/auth/store.js +147 -0
  20. package/dist/bin/dg.js +71 -0
  21. package/dist/commands/audit.js +357 -0
  22. package/dist/commands/completion.js +116 -0
  23. package/dist/commands/config.js +99 -0
  24. package/dist/commands/doctor.js +39 -0
  25. package/dist/commands/explain.js +100 -0
  26. package/dist/commands/guard-commit.js +158 -0
  27. package/dist/commands/help.js +74 -0
  28. package/dist/commands/licenses.js +435 -0
  29. package/dist/commands/login.js +81 -0
  30. package/dist/commands/logout.js +37 -0
  31. package/dist/commands/router.js +98 -0
  32. package/dist/commands/scan.js +18 -0
  33. package/dist/commands/service.js +475 -0
  34. package/dist/commands/setup.js +302 -0
  35. package/dist/commands/status.js +115 -0
  36. package/dist/commands/suggest.js +35 -0
  37. package/dist/commands/types.js +4 -0
  38. package/dist/commands/unavailable.js +11 -0
  39. package/dist/commands/uninstall.js +111 -0
  40. package/dist/commands/update.js +210 -0
  41. package/dist/commands/verify.js +151 -0
  42. package/dist/commands/version.js +22 -0
  43. package/dist/commands/wrap.js +55 -0
  44. package/dist/config/settings.js +302 -0
  45. package/dist/install-ui/LiveInstall.js +24 -0
  46. package/dist/install-ui/block-render.js +83 -0
  47. package/dist/install-ui/live-install-app.js +48 -0
  48. package/dist/install-ui/prompt.js +24 -0
  49. package/dist/launcher/classify.js +116 -0
  50. package/dist/launcher/env.js +53 -0
  51. package/dist/launcher/live-install.js +50 -0
  52. package/dist/launcher/output-redaction.js +77 -0
  53. package/dist/launcher/preflight-prompt.js +139 -0
  54. package/dist/launcher/resolve-real-binary.js +73 -0
  55. package/dist/launcher/run.js +417 -0
  56. package/dist/policy/evaluate.js +128 -0
  57. package/dist/presentation/mode.js +52 -0
  58. package/dist/presentation/theme.js +29 -0
  59. package/dist/proxy/buffer-budget.js +64 -0
  60. package/dist/proxy/ca.js +126 -0
  61. package/dist/proxy/classify-host.js +26 -0
  62. package/dist/proxy/enforcement.js +102 -0
  63. package/dist/proxy/metadata-map.js +336 -0
  64. package/dist/proxy/server.js +909 -0
  65. package/dist/proxy/upstream-proxy.js +102 -0
  66. package/dist/proxy/worker.js +39 -0
  67. package/dist/publish-set/collect.js +51 -0
  68. package/dist/publish-set/no-exec-shell.js +19 -0
  69. package/dist/publish-set/npm.js +109 -0
  70. package/dist/publish-set/pack.js +36 -0
  71. package/dist/publish-set/pypi.js +59 -0
  72. package/dist/runtime/cli.js +17 -0
  73. package/dist/runtime/first-run.js +60 -0
  74. package/dist/runtime/node-version.js +58 -0
  75. package/dist/runtime/nudges.js +105 -0
  76. package/dist/scan/analyze-worker.js +21 -0
  77. package/dist/scan/collect.js +153 -0
  78. package/dist/scan/command.js +159 -0
  79. package/dist/scan/discovery.js +209 -0
  80. package/dist/scan/render.js +240 -0
  81. package/dist/scan/scanner-report.js +82 -0
  82. package/dist/scan/staged.js +173 -0
  83. package/dist/scan/types.js +1 -0
  84. package/dist/scan-ui/LegacyApp.js +156 -0
  85. package/dist/scan-ui/alt-screen.js +84 -0
  86. package/dist/scan-ui/api-aliases.js +1 -0
  87. package/dist/scan-ui/components/ErrorView.js +23 -0
  88. package/dist/scan-ui/components/InteractiveResultsView.js +1166 -0
  89. package/dist/scan-ui/components/ProgressBar.js +89 -0
  90. package/dist/scan-ui/components/ProjectSelector.js +62 -0
  91. package/dist/scan-ui/components/ScoreHeader.js +20 -0
  92. package/dist/scan-ui/components/SetupBanner.js +13 -0
  93. package/dist/scan-ui/components/Spinner.js +4 -0
  94. package/dist/scan-ui/format-helpers.js +40 -0
  95. package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
  96. package/dist/scan-ui/hooks/useScan.js +113 -0
  97. package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
  98. package/dist/scan-ui/launch.js +27 -0
  99. package/dist/scan-ui/logo.js +91 -0
  100. package/dist/scan-ui/shims.js +30 -0
  101. package/dist/security/sanitize.js +28 -0
  102. package/dist/service/state.js +837 -0
  103. package/dist/service/trust-store.js +234 -0
  104. package/dist/service/worker.js +88 -0
  105. package/dist/setup/git-hook.js +244 -0
  106. package/dist/setup/optional-support.js +58 -0
  107. package/dist/setup/plan.js +899 -0
  108. package/dist/state/cleanup-registry.js +60 -0
  109. package/dist/state/index.js +5 -0
  110. package/dist/state/locks.js +161 -0
  111. package/dist/state/paths.js +24 -0
  112. package/dist/state/sessions.js +170 -0
  113. package/dist/state/store.js +50 -0
  114. package/dist/telemetry/events.js +40 -0
  115. package/dist/util/git.js +20 -0
  116. package/dist/util/tty-prompt.js +43 -0
  117. package/dist/verify/local.js +400 -0
  118. package/dist/verify/package-check.js +240 -0
  119. package/dist/verify/preflight.js +698 -0
  120. package/dist/verify/render.js +184 -0
  121. package/dist/verify/types.js +1 -0
  122. package/package.json +33 -50
  123. package/dist/index.mjs +0 -54116
  124. package/dist/postinstall.mjs +0 -731
  125. package/dist/python-hook/dg_pip_hook.pth +0 -1
  126. package/dist/python-hook/dg_pip_hook.py +0 -130
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useRef } from "react";
3
+ import { Text, Box } from "ink";
4
+ import InkSpinner from "ink-spinner";
5
+ import chalk from "chalk";
6
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
7
+ // Measured ETA: extrapolates remaining time from the throughput observed so far.
8
+ // `value` and `total` are the same numbers the bar renders; `elapsedSeconds` is
9
+ // the wall-clock since the bar mounted. Returns null fields when there's not
10
+ // enough signal yet (value == 0 or elapsed == 0) so the UI can show
11
+ // "calculating…" instead of a misleading number.
12
+ export function computeEta(value, total, elapsedSeconds) {
13
+ if (value <= 0 || elapsedSeconds <= 0 || total <= 0) {
14
+ return { remainingSeconds: null, ratePerSec: null };
15
+ }
16
+ const ratePerSec = value / elapsedSeconds;
17
+ if (ratePerSec <= 0 || !Number.isFinite(ratePerSec)) {
18
+ return { remainingSeconds: null, ratePerSec: null };
19
+ }
20
+ if (value >= total) {
21
+ return { remainingSeconds: 0, ratePerSec };
22
+ }
23
+ const remaining = (total - value) / ratePerSec;
24
+ return { remainingSeconds: Math.max(1, Math.ceil(remaining)), ratePerSec };
25
+ }
26
+ export function formatTime(seconds) {
27
+ if (seconds < 60)
28
+ return `${seconds}s`;
29
+ const m = Math.floor(seconds / 60);
30
+ const s = seconds % 60;
31
+ return s > 0 ? `${m}m ${s}s` : `${m}m`;
32
+ }
33
+ export function formatRate(ratePerSec) {
34
+ if (ratePerSec >= 1)
35
+ return `${Math.round(ratePerSec)} pkg/s`;
36
+ return `${ratePerSec.toFixed(1)} pkg/s`;
37
+ }
38
+ export const ProgressBar = ({ value, total, label, }) => {
39
+ const startRef = useRef(Date.now());
40
+ const [elapsed, setElapsed] = useState(0);
41
+ useEffect(() => {
42
+ const timer = setInterval(() => {
43
+ setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
44
+ }, 1000);
45
+ return () => clearInterval(timer);
46
+ }, []);
47
+ // Subscribe to terminal resizes so the bar re-lays-out when the user
48
+ // resizes the window mid-scan. Reading process.stdout.columns directly
49
+ // only captures the width at mount time and goes stale on resize.
50
+ const { cols: termWidth } = useTerminalSize();
51
+ const percent = total > 0 ? Math.round((value / total) * 100) : 0;
52
+ // Use a sub-second elapsed for the ETA math so the first tick after batch-1
53
+ // completes can produce a real number (the 1s setInterval would otherwise
54
+ // make elapsed=0 for the whole first second).
55
+ const elapsedPrecise = (Date.now() - startRef.current) / 1000;
56
+ const eta = computeEta(value, total, elapsedPrecise);
57
+ const done = total > 0 && value >= total;
58
+ const rateSuffix = eta.ratePerSec === null ? "" : ` (${formatRate(eta.ratePerSec)})`;
59
+ const rateInline = eta.ratePerSec === null ? "" : ` · ${formatRate(eta.ratePerSec)}`;
60
+ let timeInfo;
61
+ if (done) {
62
+ timeInfo = `in ${formatTime(elapsed)}${rateSuffix}`;
63
+ }
64
+ else if (eta.remainingSeconds === null) {
65
+ timeInfo = `${formatTime(elapsed)} elapsed · calculating…${rateInline}`;
66
+ }
67
+ else {
68
+ timeInfo = `${formatTime(elapsed)} elapsed · ~${formatTime(eta.remainingSeconds)} left${rateInline}`;
69
+ }
70
+ const counter = `${value}/${total} ${percent}%`;
71
+ // Width thresholds:
72
+ // < 30 cols → compact: counter only, no bar (bar would soft-wrap and
73
+ // break Ink's line-count tracker, leaving stale lines
74
+ // above on the next redraw).
75
+ // 30-59 → narrow bar (no elapsed-time tail beside the spinner).
76
+ // >= 60 → full bar + tail.
77
+ const compact = termWidth < 30;
78
+ const narrow = !compact && termWidth < 60;
79
+ // Reserve: 4 indent + counter length + 2 padding. Floor at 6 cols so
80
+ // even on a borderline-narrow terminal we draw SOMETHING; if there's
81
+ // no room at all we fall through to compact mode above.
82
+ const barWidth = Math.max(6, termWidth - counter.length - 8);
83
+ const fraction = total > 0 ? Math.min(1, value / total) : 0;
84
+ const filled = Math.round(fraction * barWidth);
85
+ const empty = barWidth - filled;
86
+ const filledBar = "━".repeat(filled);
87
+ const emptyBar = "━".repeat(empty);
88
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsx(Text, { children: chalk.bold("Dependency Guardian") }), _jsx(Text, { children: "" }), _jsxs(Box, { children: [done ? (_jsx(Text, { color: "green", children: "\u2713" })) : (_jsx(Text, { color: "cyan", children: _jsx(InkSpinner, { type: "dots" }) })), _jsxs(Text, { children: [" ", done ? "Scanned" : "Scanning", " ", total, " packages", done ? "" : "...", " "] }), !narrow && !compact && _jsx(Text, { dimColor: true, children: timeInfo })] }), compact ? (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Text, { children: counter })] })) : (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: filledBar }), _jsx(Text, { dimColor: true, children: emptyBar }), _jsxs(Text, { children: [" ", counter] })] })), label && !compact && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [" ", chalk.dim("›"), " ", label] }) }))] }));
89
+ };
@@ -0,0 +1,62 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import chalk from "chalk";
5
+ import { sanitize } from "../../security/sanitize.js";
6
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
7
+ export const ProjectSelector = ({ projects, onConfirm, onCancel, userStatus }) => {
8
+ const [cursor, setCursor] = useState(0);
9
+ const [selected, setSelected] = useState(() => new Set(projects.map((_, i) => i)));
10
+ const { cols: termCols } = useTerminalSize();
11
+ useInput((input, key) => {
12
+ if (key.upArrow) {
13
+ setCursor((c) => Math.max(0, c - 1));
14
+ }
15
+ else if (key.downArrow) {
16
+ setCursor((c) => Math.min(projects.length - 1, c + 1));
17
+ }
18
+ else if (input === " ") {
19
+ setSelected((prev) => {
20
+ const next = new Set(prev);
21
+ if (next.has(cursor))
22
+ next.delete(cursor);
23
+ else
24
+ next.add(cursor);
25
+ return next;
26
+ });
27
+ }
28
+ else if (input === "a") {
29
+ setSelected((prev) => {
30
+ if (prev.size === projects.length)
31
+ return new Set();
32
+ return new Set(projects.map((_, i) => i));
33
+ });
34
+ }
35
+ else if (key.return) {
36
+ const picked = projects.filter((_, i) => selected.has(i));
37
+ if (picked.length > 0)
38
+ onConfirm(picked);
39
+ }
40
+ else if (input === "q") {
41
+ onCancel();
42
+ }
43
+ });
44
+ const ecosystemLabel = (eco) => eco === "npm" ? chalk.magenta("npm") : chalk.blue("pip");
45
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [chalk.bold("Dependency Guardian"), " ", userStatus ? chalk.dim(userStatus) : ""] }), _jsx(Text, { children: "" }), _jsxs(Text, { bold: true, children: ["Found ", projects.length, " project", projects.length !== 1 ? "s" : ""] }), _jsx(Text, { children: "" }), projects.map((proj, i) => {
46
+ const isCursor = i === cursor;
47
+ const isSelected = selected.has(i);
48
+ const prefix = isCursor ? chalk.cyan("\u258C") : " ";
49
+ const check = isSelected ? chalk.green("\u25C9") : chalk.dim("\u25CB");
50
+ const ecoCount = `${ecosystemLabel(proj.ecosystem)} ${proj.packageCount} packages`;
51
+ const ecoCountPlainLen = `${proj.ecosystem} ${proj.packageCount} packages`.length;
52
+ const fixedPrefixLen = 4;
53
+ const pathColWidth = Math.max(20, termCols - fixedPrefixLen - ecoCountPlainLen - 3);
54
+ const path = sanitize(proj.relativePath);
55
+ const pathTruncated = path.length > pathColWidth
56
+ ? path.slice(0, Math.max(1, pathColWidth - 1)) + "\u2026"
57
+ : path.padEnd(pathColWidth);
58
+ return (_jsxs(Text, { ...(isCursor ? { backgroundColor: "#1a1a2e" } : {}), wrap: "truncate-end", children: [prefix, check, " ", pathTruncated, " ", ecoCount] }, i));
59
+ }), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: selected.size === 0
60
+ ? chalk.yellow("Select at least 1 project to scan")
61
+ : `${selected.size} of ${projects.length} selected` }), _jsx(Text, { children: "" }), _jsxs(Text, { children: [chalk.bold.cyan("space"), " ", chalk.dim("toggle"), " ", chalk.bold.cyan("a"), " ", chalk.dim("all"), " ", chalk.bold.hex('#FFD700')("\u23CE"), " ", chalk.bold.hex('#FFD700')("scan"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })] }));
62
+ };
@@ -0,0 +1,20 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Text, Box } from "ink";
3
+ import chalk from "chalk";
4
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
5
+ import { renderLogo } from "../logo.js";
6
+ function scoreColor(score, action) {
7
+ const colorFn = action === "block" ? chalk.red.bold :
8
+ action === "warn" ? chalk.yellow.bold :
9
+ action === "analysis_incomplete" ? chalk.cyan.bold :
10
+ chalk.green.bold;
11
+ return colorFn(String(score));
12
+ }
13
+ export const ScoreHeader = ({ score, action, total, flagged, clean, userStatus, scanUsage, usageNearLimit, }) => {
14
+ const logo = renderLogo(action);
15
+ const { cols } = useTerminalSize();
16
+ const showLogo = cols >= 60;
17
+ return (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: action === "block" ? "red" : action === "warn" ? "yellow" : action === "analysis_incomplete" ? "cyan" : "green", paddingLeft: 1, paddingRight: 1, width: "100%", children: _jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { bold: true, children: ["Dependency Guardian ", userStatus ?? ""] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [chalk.dim("Score"), " ", scoreColor(score, action)] }), total !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: [chalk.dim(`${total} package${total !== 1 ? "s" : ""} scanned`), flagged !== undefined && flagged > 0 ? (_jsxs(_Fragment, { children: [" ", chalk.yellow(`${flagged} flagged`), " ", chalk.green(`${clean ?? 0} clean`)] })) : (_jsxs(_Fragment, { children: [" ", chalk.green("all clean")] }))] }), scanUsage && (usageNearLimit
18
+ ? _jsxs(Text, { color: "yellow", children: [scanUsage, " \u2191 dg upgrade for Pro"] })
19
+ : _jsx(Text, { dimColor: true, children: scanUsage }))] }))] }), showLogo && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: logo.map((line, i) => _jsx(Text, { children: line }, i)) }))] }) }));
20
+ };
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ // Visible alert shown above the scan results when the user's DG setup has
4
+ // gaps — missing api key, missing pre-commit hook, etc. Renders nothing
5
+ // when issues is empty so it's free to drop in unconditionally.
6
+ //
7
+ // Yellow border, single line per issue, fix command in cyan so the user
8
+ // can copy-paste it.
9
+ export const SetupBanner = ({ issues }) => {
10
+ if (issues.length === 0)
11
+ return null;
12
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", bold: true, children: "! Setup incomplete" }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", issues.length === 1 ? "1 thing" : `${issues.length} things`, " you haven't set up yet"] })] }), issues.map((issue) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: " \u00B7 " }), issue.label, _jsx(Text, { dimColor: true, children: " \u2192 " }), _jsx(Text, { color: "cyan", bold: true, children: issue.fix })] }, issue.id)))] }));
13
+ };
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, Box } from "ink";
3
+ import InkSpinner from "ink-spinner";
4
+ export const Spinner = ({ label }) => (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(InkSpinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", label] })] }));
@@ -0,0 +1,40 @@
1
+ export const USAGE_NEAR_LIMIT_RATIO = 0.8;
2
+ // Renders the monthly package-usage readout shown on every scan. `nearLimit`
3
+ // drives the dim → yellow + upgrade-nudge escalation; it's only ever true for
4
+ // a finite limit at/above 80% used.
5
+ export function formatUsage(usage) {
6
+ const used = usage.used.toLocaleString();
7
+ if (usage.limit === null) {
8
+ return { text: `${used} packages this month`, nearLimit: false };
9
+ }
10
+ return {
11
+ text: `${used} / ${usage.limit.toLocaleString()} packages this month`,
12
+ nearLimit: usage.used / usage.limit >= USAGE_NEAR_LIMIT_RATIO,
13
+ };
14
+ }
15
+ export function pad(s, len) {
16
+ return s + " ".repeat(Math.max(0, len - s.length));
17
+ }
18
+ export function truncate(s, max) {
19
+ return s.length <= max ? s : s.slice(0, max - 1) + "…";
20
+ }
21
+ export function groupPackages(packages, keyBy = "name") {
22
+ const map = new Map();
23
+ for (const pkg of packages) {
24
+ const fingerprint = pkg.findings.length === 0
25
+ ? `__clean_${pkg.score}`
26
+ : pkg.findings
27
+ .map((f) => `${f.category ?? ""}:${f.severity}`)
28
+ .sort()
29
+ .join("|") + `|score:${pkg.score}`;
30
+ const group = map.get(fingerprint) ?? [];
31
+ group.push(pkg);
32
+ map.set(fingerprint, group);
33
+ }
34
+ return [...map.entries()]
35
+ .map(([fingerprint, pkgs]) => ({
36
+ packages: pkgs,
37
+ key: keyBy === "fingerprint" ? fingerprint : pkgs[0]?.name ?? "",
38
+ }))
39
+ .sort((a, b) => (b.packages[0]?.score ?? 0) - (a.packages[0]?.score ?? 0));
40
+ }
@@ -0,0 +1,40 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ export function useExpandAnimation(targetHeight, active, durationMs = 180) {
3
+ const [visibleLines, setVisibleLines] = useState(0);
4
+ const timerRef = useRef(null);
5
+ useEffect(() => {
6
+ if (timerRef.current) {
7
+ clearInterval(timerRef.current);
8
+ timerRef.current = null;
9
+ }
10
+ if (!active || targetHeight <= 0) {
11
+ setVisibleLines(0);
12
+ return;
13
+ }
14
+ const intervalMs = Math.max(16, Math.floor(durationMs / targetHeight));
15
+ let current = 1;
16
+ setVisibleLines(1);
17
+ timerRef.current = setInterval(() => {
18
+ current++;
19
+ if (current >= targetHeight) {
20
+ setVisibleLines(targetHeight);
21
+ if (timerRef.current)
22
+ clearInterval(timerRef.current);
23
+ timerRef.current = null;
24
+ }
25
+ else {
26
+ setVisibleLines(current);
27
+ }
28
+ }, intervalMs);
29
+ return () => {
30
+ if (timerRef.current) {
31
+ clearInterval(timerRef.current);
32
+ timerRef.current = null;
33
+ }
34
+ };
35
+ }, [active, targetHeight, durationMs]);
36
+ return {
37
+ visibleLines: active ? visibleLines : 0,
38
+ isAnimating: active && visibleLines > 0 && visibleLines < targetHeight,
39
+ };
40
+ }
@@ -0,0 +1,113 @@
1
+ import { useReducer, useEffect, useRef, useCallback, useState } from "react";
2
+ import { analyzePackages, AnalyzeError, mergeAnalyzeResponses } from "../../api/analyze.js";
3
+ import { collectScanPackages, discoverScanProjectsAsync } from "../../scan/collect.js";
4
+ function reducer(_state, action) {
5
+ switch (action.type) {
6
+ case "PROJECTS_FOUND":
7
+ case "RESTART_SELECTION":
8
+ return { phase: "selecting", projects: action.projects };
9
+ case "DISCOVERY_PROGRESS":
10
+ return { phase: "discovering", path: action.path, found: action.found };
11
+ case "DISCOVERY_COMPLETE":
12
+ return { phase: "scanning", done: 0, total: action.total, currentBatch: [] };
13
+ case "DISCOVERY_EMPTY":
14
+ return { phase: "empty", message: action.message };
15
+ case "SCAN_PROGRESS":
16
+ return { phase: "scanning", done: action.done, total: action.total, currentBatch: action.currentBatch };
17
+ case "SCAN_COMPLETE":
18
+ return {
19
+ phase: "results",
20
+ result: action.result,
21
+ durationMs: action.durationMs,
22
+ skippedCount: action.skippedCount,
23
+ ...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {})
24
+ };
25
+ case "ERROR":
26
+ return { phase: "error", error: action.error };
27
+ case "FREE_CAP_REACHED":
28
+ return { phase: "free_cap_reached", scansUsed: action.scansUsed, maxScans: action.maxScans, capReason: action.capReason };
29
+ }
30
+ }
31
+ export function useScan(config) {
32
+ const [state, dispatch] = useReducer(reducer, { phase: "discovering" });
33
+ const started = useRef(false);
34
+ const [multiProjects, setMultiProjects] = useState(null);
35
+ useEffect(() => {
36
+ if (started.current) {
37
+ return;
38
+ }
39
+ started.current = true;
40
+ void (async () => {
41
+ const projects = await discoverScanProjectsAsync(process.cwd(), (progress) => {
42
+ dispatch({ type: "DISCOVERY_PROGRESS", path: progress.path, found: progress.found });
43
+ });
44
+ if (projects.length === 0) {
45
+ dispatch({ type: "DISCOVERY_EMPTY", message: "No dependency lockfiles found." });
46
+ return;
47
+ }
48
+ if (projects.length > 1) {
49
+ setMultiProjects(projects);
50
+ dispatch({ type: "PROJECTS_FOUND", projects });
51
+ return;
52
+ }
53
+ await scanProjects(projects, dispatch);
54
+ })();
55
+ }, [config]);
56
+ const scanSelectedProjects = useCallback((projects) => {
57
+ void scanProjects(projects, dispatch);
58
+ }, []);
59
+ const restartSelection = useCallback(() => {
60
+ if (multiProjects) {
61
+ dispatch({ type: "RESTART_SELECTION", projects: multiProjects });
62
+ }
63
+ }, [multiProjects]);
64
+ return {
65
+ state,
66
+ scanSelectedProjects,
67
+ restartSelection: multiProjects ? restartSelection : null
68
+ };
69
+ }
70
+ async function scanProjects(projects, dispatch) {
71
+ try {
72
+ const { byEcosystem, skipped } = collectScanPackages(projects);
73
+ const total = [...byEcosystem.values()].reduce((sum, list) => sum + list.length, 0);
74
+ if (total === 0) {
75
+ dispatch({ type: "DISCOVERY_EMPTY", message: "No packages to scan." });
76
+ return;
77
+ }
78
+ dispatch({ type: "DISCOVERY_COMPLETE", total });
79
+ const startMs = Date.now();
80
+ const responses = [];
81
+ let completed = 0;
82
+ for (const [ecosystem, packages] of byEcosystem) {
83
+ const base = completed;
84
+ responses.push(await analyzePackages(packages, {
85
+ ecosystem,
86
+ onProgress: (done, _ecosystemTotal, currentBatch) => {
87
+ dispatch({ type: "SCAN_PROGRESS", done: base + done, total, currentBatch: [...currentBatch] });
88
+ }
89
+ }));
90
+ completed = base + packages.length;
91
+ }
92
+ dispatch({
93
+ type: "SCAN_COMPLETE",
94
+ result: mergeAnalyzeResponses(responses),
95
+ durationMs: Date.now() - startMs,
96
+ skippedCount: skipped,
97
+ discoveredTotal: total
98
+ });
99
+ }
100
+ catch (error) {
101
+ if (error instanceof AnalyzeError && (error.statusCode === 402 || error.statusCode === 429)) {
102
+ const body = (error.body ?? {});
103
+ dispatch({
104
+ type: "FREE_CAP_REACHED",
105
+ scansUsed: body.scansUsed ?? 0,
106
+ maxScans: body.maxScans ?? 0,
107
+ capReason: body.reason === "prefix_cap" ? "prefix_cap" : "monthly_limit"
108
+ });
109
+ return;
110
+ }
111
+ dispatch({ type: "ERROR", error: error instanceof Error ? error : new Error(String(error)) });
112
+ }
113
+ }
@@ -0,0 +1,24 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useStdout } from "ink";
3
+ export function useTerminalSize() {
4
+ const { stdout } = useStdout();
5
+ const [size, setSize] = useState({
6
+ rows: stdout?.rows ?? process.stdout.rows ?? 24,
7
+ cols: stdout?.columns ?? process.stdout.columns ?? 80,
8
+ });
9
+ useEffect(() => {
10
+ const handle = () => {
11
+ setSize({
12
+ rows: process.stdout.rows ?? 24,
13
+ cols: process.stdout.columns ?? 80,
14
+ });
15
+ };
16
+ process.stdout.setMaxListeners(process.stdout.getMaxListeners() + 1);
17
+ process.stdout.on("resize", handle);
18
+ return () => {
19
+ process.stdout.off("resize", handle);
20
+ process.stdout.setMaxListeners(Math.max(0, process.stdout.getMaxListeners() - 1));
21
+ };
22
+ }, []);
23
+ return size;
24
+ }
@@ -0,0 +1,27 @@
1
+ import { loadUserConfig } from "../config/settings.js";
2
+ import { resolvePresentation } from "../presentation/mode.js";
3
+ export function shouldLaunchScanTui(options) {
4
+ if (options.format !== "text" || options.outputPath) {
5
+ return false;
6
+ }
7
+ if (options.targetPath !== ".") {
8
+ return false;
9
+ }
10
+ return resolvePresentation().mode === "rich";
11
+ }
12
+ export async function launchScanTui(initialView = "results") {
13
+ const ci = process.env.CI;
14
+ if (ci === "" || ci === "0" || ci === "false") {
15
+ delete process.env.CI;
16
+ }
17
+ const [{ render }, react, app] = await Promise.all([
18
+ import("ink"),
19
+ import("react"),
20
+ import("./LegacyApp.js")
21
+ ]);
22
+ const policyMode = loadUserConfig().policy.mode;
23
+ const mode = policyMode === "off" ? "off" : policyMode === "warn" ? "warn" : "block";
24
+ const config = { mode };
25
+ const instance = render(react.default.createElement(app.App, { config, initialView }), { exitOnCtrlC: true });
26
+ await instance.waitUntilExit();
27
+ }
@@ -0,0 +1,91 @@
1
+ import chalk from "chalk";
2
+ function plot(grid, x, y, type) {
3
+ const row = grid[y];
4
+ if (!row || x < 0 || x >= row.length)
5
+ return;
6
+ row[x] = Math.max(row[x] ?? 0, type);
7
+ }
8
+ function bresenham(grid, x0, y0, x1, y1, type) {
9
+ const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
10
+ const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
11
+ let err = dx - dy, x = x0, y = y0;
12
+ while (true) {
13
+ plot(grid, x, y, type);
14
+ if (x === x1 && y === y1)
15
+ break;
16
+ const e2 = 2 * err;
17
+ if (e2 > -dy) {
18
+ err -= dy;
19
+ x += sx;
20
+ }
21
+ if (e2 < dx) {
22
+ err += dx;
23
+ y += sy;
24
+ }
25
+ }
26
+ }
27
+ function fillCircle(grid, cx, cy, r, type) {
28
+ for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++)
29
+ for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++)
30
+ if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r)
31
+ plot(grid, x, y, type);
32
+ }
33
+ const LOGO_DATA = (() => {
34
+ const W = 22, H = 28;
35
+ const grid = Array.from({ length: H }, () => Array(W).fill(0));
36
+ const cx = 11, cy = 14, R = 10;
37
+ const angles = [270, 315, 0, 45, 90, 135, 180, 225];
38
+ const nodes = angles.map(a => {
39
+ const rad = a * Math.PI / 180;
40
+ return [Math.round(cx + R * Math.cos(rad)), Math.round(cy + R * Math.sin(rad))];
41
+ });
42
+ for (const [nx, ny] of nodes)
43
+ bresenham(grid, cx, cy, nx, ny, 1);
44
+ nodes.forEach(([nx, ny], i) => fillCircle(grid, nx, ny, 1.5, i === 1 ? 4 : 2));
45
+ fillCircle(grid, cx, cy, 3, 3);
46
+ const chars = [];
47
+ const types = [];
48
+ for (let row = 0; row < H; row += 4) {
49
+ for (let col = 0; col < W; col += 2) {
50
+ let bits = 0, maxType = 0;
51
+ const offsets = [
52
+ [0, 0, 1], [1, 0, 2], [2, 0, 4], [3, 0, 64],
53
+ [0, 1, 8], [1, 1, 16], [2, 1, 32], [3, 1, 128],
54
+ ];
55
+ for (const [dy, dx, bit] of offsets) {
56
+ const py = row + dy, px = col + dx;
57
+ const cell = grid[py]?.[px] ?? 0;
58
+ if (py < H && px < W && cell > 0) {
59
+ bits |= bit;
60
+ maxType = Math.max(maxType, cell);
61
+ }
62
+ }
63
+ chars.push(String.fromCharCode(0x2800 + bits));
64
+ types.push(maxType);
65
+ }
66
+ }
67
+ return { chars, types, cols: W / 2 };
68
+ })();
69
+ export function renderLogo(action) {
70
+ const colors = {
71
+ 1: chalk.dim,
72
+ 2: chalk.white,
73
+ 3: (s) => chalk.bold.white(s),
74
+ 4: action === "block" ? chalk.red
75
+ : action === "warn" ? chalk.yellow
76
+ : action === "analysis_incomplete" ? chalk.cyan
77
+ : chalk.green,
78
+ };
79
+ const result = [];
80
+ const { chars, types, cols } = LOGO_DATA;
81
+ for (let i = 0; i < chars.length; i += cols) {
82
+ let row = "";
83
+ for (let j = 0; j < cols; j++) {
84
+ const ch = chars[i + j] ?? "";
85
+ const t = types[i + j] ?? 0;
86
+ row += t > 0 ? (colors[t] ?? chalk.dim)(ch) : ch;
87
+ }
88
+ result.push(row);
89
+ }
90
+ return result;
91
+ }
@@ -0,0 +1,30 @@
1
+ import { authStatus } from "../auth/store.js";
2
+ export function isLoggedIn() {
3
+ try {
4
+ return authStatus().authenticated;
5
+ }
6
+ catch {
7
+ return false;
8
+ }
9
+ }
10
+ export function getStoredApiKey() {
11
+ try {
12
+ const status = authStatus();
13
+ return status.authenticated ? status.tokenPreview : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function scanExitCode(action, mode) {
20
+ if (action === "block") {
21
+ return 2;
22
+ }
23
+ if (action === "warn") {
24
+ return mode === "strict" ? 2 : 1;
25
+ }
26
+ if (action === "analysis_incomplete") {
27
+ return 4;
28
+ }
29
+ return 0;
30
+ }
@@ -0,0 +1,28 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ export function sanitize(s) {
3
+ return stripVTControlCharacters(s);
4
+ }
5
+ export function sanitizeDeep(value) {
6
+ if (typeof value === "string") {
7
+ return stripVTControlCharacters(value);
8
+ }
9
+ if (value === null || value === undefined) {
10
+ return value;
11
+ }
12
+ if (Array.isArray(value)) {
13
+ return value.map((v) => sanitizeDeep(v));
14
+ }
15
+ if (typeof value === "object") {
16
+ const src = value;
17
+ const out = {};
18
+ for (const k of Object.keys(src)) {
19
+ const cleanKey = stripVTControlCharacters(k);
20
+ out[cleanKey] = sanitizeDeep(src[k]);
21
+ }
22
+ return out;
23
+ }
24
+ return value;
25
+ }
26
+ export function sanitizeResponse(response) {
27
+ return sanitizeDeep(response);
28
+ }