@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.
- package/README.md +216 -226
- package/dist/agents/claude-code.js +113 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +113 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +285 -0
- package/dist/agents/registry.js +127 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +6 -4
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +8 -3
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +231 -0
- package/dist/commands/audit.js +22 -3
- package/dist/commands/config.js +26 -10
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +169 -0
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +8 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +3 -2
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +197 -27
- package/dist/commands/status.js +5 -2
- package/dist/commands/types.js +1 -0
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +154 -65
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +92 -0
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +2 -2
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/install-ui/prompt.js +14 -0
- package/dist/launcher/agent-check.js +466 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +31 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +17 -6
- package/dist/launcher/env.js +71 -24
- package/dist/launcher/install-preflight.js +167 -17
- package/dist/launcher/live-install.js +25 -5
- package/dist/launcher/output-redaction.js +7 -4
- package/dist/launcher/preflight-prompt.js +43 -3
- package/dist/launcher/run.js +100 -86
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +117 -0
- package/dist/policy/evaluate.js +5 -21
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/provenance.js +23 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +446 -0
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +57 -17
- package/dist/proxy/metadata-map.js +66 -4
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +473 -45
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +12 -11
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +128 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +51 -17
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +63 -8
- package/dist/scan/scanner-report.js +42 -9
- package/dist/scan/staged.js +71 -11
- package/dist/scan-ui/LegacyApp.js +12 -16
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +46 -4
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +14 -4
- package/dist/scripts/detect.js +158 -0
- package/dist/scripts/gate.js +170 -0
- package/dist/service/state.js +27 -8
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +98 -29
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +225 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/json-file.js +24 -0
- package/dist/util/report-writer.js +57 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/local.js +240 -42
- package/dist/verify/package-check.js +86 -18
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +14 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -116
- package/dist/commands/explain.js +0 -232
- package/dist/commands/unavailable.js +0 -11
- package/dist/telemetry/events.js +0 -40
|
@@ -4,11 +4,7 @@ import { Text, Box } from "ink";
|
|
|
4
4
|
import InkSpinner from "ink-spinner";
|
|
5
5
|
import chalk from "chalk";
|
|
6
6
|
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
|
7
|
-
//
|
|
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.
|
|
7
|
+
// Null fields until value/elapsed are non-zero so the UI shows "calculating…".
|
|
12
8
|
export function computeEta(value, total, elapsedSeconds) {
|
|
13
9
|
if (value <= 0 || elapsedSeconds <= 0 || total <= 0) {
|
|
14
10
|
return { remainingSeconds: null, ratePerSec: null };
|
|
@@ -68,17 +64,10 @@ export const ProgressBar = ({ value, total, label, }) => {
|
|
|
68
64
|
timeInfo = `${formatTime(elapsed)} elapsed · ~${formatTime(eta.remainingSeconds)} left${rateInline}`;
|
|
69
65
|
}
|
|
70
66
|
const counter = `${value}/${total} ${percent}%`;
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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.
|
|
67
|
+
// Below 30 cols the bar would soft-wrap and break Ink's line-count
|
|
68
|
+
// tracker, leaving stale lines above on the next redraw.
|
|
77
69
|
const compact = termWidth < 30;
|
|
78
70
|
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
71
|
const barWidth = Math.max(6, termWidth - counter.length - 8);
|
|
83
72
|
const fraction = total > 0 ? Math.min(1, value / total) : 0;
|
|
84
73
|
const filled = Math.round(fraction * barWidth);
|
|
@@ -42,7 +42,7 @@ export const ProjectSelector = ({ projects, onConfirm, onCancel, userStatus }) =
|
|
|
42
42
|
}
|
|
43
43
|
});
|
|
44
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
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [chalk.bold("Dependency Guardian"), " ", 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
46
|
const isCursor = i === cursor;
|
|
47
47
|
const isSelected = selected.has(i);
|
|
48
48
|
const prefix = isCursor ? chalk.cyan("\u258C") : " ";
|
|
@@ -13,10 +13,9 @@ function scoreColor(score, action) {
|
|
|
13
13
|
chalk.green.bold;
|
|
14
14
|
return colorFn(String(score));
|
|
15
15
|
}
|
|
16
|
-
export const ScoreHeader = ({ score, action, total, flagged, clean, userStatus, scanUsage, usageNearLimit, }) => {
|
|
16
|
+
export const ScoreHeader = ({ score, action, compact, total, flagged, clean, userStatus, scanUsage, usageNearLimit, }) => {
|
|
17
17
|
const logo = renderLogo(action);
|
|
18
|
-
const { cols
|
|
19
|
-
const compact = rows < COMPACT_ROWS;
|
|
18
|
+
const { cols } = useTerminalSize();
|
|
20
19
|
const showLogo = cols >= LOGO_MIN_COLS && !compact;
|
|
21
20
|
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 ?? ""] }), !compact && _jsx(Text, { children: " " }), _jsxs(Text, { children: [chalk.dim("Score"), " ", scoreColor(score, action)] }), total !== undefined && (_jsxs(_Fragment, { children: [!compact && _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
|
|
22
21
|
? _jsxs(Text, { color: "yellow", children: [scanUsage, " \u2191 Pro: westbayberry.com/pricing"] })
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
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
3
|
export const SetupBanner = ({ issues }) => {
|
|
10
4
|
if (issues.length === 0)
|
|
11
5
|
return null;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { sanitize } from "../security/sanitize.js";
|
|
1
3
|
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
4
|
export function formatUsage(usage) {
|
|
6
5
|
const used = usage.used.toLocaleString();
|
|
7
6
|
if (usage.limit === null) {
|
|
@@ -12,11 +11,68 @@ export function formatUsage(usage) {
|
|
|
12
11
|
nearLimit: usage.used / usage.limit >= USAGE_NEAR_LIMIT_RATIO,
|
|
13
12
|
};
|
|
14
13
|
}
|
|
14
|
+
export function formatAccountStatus(tier, loggedIn, name) {
|
|
15
|
+
const cleaned = sanitize(tier).trim().slice(0, 24).toLowerCase();
|
|
16
|
+
const label = cleaned ? cleaned.charAt(0).toUpperCase() + cleaned.slice(1) : loggedIn ? "Account" : "Free";
|
|
17
|
+
if (!loggedIn) {
|
|
18
|
+
return `${chalk.dim(`${label} · `)}${chalk.cyan.bold("dg login")}`;
|
|
19
|
+
}
|
|
20
|
+
const plan = (cleaned === "free" ? chalk.yellow : chalk.green)(`${label} plan`);
|
|
21
|
+
const who = name ? sanitize(name).trim().slice(0, 40) : "";
|
|
22
|
+
return who ? `${chalk.dim(`${who} · `)}${plan}` : plan;
|
|
23
|
+
}
|
|
24
|
+
function isWideCodePoint(cp) {
|
|
25
|
+
return (cp >= 0x1100 &&
|
|
26
|
+
(cp <= 0x115f ||
|
|
27
|
+
cp === 0x2329 ||
|
|
28
|
+
cp === 0x232a ||
|
|
29
|
+
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
|
|
30
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
31
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
32
|
+
(cp >= 0xfe10 && cp <= 0xfe19) ||
|
|
33
|
+
(cp >= 0xfe30 && cp <= 0xfe6b) ||
|
|
34
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
35
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
36
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
37
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
38
|
+
(cp >= 0x20000 && cp <= 0x3fffd)));
|
|
39
|
+
}
|
|
40
|
+
function isZeroWidthCodePoint(cp) {
|
|
41
|
+
return ((cp >= 0x0300 && cp <= 0x036f) ||
|
|
42
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
43
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
44
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
45
|
+
(cp >= 0x200b && cp <= 0x200f) ||
|
|
46
|
+
cp === 0xfeff);
|
|
47
|
+
}
|
|
48
|
+
function codePointWidth(cp) {
|
|
49
|
+
if (isZeroWidthCodePoint(cp))
|
|
50
|
+
return 0;
|
|
51
|
+
return isWideCodePoint(cp) ? 2 : 1;
|
|
52
|
+
}
|
|
53
|
+
export function displayWidth(s) {
|
|
54
|
+
let width = 0;
|
|
55
|
+
for (const ch of s) {
|
|
56
|
+
width += codePointWidth(ch.codePointAt(0) ?? 0);
|
|
57
|
+
}
|
|
58
|
+
return width;
|
|
59
|
+
}
|
|
15
60
|
export function pad(s, len) {
|
|
16
|
-
return s + " ".repeat(Math.max(0, len - s
|
|
61
|
+
return s + " ".repeat(Math.max(0, len - displayWidth(s)));
|
|
17
62
|
}
|
|
18
63
|
export function truncate(s, max) {
|
|
19
|
-
|
|
64
|
+
if (displayWidth(s) <= max)
|
|
65
|
+
return s;
|
|
66
|
+
let out = "";
|
|
67
|
+
let width = 0;
|
|
68
|
+
for (const ch of s) {
|
|
69
|
+
const w = codePointWidth(ch.codePointAt(0) ?? 0);
|
|
70
|
+
if (width + w > max - 1)
|
|
71
|
+
break;
|
|
72
|
+
out += ch;
|
|
73
|
+
width += w;
|
|
74
|
+
}
|
|
75
|
+
return out + "…";
|
|
20
76
|
}
|
|
21
77
|
export function groupPackages(packages, keyBy = "name") {
|
|
22
78
|
const map = new Map();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useStdout } from "ink";
|
|
3
|
+
import { CLEAR_HOME } from "../alt-screen.js";
|
|
4
|
+
// Ink skips repainting when output matches its memoized last frame, so a bare
|
|
5
|
+
// screen clear on resize leaves the TUI blank until the next state change.
|
|
6
|
+
// useStdout().write is the only public path that erases and repaints the
|
|
7
|
+
// memoized frame unconditionally; CLEAR_HOME first wipes resize artifacts.
|
|
8
|
+
export function useResizeRepaint() {
|
|
9
|
+
const { stdout, write } = useStdout();
|
|
10
|
+
const [resizes, setResizes] = useState(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const handle = () => setResizes((count) => count + 1);
|
|
13
|
+
stdout.setMaxListeners(stdout.getMaxListeners() + 1);
|
|
14
|
+
stdout.on("resize", handle);
|
|
15
|
+
return () => {
|
|
16
|
+
stdout.off("resize", handle);
|
|
17
|
+
stdout.setMaxListeners(Math.max(0, stdout.getMaxListeners() - 1));
|
|
18
|
+
};
|
|
19
|
+
}, [stdout]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (resizes === 0)
|
|
22
|
+
return;
|
|
23
|
+
write(CLEAR_HOME);
|
|
24
|
+
}, [resizes, write]);
|
|
25
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { useReducer, useEffect, useRef, useCallback, useState } from "react";
|
|
3
3
|
import { analyzePackages, AnalyzeError, mergeAnalyzeResponses } from "../../api/analyze.js";
|
|
4
|
+
import { applyDecisions, packageKey } from "../../decisions/apply.js";
|
|
5
|
+
import { findProjectRoot, loadDgFile } from "../../project/dgfile.js";
|
|
4
6
|
import { collectScanPackages, discoverScanProjectsAsync } from "../../scan/collect.js";
|
|
5
7
|
function reducer(_state, action) {
|
|
6
8
|
switch (action.type) {
|
|
@@ -21,7 +23,8 @@ function reducer(_state, action) {
|
|
|
21
23
|
result: action.result,
|
|
22
24
|
durationMs: action.durationMs,
|
|
23
25
|
skippedCount: action.skippedCount,
|
|
24
|
-
...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {})
|
|
26
|
+
...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {}),
|
|
27
|
+
...(action.decisions !== undefined ? { decisions: action.decisions } : {})
|
|
25
28
|
};
|
|
26
29
|
case "ERROR":
|
|
27
30
|
return { phase: "error", error: action.error };
|
|
@@ -50,9 +53,10 @@ export function useScan(config) {
|
|
|
50
53
|
}
|
|
51
54
|
started.current = true;
|
|
52
55
|
void (async () => {
|
|
53
|
-
const
|
|
56
|
+
const discovered = await discoverScanProjectsAsync(process.cwd(), (progress) => {
|
|
54
57
|
dispatch({ type: "DISCOVERY_PROGRESS", path: progress.path, found: progress.found });
|
|
55
58
|
});
|
|
59
|
+
const projects = discovered.filter((project) => project.ecosystem === "npm" || project.ecosystem === "pypi");
|
|
56
60
|
if (projects.length === 0) {
|
|
57
61
|
dispatch({ type: "DISCOVERY_EMPTY", message: "No dependency lockfiles found." });
|
|
58
62
|
return;
|
|
@@ -79,6 +83,28 @@ export function useScan(config) {
|
|
|
79
83
|
restartSelection: multiProjects ? restartSelection : null
|
|
80
84
|
};
|
|
81
85
|
}
|
|
86
|
+
function computeProjectDecisions(result, entries) {
|
|
87
|
+
try {
|
|
88
|
+
const root = findProjectRoot(process.cwd());
|
|
89
|
+
if (!root) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const file = loadDgFile(root);
|
|
93
|
+
if (!file.readable) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const ecosystems = new Map();
|
|
97
|
+
for (const [ecosystem, packages] of entries) {
|
|
98
|
+
for (const pkg of packages) {
|
|
99
|
+
ecosystems.set(packageKey(pkg.name, pkg.version), ecosystem);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return applyDecisions(result.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), file, result.action);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
82
108
|
async function scanProjects(projects, dispatch, signal) {
|
|
83
109
|
const startMs = Date.now();
|
|
84
110
|
let skipped = 0;
|
|
@@ -134,12 +160,28 @@ async function scanProjects(projects, dispatch, signal) {
|
|
|
134
160
|
const firstFailure = outcomes.find((outcome) => "error" in outcome);
|
|
135
161
|
if (responses.length > 0) {
|
|
136
162
|
const merged = mergeAnalyzeResponses(responses);
|
|
163
|
+
const base = firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged;
|
|
164
|
+
const ecoByKey = new Map();
|
|
165
|
+
for (const [ecosystem, packages] of entries) {
|
|
166
|
+
for (const pkg of packages) {
|
|
167
|
+
ecoByKey.set(packageKey(pkg.name, pkg.version), ecosystem);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const result = {
|
|
171
|
+
...base,
|
|
172
|
+
packages: base.packages.map((pkg) => {
|
|
173
|
+
const ecosystem = ecoByKey.get(packageKey(pkg.name, pkg.version));
|
|
174
|
+
return ecosystem ? { ...pkg, ecosystem } : pkg;
|
|
175
|
+
})
|
|
176
|
+
};
|
|
177
|
+
const decisions = computeProjectDecisions(result, entries);
|
|
137
178
|
dispatch({
|
|
138
179
|
type: "SCAN_COMPLETE",
|
|
139
|
-
result
|
|
180
|
+
result,
|
|
140
181
|
durationMs: Date.now() - startMs,
|
|
141
182
|
skippedCount: skipped,
|
|
142
|
-
discoveredTotal: total
|
|
183
|
+
discoveredTotal: total,
|
|
184
|
+
...(decisions ? { decisions } : {})
|
|
143
185
|
});
|
|
144
186
|
return;
|
|
145
187
|
}
|
package/dist/scan-ui/launch.js
CHANGED
|
@@ -12,11 +12,15 @@ export function shouldLaunchScanTui(options) {
|
|
|
12
12
|
if (process.env.TERM === "dumb") {
|
|
13
13
|
return false;
|
|
14
14
|
}
|
|
15
|
+
if (!process.stdin.isTTY) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
15
18
|
return resolvePresentation().mode === "rich";
|
|
16
19
|
}
|
|
17
20
|
export async function launchScanTui(initialView = "results") {
|
|
18
21
|
const ci = process.env.CI;
|
|
19
|
-
|
|
22
|
+
const ciCleared = ci === "" || ci === "0" || ci === "false";
|
|
23
|
+
if (ciCleared) {
|
|
20
24
|
delete process.env.CI;
|
|
21
25
|
}
|
|
22
26
|
const [{ render }, react, app] = await Promise.all([
|
|
@@ -32,13 +36,12 @@ export async function launchScanTui(initialView = "results") {
|
|
|
32
36
|
: undefined;
|
|
33
37
|
enterTui();
|
|
34
38
|
const instance = render(react.default.createElement(app.App, { config, initialView, updateAvailable }), { exitOnCtrlC: true });
|
|
35
|
-
const clearStaleFrameOnResize = () => instance.clear();
|
|
36
|
-
process.stdout.on("resize", clearStaleFrameOnResize);
|
|
37
39
|
try {
|
|
38
40
|
await instance.waitUntilExit();
|
|
39
41
|
}
|
|
40
42
|
finally {
|
|
41
|
-
|
|
43
|
+
if (ciCleared)
|
|
44
|
+
process.env.CI = ci;
|
|
42
45
|
leaveTui();
|
|
43
46
|
}
|
|
44
47
|
}
|
package/dist/scan-ui/shims.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { authStatus } from "../auth/store.js";
|
|
2
|
+
import { formatAccountStatus } from "./format-helpers.js";
|
|
2
3
|
export function isLoggedIn() {
|
|
3
4
|
try {
|
|
4
5
|
return authStatus().authenticated;
|
|
@@ -7,14 +8,23 @@ export function isLoggedIn() {
|
|
|
7
8
|
return false;
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
|
-
export function
|
|
11
|
+
export function accountHeaderLine(usageTier, env = process.env) {
|
|
12
|
+
let loggedIn = false;
|
|
13
|
+
let name;
|
|
14
|
+
let storedTier;
|
|
11
15
|
try {
|
|
12
|
-
const status = authStatus();
|
|
13
|
-
|
|
16
|
+
const status = authStatus(env);
|
|
17
|
+
loggedIn = status.authenticated;
|
|
18
|
+
name = status.name ?? status.email;
|
|
19
|
+
storedTier = status.tier;
|
|
14
20
|
}
|
|
15
21
|
catch {
|
|
16
|
-
|
|
22
|
+
loggedIn = false;
|
|
17
23
|
}
|
|
24
|
+
return formatAccountStatus(usageTier ?? storedTier ?? "", loggedIn, name);
|
|
25
|
+
}
|
|
26
|
+
export function effectiveScanAction(raw, effective, mode) {
|
|
27
|
+
return mode === "strict" || effective === undefined ? raw : effective;
|
|
18
28
|
}
|
|
19
29
|
export function scanExitCode(action, mode) {
|
|
20
30
|
if (action === "block") {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
const LIFECYCLE_HOOKS = ["preinstall", "install", "postinstall"];
|
|
5
|
+
export function computeScriptsHash(scripts, hasGyp) {
|
|
6
|
+
const canonical = JSON.stringify({
|
|
7
|
+
preinstall: lifecycleCommand(scripts, "preinstall"),
|
|
8
|
+
install: lifecycleCommand(scripts, "install"),
|
|
9
|
+
postinstall: lifecycleCommand(scripts, "postinstall"),
|
|
10
|
+
gyp: hasGyp
|
|
11
|
+
});
|
|
12
|
+
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
|
|
13
|
+
}
|
|
14
|
+
export function detectScriptWanters(projectDir) {
|
|
15
|
+
const nodeModules = join(projectDir, "node_modules");
|
|
16
|
+
const fromLockfile = wantersFromHiddenLockfile(projectDir, nodeModules);
|
|
17
|
+
const wanters = fromLockfile ?? wantersFromWalk(nodeModules);
|
|
18
|
+
const byName = new Map();
|
|
19
|
+
for (const wanter of wanters) {
|
|
20
|
+
if (!byName.has(wanter.name)) {
|
|
21
|
+
byName.set(wanter.name, wanter);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
}
|
|
26
|
+
export function detectPnpmIgnoredBuilds(projectDir) {
|
|
27
|
+
const modulesYamlPath = join(projectDir, "node_modules", ".modules.yaml");
|
|
28
|
+
let content;
|
|
29
|
+
try {
|
|
30
|
+
content = readFileSync(modulesYamlPath, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const lines = content.split("\n");
|
|
36
|
+
const startIndex = lines.findIndex((line) => /^ignoredBuilds:/.test(line));
|
|
37
|
+
if (startIndex === -1) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const startLine = lines[startIndex] ?? "";
|
|
41
|
+
if (/\[\s*\]\s*$/.test(startLine)) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const names = [];
|
|
45
|
+
for (const line of lines.slice(startIndex + 1)) {
|
|
46
|
+
const match = /^\s+-\s+(.+?)\s*$/.exec(line);
|
|
47
|
+
if (!match || !match[1]) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
names.push(stripYamlQuotes(match[1]));
|
|
51
|
+
}
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
function stripYamlQuotes(value) {
|
|
55
|
+
if (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) {
|
|
56
|
+
return value.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function wantersFromHiddenLockfile(projectDir, nodeModules) {
|
|
61
|
+
const lockfilePath = join(nodeModules, ".package-lock.json");
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(readFileSync(lockfilePath, "utf8"));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.packages)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const wanters = [];
|
|
73
|
+
for (const [packagePath, entry] of Object.entries(parsed.packages)) {
|
|
74
|
+
if (!isPlainObject(entry) || entry.hasInstallScript !== true || !packagePath.startsWith("node_modules/")) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const manifestDir = resolve(projectDir, packagePath);
|
|
78
|
+
const containment = relative(resolve(nodeModules), manifestDir);
|
|
79
|
+
if (containment === "" || containment.startsWith("..") || isAbsolute(containment)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const wanter = wanterFromManifestDir(manifestDir);
|
|
83
|
+
if (wanter) {
|
|
84
|
+
wanters.push(wanter);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return wanters;
|
|
88
|
+
}
|
|
89
|
+
function wantersFromWalk(nodeModules) {
|
|
90
|
+
const wanters = [];
|
|
91
|
+
for (const dir of packageDirs(nodeModules)) {
|
|
92
|
+
const wanter = wanterFromManifestDir(dir);
|
|
93
|
+
if (wanter) {
|
|
94
|
+
wanters.push(wanter);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return wanters;
|
|
98
|
+
}
|
|
99
|
+
function packageDirs(nodeModules) {
|
|
100
|
+
const dirs = [];
|
|
101
|
+
for (const entry of safeReaddir(nodeModules)) {
|
|
102
|
+
if (entry.startsWith(".")) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (entry.startsWith("@")) {
|
|
106
|
+
for (const scoped of safeReaddir(join(nodeModules, entry))) {
|
|
107
|
+
if (!scoped.startsWith(".")) {
|
|
108
|
+
dirs.push(join(nodeModules, entry, scoped));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
dirs.push(join(nodeModules, entry));
|
|
114
|
+
}
|
|
115
|
+
return dirs;
|
|
116
|
+
}
|
|
117
|
+
function wanterFromManifestDir(dir) {
|
|
118
|
+
let manifest;
|
|
119
|
+
try {
|
|
120
|
+
manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (!isPlainObject(manifest) || typeof manifest.name !== "string" || manifest.name.length === 0) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const scripts = isPlainObject(manifest.scripts) ? manifest.scripts : {};
|
|
129
|
+
const hasGyp = existsSync(join(dir, "binding.gyp"));
|
|
130
|
+
const hooks = LIFECYCLE_HOOKS.filter((hook) => typeof lifecycleCommand(scripts, hook) === "string");
|
|
131
|
+
if (hasGyp) {
|
|
132
|
+
hooks.push("gyp");
|
|
133
|
+
}
|
|
134
|
+
if (hooks.length === 0) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
name: manifest.name,
|
|
139
|
+
version: typeof manifest.version === "string" ? manifest.version : "",
|
|
140
|
+
hooks,
|
|
141
|
+
scriptsHash: computeScriptsHash(scripts, hasGyp)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function lifecycleCommand(scripts, hook) {
|
|
145
|
+
const command = scripts[hook];
|
|
146
|
+
return typeof command === "string" && command.length > 0 ? command : null;
|
|
147
|
+
}
|
|
148
|
+
function safeReaddir(dir) {
|
|
149
|
+
try {
|
|
150
|
+
return readdirSync(dir);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isPlainObject(value) {
|
|
157
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
158
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
2
|
+
import { resolvePresentation } from "../presentation/mode.js";
|
|
3
|
+
import { createTheme } from "../presentation/theme.js";
|
|
4
|
+
import { loadDgFile, saveDgFile } from "../project/dgfile.js";
|
|
5
|
+
import { detectPnpmIgnoredBuilds, detectScriptWanters } from "./detect.js";
|
|
6
|
+
export function evaluateScriptGate(wanters, approvals) {
|
|
7
|
+
const approved = [];
|
|
8
|
+
const denied = [];
|
|
9
|
+
const pending = [];
|
|
10
|
+
const drifted = [];
|
|
11
|
+
for (const wanter of wanters) {
|
|
12
|
+
const entry = approvals[wanter.name];
|
|
13
|
+
if (!entry) {
|
|
14
|
+
pending.push(wanter);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (entry.scriptsHash !== wanter.scriptsHash) {
|
|
18
|
+
drifted.push({ wanter, priorHash: entry.scriptsHash });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (entry.decision === "allow") {
|
|
22
|
+
approved.push(wanter);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
denied.push(wanter);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { approved, denied, pending, drifted };
|
|
29
|
+
}
|
|
30
|
+
export function applyScriptDecisions(file, decisions, now) {
|
|
31
|
+
if (decisions.length === 0) {
|
|
32
|
+
return file;
|
|
33
|
+
}
|
|
34
|
+
const npm = { ...file.scriptApprovals.npm };
|
|
35
|
+
for (const input of decisions) {
|
|
36
|
+
npm[input.wanter.name] = {
|
|
37
|
+
decision: input.decision,
|
|
38
|
+
scriptsHash: input.wanter.scriptsHash,
|
|
39
|
+
hooks: input.wanter.hooks,
|
|
40
|
+
...(input.wanter.version ? { approvedVersion: input.wanter.version } : {}),
|
|
41
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
42
|
+
approvedAt: now.toISOString(),
|
|
43
|
+
provenance: input.provenance ?? "prompt"
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
...file,
|
|
48
|
+
scriptApprovals: { ...file.scriptApprovals, npm }
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function recordScriptObservations(options) {
|
|
52
|
+
const file = loadDgFile(options.projectDir);
|
|
53
|
+
if (!file.readable || (!file.exists && !options.createIfMissing) || options.wanters.length === 0) {
|
|
54
|
+
return { written: false, path: file.path };
|
|
55
|
+
}
|
|
56
|
+
const observed = { ...file.scriptApprovals.observed };
|
|
57
|
+
let changed = false;
|
|
58
|
+
for (const wanter of options.wanters) {
|
|
59
|
+
const existing = observed[wanter.name];
|
|
60
|
+
if (existing &&
|
|
61
|
+
existing.version === wanter.version &&
|
|
62
|
+
existing.scriptsHash === wanter.scriptsHash &&
|
|
63
|
+
sameHooks(existing.hooks, wanter.hooks)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
observed[wanter.name] = {
|
|
67
|
+
version: wanter.version,
|
|
68
|
+
hooks: wanter.hooks,
|
|
69
|
+
scriptsHash: wanter.scriptsHash,
|
|
70
|
+
firstSeen: existing ? existing.firstSeen : options.now.toISOString()
|
|
71
|
+
};
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
if (!changed) {
|
|
75
|
+
return { written: false, path: file.path };
|
|
76
|
+
}
|
|
77
|
+
saveDgFile({ ...file, scriptApprovals: { ...file.scriptApprovals, observed } });
|
|
78
|
+
return { written: true, path: file.path };
|
|
79
|
+
}
|
|
80
|
+
function sameHooks(a, b) {
|
|
81
|
+
return a.length === b.length && a.every((hook, index) => hook === b[index]);
|
|
82
|
+
}
|
|
83
|
+
export function hasExplicitScriptPreference(args, env) {
|
|
84
|
+
if (args.some((arg) => arg === "--ignore-scripts" || arg.startsWith("--ignore-scripts="))) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return env.npm_config_ignore_scripts !== undefined && env.npm_config_ignore_scripts !== "";
|
|
88
|
+
}
|
|
89
|
+
export function scriptGateInstallArgs(options) {
|
|
90
|
+
if (options.mode !== "enforce") {
|
|
91
|
+
return options.args;
|
|
92
|
+
}
|
|
93
|
+
if (options.manager !== "npm" && options.manager !== "yarn") {
|
|
94
|
+
return options.args;
|
|
95
|
+
}
|
|
96
|
+
if (hasExplicitScriptPreference(options.args, options.env)) {
|
|
97
|
+
return options.args;
|
|
98
|
+
}
|
|
99
|
+
return [...options.args, "--ignore-scripts"];
|
|
100
|
+
}
|
|
101
|
+
export function scriptGateChildEnv(options) {
|
|
102
|
+
if (options.mode !== "enforce" || (options.manager !== "npm" && options.manager !== "yarn")) {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
if (hasExplicitScriptPreference(options.args, options.env)) {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
return { npm_config_ignore_scripts: "true" };
|
|
109
|
+
}
|
|
110
|
+
const REPORTED_NAME_LIMIT = 6;
|
|
111
|
+
export function scriptGateReportLine(options) {
|
|
112
|
+
const theme = createTheme(resolvePresentation().color);
|
|
113
|
+
if (options.manager === "pnpm") {
|
|
114
|
+
const ignored = options.pnpmIgnoredBuilds ?? [];
|
|
115
|
+
if (ignored.length === 0) {
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
return `\n ${theme.paint("muted", `dg scripts: pnpm natively blocked install scripts for ${formatNames(ignored)} — review with 'pnpm approve-builds'`)}\n`;
|
|
119
|
+
}
|
|
120
|
+
const wanters = options.wanters ?? [];
|
|
121
|
+
if (wanters.length === 0) {
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
const names = wanters.map((wanter) => (wanter.version ? `${wanter.name}@${wanter.version}` : wanter.name));
|
|
125
|
+
const noun = wanters.length === 1 ? "package ran" : "packages ran";
|
|
126
|
+
return `\n ${theme.paint("muted", `dg scripts: ${wanters.length} ${noun} install scripts (${formatNames(names)}) — observed, not blocked · silence: dg config set scriptGate.mode off`)}\n`;
|
|
127
|
+
}
|
|
128
|
+
function formatNames(names) {
|
|
129
|
+
if (names.length <= REPORTED_NAME_LIMIT) {
|
|
130
|
+
return names.join(", ");
|
|
131
|
+
}
|
|
132
|
+
return `${names.slice(0, REPORTED_NAME_LIMIT).join(", ")}, +${names.length - REPORTED_NAME_LIMIT} more`;
|
|
133
|
+
}
|
|
134
|
+
const MUTATING_ACTIONS = {
|
|
135
|
+
npm: new Set(["install", "i", "ci", "add", "update", "dedupe"]),
|
|
136
|
+
yarn: new Set(["add", "install", "upgrade"]),
|
|
137
|
+
pnpm: new Set(["install", "i", "add", "update"])
|
|
138
|
+
};
|
|
139
|
+
export function runScriptGateAfterInstall(options) {
|
|
140
|
+
try {
|
|
141
|
+
const classification = options.classification;
|
|
142
|
+
if (classification.kind !== "protected" || classification.ecosystem !== "javascript") {
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
const mutatingActions = MUTATING_ACTIONS[classification.manager];
|
|
146
|
+
if (!mutatingActions || !mutatingActions.has(classification.action)) {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
const config = loadUserConfig(options.env ?? process.env);
|
|
150
|
+
if (config.scriptGate.mode === "off") {
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
154
|
+
if (classification.manager === "pnpm") {
|
|
155
|
+
return scriptGateReportLine({ manager: "pnpm", pnpmIgnoredBuilds: detectPnpmIgnoredBuilds(projectDir) });
|
|
156
|
+
}
|
|
157
|
+
const wanters = detectScriptWanters(projectDir);
|
|
158
|
+
recordScriptObservations({
|
|
159
|
+
projectDir,
|
|
160
|
+
wanters,
|
|
161
|
+
createIfMissing: config.scriptGate.observe,
|
|
162
|
+
now: options.now ?? new Date()
|
|
163
|
+
});
|
|
164
|
+
return scriptGateReportLine({ manager: classification.manager, wanters });
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// observing must never fail or block an install that already succeeded
|
|
168
|
+
return "";
|
|
169
|
+
}
|
|
170
|
+
}
|