@westbayberry/dg 2.1.0 → 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 (138) 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 +1 -1
  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 +15 -7
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +23 -9
  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 +6 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +1 -1
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/types.js +1 -0
  38. package/dist/commands/update.js +17 -8
  39. package/dist/commands/verify.js +8 -5
  40. package/dist/config/settings.js +14 -44
  41. package/dist/decisions/remember-prompt.js +5 -10
  42. package/dist/export-ui/ExportDialog.js +198 -0
  43. package/dist/install-ui/LiveInstall.js +2 -2
  44. package/dist/install-ui/block-render.js +1 -1
  45. package/dist/install-ui/prep-spinner.js +32 -0
  46. package/dist/launcher/agent-check.js +466 -0
  47. package/dist/launcher/agent-hook-exec.js +70 -0
  48. package/dist/launcher/agent-hook-io.js +31 -0
  49. package/dist/launcher/cargo-cache.js +40 -0
  50. package/dist/launcher/classify.js +17 -6
  51. package/dist/launcher/env.js +71 -24
  52. package/dist/launcher/install-preflight.js +51 -14
  53. package/dist/launcher/live-install.js +25 -5
  54. package/dist/launcher/output-redaction.js +7 -4
  55. package/dist/launcher/preflight-prompt.js +14 -1
  56. package/dist/launcher/run.js +89 -86
  57. package/dist/launcher/spawn-invocation.js +21 -0
  58. package/dist/policy/cooldown.js +18 -5
  59. package/dist/policy/evaluate.js +5 -6
  60. package/dist/policy/pypi-name.js +17 -0
  61. package/dist/presentation/mode.js +3 -2
  62. package/dist/presentation/package-page.js +9 -0
  63. package/dist/presentation/theme.js +7 -7
  64. package/dist/project/dgfile.js +145 -6
  65. package/dist/proxy/auth.js +42 -0
  66. package/dist/proxy/ca.js +29 -9
  67. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  68. package/dist/proxy/enforcement.js +55 -16
  69. package/dist/proxy/metadata-map.js +41 -3
  70. package/dist/proxy/preverified.js +55 -0
  71. package/dist/proxy/server.js +448 -49
  72. package/dist/proxy/worker.js +16 -1
  73. package/dist/publish-set/collect.js +1 -4
  74. package/dist/publish-set/npm.js +8 -5
  75. package/dist/publish-set/pack.js +9 -3
  76. package/dist/runtime/cli.js +0 -4
  77. package/dist/runtime/fatal.js +31 -0
  78. package/dist/runtime/first-run.js +12 -11
  79. package/dist/runtime/node-version.js +43 -6
  80. package/dist/runtime/nudges.js +35 -2
  81. package/dist/sbom/cyclonedx.js +211 -0
  82. package/dist/sbom-ui/SbomApp.js +158 -0
  83. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  84. package/dist/sbom-ui/components/SbomList.js +52 -0
  85. package/dist/sbom-ui/inventory.js +128 -0
  86. package/dist/sbom-ui/launch.js +51 -0
  87. package/dist/sbom-ui/run.js +55 -0
  88. package/dist/sbom-ui/store.js +26 -0
  89. package/dist/scan/collect.js +10 -6
  90. package/dist/scan/command.js +17 -10
  91. package/dist/scan/discovery.js +11 -2
  92. package/dist/scan/render.js +28 -4
  93. package/dist/scan/scanner-report.js +15 -9
  94. package/dist/scan/staged.js +2 -1
  95. package/dist/scan-ui/LegacyApp.js +9 -13
  96. package/dist/scan-ui/alt-screen.js +5 -8
  97. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  98. package/dist/scan-ui/components/ProgressBar.js +3 -14
  99. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  100. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  101. package/dist/scan-ui/components/SetupBanner.js +0 -6
  102. package/dist/scan-ui/format-helpers.js +61 -5
  103. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  104. package/dist/scan-ui/hooks/useScan.js +16 -2
  105. package/dist/scan-ui/launch.js +7 -4
  106. package/dist/scan-ui/shims.js +11 -4
  107. package/dist/scripts/detect.js +7 -2
  108. package/dist/scripts/gate.js +1 -1
  109. package/dist/service/state.js +27 -8
  110. package/dist/service/trust-refresh.js +92 -0
  111. package/dist/service/worker.js +23 -1
  112. package/dist/setup/activate-shell.js +28 -0
  113. package/dist/setup/git-hook.js +49 -4
  114. package/dist/setup/plan.js +73 -39
  115. package/dist/setup-ui/gate.js +39 -0
  116. package/dist/setup-ui/offer.js +42 -0
  117. package/dist/setup-ui/selector.js +27 -0
  118. package/dist/setup-ui/tasks.js +56 -0
  119. package/dist/setup-ui/wizard.js +225 -0
  120. package/dist/state/cooldown-held.js +66 -0
  121. package/dist/state/index.js +1 -0
  122. package/dist/state/locks.js +4 -2
  123. package/dist/state/store.js +2 -1
  124. package/dist/util/external-tool.js +25 -0
  125. package/dist/util/git.js +10 -3
  126. package/dist/util/report-writer.js +57 -0
  127. package/dist/verify/local.js +240 -42
  128. package/dist/verify/package-check.js +84 -28
  129. package/dist/verify/preflight.js +242 -49
  130. package/dist/verify/render.js +15 -1
  131. package/npm-shrinkwrap.json +2383 -0
  132. package/package.json +6 -8
  133. package/NOTICE +0 -5
  134. package/dist/commands/completion.js +0 -117
  135. package/dist/commands/explain.js +0 -236
  136. package/dist/commands/unavailable.js +0 -11
  137. package/dist/scripts/rebuild.js +0 -28
  138. package/dist/telemetry/events.js +0 -40
@@ -0,0 +1,225 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import { Spinner } from "../scan-ui/components/Spinner.js";
5
+ import { renderLogo } from "../scan-ui/logo.js";
6
+ import { enterTui, leaveTui } from "../scan-ui/alt-screen.js";
7
+ import { dgVersion } from "../commands/version.js";
8
+ import { authStatus, displayTier } from "../auth/store.js";
9
+ import { resolveWebBase } from "../auth/device-login.js";
10
+ import { useLogin } from "../auth/login-app.js";
11
+ import { collectAgentOffers, collectAgentSkips } from "../agents/registry.js";
12
+ import { markFirstRunShown } from "../runtime/first-run.js";
13
+ import { recordLoginNudge } from "../runtime/nudges.js";
14
+ import { resolvePresentation } from "../presentation/mode.js";
15
+ import { createTheme } from "../presentation/theme.js";
16
+ import { activateShell, activationOffer } from "../setup/activate-shell.js";
17
+ import { commitGuardOffer } from "../setup/git-hook.js";
18
+ import { activationCommand, tildifyPath } from "../setup/plan.js";
19
+ import { markSecurityNotesShown, markWizardSkipped, securityNotesShown } from "./gate.js";
20
+ import { Selector } from "./selector.js";
21
+ import { buildCommitGuardTask, buildWizardTasks } from "./tasks.js";
22
+ export function protectsSentence(agentLabels) {
23
+ if (agentLabels.length === 0) {
24
+ return "Protects npm and pip installs in your shell.";
25
+ }
26
+ if (agentLabels.length === 1) {
27
+ return `Protects npm and pip installs in your shell and in ${agentLabels[0]}.`;
28
+ }
29
+ return `Protects npm and pip installs in your shell and in ${agentLabels.slice(0, -1).join(", ")} and ${agentLabels[agentLabels.length - 1]}.`;
30
+ }
31
+ const Header = ({ version }) => (_jsxs(Box, { flexDirection: "column", children: [renderLogo("pass").map((line, i) => (_jsx(Text, { children: line }, i))), _jsx(Text, { children: " " }), _jsxs(Text, { bold: true, children: ["Dependency Guardian v", version] }), _jsx(Text, { children: " " })] }));
32
+ const LoginFlow = ({ webBase, env, onDone }) => {
33
+ const { state, openAndPoll } = useLogin(webBase, env);
34
+ useEffect(() => {
35
+ if (state.phase === "ready") {
36
+ openAndPoll();
37
+ }
38
+ }, [state.phase, openAndPoll]);
39
+ useInput((_input, key) => {
40
+ if (key.return && (state.phase === "success" || state.phase === "expired" || state.phase === "error")) {
41
+ onDone(state.phase === "success" ? state.email : "");
42
+ }
43
+ else if (key.escape) {
44
+ onDone("");
45
+ }
46
+ });
47
+ switch (state.phase) {
48
+ case "creating":
49
+ return _jsx(Spinner, { label: "Creating login session\u2026" });
50
+ case "ready":
51
+ case "waiting":
52
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Sign in at:" }), _jsx(Text, { color: "cyan", children: state.verifyUrl }), _jsx(Text, { children: " " }), _jsx(Spinner, { label: "Waiting for you to approve in the browser\u2026" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Esc to skip" })] }));
53
+ case "success":
54
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Logged in", state.email ? ` as ${state.email}` : "", state.plan ? ` (${displayTier(state.plan)} plan)` : ""] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Login successful. Press Enter to continue\u2026" })] }));
55
+ case "expired":
56
+ case "error":
57
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: state.phase === "expired" ? "That login link expired." : `Login failed: ${state.message}.` }), _jsx(Text, { dimColor: true, children: "You can run dg login later. Press Enter to continue\u2026" })] }));
58
+ }
59
+ };
60
+ function hyperlink(label, url) {
61
+ return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
62
+ }
63
+ const NotesStep = ({ termsUrl, privacyUrl, onAgree }) => {
64
+ useInput((_input, key) => {
65
+ if (key.return) {
66
+ onAgree();
67
+ }
68
+ });
69
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Security notes" }), _jsx(Text, { children: " " }), _jsx(Text, { children: "1. dg can make mistakes." }), _jsx(Text, { children: " A PASS verdict does not guarantee a package is safe. You are" }), _jsx(Text, { children: " responsible for what you install and should review new" }), _jsx(Text, { children: " dependencies." }), _jsx(Text, { children: " " }), _jsx(Text, { children: "2. By continuing you confirm you have read and understand the" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: hyperlink("Terms of Service", termsUrl) }), " and ", _jsx(Text, { color: "cyan", children: hyperlink("Privacy Policy", privacyUrl) }), "."] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Press Enter to agree" })] }));
70
+ };
71
+ export const SetupWizardApp = (props) => {
72
+ const { exit } = useApp();
73
+ const [step, setStep] = useState(props.loginNeeded ? "login" : props.notesNeeded ? "notes" : "choose");
74
+ const [results, setResults] = useState([]);
75
+ const [running, setRunning] = useState(null);
76
+ const [fatalDetail, setFatalDetail] = useState("");
77
+ const [email, setEmail] = useState(props.authedEmail);
78
+ const emailRef = useRef(props.authedEmail);
79
+ const applyStarted = useRef(false);
80
+ const Login = props.LoginFlowComponent ?? LoginFlow;
81
+ const afterLogin = () => {
82
+ setStep(props.notesNeeded ? "notes" : "choose");
83
+ };
84
+ useInput((_input, key) => {
85
+ if (key.return) {
86
+ exit();
87
+ }
88
+ }, { isActive: step === "done" });
89
+ useEffect(() => {
90
+ if (step !== "declined" && step !== "fatal") {
91
+ return undefined;
92
+ }
93
+ const timer = setTimeout(() => exit(), 30);
94
+ return () => clearTimeout(timer);
95
+ }, [step, exit]);
96
+ useEffect(() => {
97
+ if (step !== "applying" || applyStarted.current) {
98
+ return undefined;
99
+ }
100
+ applyStarted.current = true;
101
+ let cancelled = false;
102
+ void (async () => {
103
+ const completed = [];
104
+ for (const task of props.tasks) {
105
+ if (cancelled) {
106
+ return;
107
+ }
108
+ setRunning(task.label);
109
+ await new Promise((resolve) => setImmediate(resolve));
110
+ let result;
111
+ try {
112
+ result = await task.run();
113
+ }
114
+ catch (error) {
115
+ result = { ok: false, fatal: true, detail: error instanceof Error ? error.message : "unknown error" };
116
+ }
117
+ if (cancelled) {
118
+ return;
119
+ }
120
+ const line = { label: task.label, ok: result.ok, detail: result.detail };
121
+ completed.push(line);
122
+ setResults((prev) => [...prev, line]);
123
+ if (result.fatal) {
124
+ setRunning(null);
125
+ setFatalDetail(result.detail ?? "");
126
+ props.onOutcome({ kind: "fatal", detail: result.detail ?? "" });
127
+ setStep("fatal");
128
+ return;
129
+ }
130
+ }
131
+ setRunning(null);
132
+ props.onOutcome({ kind: "done", email: emailRef.current, results: completed });
133
+ setStep("done");
134
+ })();
135
+ return () => {
136
+ cancelled = true;
137
+ };
138
+ }, [step, props]);
139
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsx(Header, { version: props.version }), step === "login" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Connect your account" }), _jsx(Text, { children: " " }), _jsx(Selector, { options: [{ label: "Connect in your browser" }, { label: "Skip for now" }], onSelect: (index) => {
140
+ if (index === 0) {
141
+ setStep("login-flow");
142
+ }
143
+ else {
144
+ props.onLoginSkipped();
145
+ afterLogin();
146
+ }
147
+ }, onCancel: () => {
148
+ props.onLoginSkipped();
149
+ afterLogin();
150
+ } })] })), step === "login-flow" && (_jsx(Login, { webBase: props.webBase, env: props.env, onDone: (loggedInEmail) => {
151
+ if (loggedInEmail) {
152
+ setEmail(loggedInEmail);
153
+ emailRef.current = loggedInEmail;
154
+ }
155
+ else {
156
+ props.onLoginSkipped();
157
+ }
158
+ afterLogin();
159
+ } })), step === "notes" && (_jsx(NotesStep, { termsUrl: `${props.webBase}/terms`, privacyUrl: `${props.webBase}/privacy`, onAgree: () => {
160
+ props.onNotesAgreed();
161
+ setStep("choose");
162
+ } })), step === "choose" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Use recommended settings?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: props.sentence }), props.guardsRepo && _jsx(Text, { children: "Also scans this repo's commits before they land." }), props.skips.map((skip) => (_jsxs(Text, { dimColor: true, children: [skip.label, " found but skipped \u2014 ", skip.detail] }, skip.label))), _jsx(Text, { children: " " }), _jsx(Selector, { options: [{ label: "Yes, use recommended settings" }, { label: "No, maybe later with dg setup" }], onSelect: (index) => {
163
+ if (index === 0) {
164
+ setStep("applying");
165
+ }
166
+ else {
167
+ props.onOutcome({ kind: "declined" });
168
+ setStep("declined");
169
+ }
170
+ }, onCancel: () => {
171
+ props.onOutcome({ kind: "declined" });
172
+ setStep("declined");
173
+ } })] })), (step === "applying" || step === "done" || step === "fatal") && (_jsxs(Box, { flexDirection: "column", children: [results.map((line) => (_jsxs(Text, { color: line.ok ? "green" : "yellow", children: [line.ok ? "✓" : "✗", " ", line.label, !line.ok && line.detail ? ` — ${line.detail}` : ""] }, line.label))), running !== null && _jsx(Spinner, { label: running }), step === "fatal" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "red", children: fatalDetail })] })), step === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Setup complete." }), " ", "Activate now: ", _jsx(Text, { bold: true, children: props.activateHint })] }), email ? _jsxs(Text, { dimColor: true, children: ["Logged in as ", email] }) : null, _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Press Enter to continue\u2026" })] }))] })), step === "declined" && _jsx(Text, { dimColor: true, children: "Run dg setup any time to turn this on." })] }));
174
+ };
175
+ export async function runSetupWizard(plan, options = {}) {
176
+ const env = options.env ?? process.env;
177
+ const ci = process.env.CI;
178
+ if (ci === "" || ci === "0" || ci === "false") {
179
+ delete process.env.CI;
180
+ }
181
+ const agents = collectAgentOffers();
182
+ const repo = commitGuardOffer();
183
+ const auth = authStatus(env);
184
+ const state = { outcome: null };
185
+ const tasks = buildWizardTasks(plan, agents);
186
+ if (repo) {
187
+ tasks.push(buildCommitGuardTask(repo));
188
+ }
189
+ const { render } = await import("ink");
190
+ enterTui();
191
+ const instance = render(_jsx(SetupWizardApp, { version: dgVersion(), notesNeeded: !securityNotesShown(env), loginNeeded: !auth.authenticated, authedEmail: auth.email ?? "", sentence: protectsSentence(agents.map((offer) => offer.label)), guardsRepo: repo !== null, skips: collectAgentSkips(), tasks: tasks, webBase: resolveWebBase(env), env: env, activateHint: activationCommand(plan.shell, tildifyPath(plan.rcPath)), onNotesAgreed: () => markSecurityNotesShown(env), onLoginSkipped: () => recordLoginNudge(env), onOutcome: (outcome) => {
192
+ state.outcome = outcome;
193
+ } }), { exitOnCtrlC: true });
194
+ await instance.waitUntilExit();
195
+ leaveTui();
196
+ const theme = createTheme(resolvePresentation().color);
197
+ const accent = (text) => theme.paint("accent", text);
198
+ const muted = (text) => theme.paint("muted", text);
199
+ const outcome = state.outcome;
200
+ if (outcome === null) {
201
+ return { exitCode: 0, stdout: "", stderr: "" };
202
+ }
203
+ if (outcome.kind === "declined") {
204
+ markWizardSkipped(env);
205
+ markFirstRunShown(env);
206
+ return { exitCode: 0, stdout: "", stderr: ` ${muted("Run")} ${accent("dg setup")} ${muted("any time to turn this on.")}\n` };
207
+ }
208
+ if (outcome.kind === "fatal") {
209
+ return { exitCode: 1, stdout: "", stderr: ` ${outcome.detail}\n` };
210
+ }
211
+ markFirstRunShown(env);
212
+ const lines = outcome.results.map((line) => line.ok
213
+ ? ` ${theme.paint("pass", `✓ ${line.label}`)}`
214
+ : ` ${theme.paint("warn", `✗ ${line.label}`)}${line.detail ? ` ${muted(`— ${line.detail}`)}` : ""}`);
215
+ lines.push(` ${theme.paint("pass", "✓ dg setup complete — installs are protected in new terminals.")}`);
216
+ if (outcome.email) {
217
+ lines.push(` ${muted(`Logged in as ${outcome.email}`)}`);
218
+ }
219
+ if (options.autoActivate && activationOffer() === "prompt") {
220
+ process.stderr.write(`${lines.join("\n")}\n ${muted("Starting a protected shell — type")} ${accent("exit")} ${muted("to return to your previous one.")}\n`);
221
+ return { exitCode: activateShell(), stdout: "", stderr: "" };
222
+ }
223
+ lines.push(` ${muted("Activate now:")} ${accent(activationCommand(plan.shell, tildifyPath(plan.rcPath)))}`);
224
+ return { exitCode: 0, stdout: "", stderr: `${lines.join("\n")}\n` };
225
+ }
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeJsonAtomic } from "../util/json-file.js";
4
+ import { resolveDgPaths } from "./paths.js";
5
+ export const HELD_PACKAGES_CAP = 500;
6
+ export const UNKNOWN_ELIGIBILITY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
+ export function heldPackagesPath(env = process.env) {
8
+ return join(resolveDgPaths(env).stateDir, "cooldown-held.json");
9
+ }
10
+ function heldKey(entry) {
11
+ return `${entry.ecosystem}:${entry.name}@${entry.version}`;
12
+ }
13
+ function readDocument(path) {
14
+ let raw;
15
+ try {
16
+ raw = readFileSync(path, "utf8");
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ if (!parsed || parsed.schemaVersion !== 1 || !Array.isArray(parsed.entries)) {
24
+ return [];
25
+ }
26
+ return parsed.entries.filter((entry) => !!entry
27
+ && typeof entry.ecosystem === "string"
28
+ && typeof entry.name === "string"
29
+ && typeof entry.version === "string"
30
+ && typeof entry.requiredDays === "number"
31
+ && typeof entry.firstSeenAt === "string"
32
+ && typeof entry.lastSeenAt === "string");
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ function prune(entries, now) {
39
+ return entries.filter((entry) => {
40
+ const eligible = entry.eligibleAt ? Date.parse(entry.eligibleAt) : Number.NaN;
41
+ if (Number.isFinite(eligible)) {
42
+ return eligible > now.getTime();
43
+ }
44
+ const lastSeen = Date.parse(entry.lastSeenAt);
45
+ return Number.isFinite(lastSeen) && now.getTime() - lastSeen <= UNKNOWN_ELIGIBILITY_TTL_MS;
46
+ });
47
+ }
48
+ export function readHeldPackages(env = process.env, now = new Date()) {
49
+ return prune(readDocument(heldPackagesPath(env)), now);
50
+ }
51
+ export function recordHeldPackage(entry, env = process.env, now = new Date()) {
52
+ const path = heldPackagesPath(env);
53
+ const nowIso = now.toISOString();
54
+ const existing = prune(readDocument(path), now);
55
+ const key = heldKey(entry);
56
+ const previous = existing.find((candidate) => heldKey(candidate) === key);
57
+ const updated = {
58
+ ...entry,
59
+ firstSeenAt: previous?.firstSeenAt ?? nowIso,
60
+ lastSeenAt: nowIso
61
+ };
62
+ const merged = [...existing.filter((candidate) => heldKey(candidate) !== key), updated]
63
+ .sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
64
+ .slice(0, HELD_PACKAGES_CAP);
65
+ writeJsonAtomic(path, { schemaVersion: 1, entries: merged });
66
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./cleanup-registry.js";
2
+ export * from "./cooldown-held.js";
2
3
  export * from "./locks.js";
3
4
  export * from "./paths.js";
4
5
  export * from "./sessions.js";
@@ -17,7 +17,8 @@ export class LockBusyError extends Error {
17
17
  export async function acquireLock(paths, name, options = {}) {
18
18
  assertLockName(name);
19
19
  await mkdir(paths.locksDir, {
20
- recursive: true
20
+ recursive: true,
21
+ mode: 0o700
21
22
  });
22
23
  const path = join(paths.locksDir, `${name}.lock`);
23
24
  await removeStaleLock(path, options);
@@ -54,7 +55,8 @@ export async function acquireLock(paths, name, options = {}) {
54
55
  export function acquireLockSync(paths, name, options = {}) {
55
56
  assertLockName(name);
56
57
  mkdirSync(paths.locksDir, {
57
- recursive: true
58
+ recursive: true,
59
+ mode: 0o700
58
60
  });
59
61
  const path = join(paths.locksDir, `${name}.lock`);
60
62
  removeStaleLockSync(path, options);
@@ -26,7 +26,8 @@ export async function readJsonFile(path, fallback) {
26
26
  }
27
27
  export async function writeJsonFileAtomic(path, value) {
28
28
  await mkdir(dirname(path), {
29
- recursive: true
29
+ recursive: true,
30
+ mode: 0o700
30
31
  });
31
32
  const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
32
33
  const payload = `${JSON.stringify(value, null, 2)}\n`;
@@ -0,0 +1,25 @@
1
+ import { resolveRealBinary } from "../launcher/resolve-real-binary.js";
2
+ import { resolveSpawnInvocation } from "../launcher/spawn-invocation.js";
3
+ const toolPathCache = new Map();
4
+ // Windows resolves bare command names against the child cwd before PATH,
5
+ // and dg routinely runs with cwd inside untrusted trees; external tools
6
+ // must always spawn from an absolute PATH-resolved location. Callers may
7
+ // pass a minimal child env; resolution then falls back to the parent PATH.
8
+ export function resolveToolPath(name, env = process.env) {
9
+ const searchEnv = env.PATH ? env : { ...env, PATH: process.env.PATH };
10
+ const key = `${name} ${searchEnv.PATH ?? ""}`;
11
+ const cached = toolPathCache.get(key);
12
+ if (cached !== undefined) {
13
+ return cached;
14
+ }
15
+ const path = resolveRealBinary({ name, env: searchEnv }).path;
16
+ toolPathCache.set(key, path);
17
+ return path;
18
+ }
19
+ export function toolInvocation(name, args, env = process.env) {
20
+ const path = resolveToolPath(name, env);
21
+ if (!path) {
22
+ return null;
23
+ }
24
+ return resolveSpawnInvocation(path, args);
25
+ }
package/dist/util/git.js CHANGED
@@ -1,11 +1,18 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { toolInvocation } from "./external-tool.js";
2
3
  const DEFAULT_MAX_BUFFER = 256 * 1024 * 1024;
3
4
  export function gitSync(args, options) {
4
- const result = spawnSync("git", [...args], {
5
+ const env = options.env ?? process.env;
6
+ const invocation = toolInvocation("git", args, env);
7
+ if (!invocation) {
8
+ return { ok: false, code: null, stdout: "", stderr: "git executable not found on PATH" };
9
+ }
10
+ const result = spawnSync(invocation.command, [...invocation.args], {
5
11
  cwd: options.cwd,
6
- env: options.env ?? process.env,
12
+ env,
7
13
  encoding: "utf8",
8
- maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER
14
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
15
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
9
16
  });
10
17
  return {
11
18
  ok: result.status === 0,
@@ -0,0 +1,57 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ export function writeReportAtomic(outputPath, contents) {
6
+ const directory = dirname(outputPath);
7
+ mkdirSync(directory, { recursive: true });
8
+ const temporaryPath = `${outputPath}.${randomUUID()}.tmp`;
9
+ try {
10
+ writeFileSync(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
11
+ renameSync(temporaryPath, outputPath);
12
+ }
13
+ catch (error) {
14
+ rmSync(temporaryPath, { force: true });
15
+ throw error;
16
+ }
17
+ }
18
+ export function resolveExportPath(input, cwd) {
19
+ const trimmed = input.trim();
20
+ if (trimmed === "~" || trimmed.startsWith("~/")) {
21
+ return resolve(homedir(), trimmed.slice(2));
22
+ }
23
+ return resolve(cwd, trimmed);
24
+ }
25
+ function isDirectory(path) {
26
+ try {
27
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ export function userHomeDir(env = process.env) {
34
+ return env.HOME && isAbsolute(env.HOME) ? env.HOME : homedir();
35
+ }
36
+ export function exportDestinations(cwd, env = process.env) {
37
+ const home = userHomeDir(env);
38
+ const candidates = [{ label: "This folder", dir: cwd }];
39
+ for (const name of ["Downloads", "Desktop"]) {
40
+ const dir = join(home, name);
41
+ if (isDirectory(dir)) {
42
+ candidates.push({ label: name, dir });
43
+ }
44
+ }
45
+ candidates.push({ label: "Home", dir: home });
46
+ const seen = new Set();
47
+ const out = [];
48
+ for (const candidate of candidates) {
49
+ const key = resolve(candidate.dir);
50
+ if (seen.has(key)) {
51
+ continue;
52
+ }
53
+ seen.add(key);
54
+ out.push(candidate);
55
+ }
56
+ return out;
57
+ }