@westbayberry/dg 1.0.53 → 1.0.56

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 (64) hide show
  1. package/README.md +5 -1
  2. package/dist/index.mjs +249 -114
  3. package/dist/packages/cli/src/alt-screen.js +36 -0
  4. package/dist/packages/cli/src/api.js +322 -0
  5. package/dist/packages/cli/src/auth.js +218 -0
  6. package/dist/packages/cli/src/bin.js +386 -0
  7. package/dist/packages/cli/src/config.js +228 -0
  8. package/dist/packages/cli/src/discover.js +126 -0
  9. package/dist/packages/cli/src/first-run.js +135 -0
  10. package/dist/packages/cli/src/hook.js +360 -0
  11. package/dist/packages/cli/src/lockfile.js +303 -0
  12. package/dist/packages/cli/src/npm-wrapper.js +218 -0
  13. package/dist/packages/cli/src/pip-wrapper.js +273 -0
  14. package/dist/packages/cli/src/sanitize.js +38 -0
  15. package/dist/packages/cli/src/scan-core.js +144 -0
  16. package/dist/packages/cli/src/setup-status.js +46 -0
  17. package/dist/packages/cli/src/static-output.js +625 -0
  18. package/dist/packages/cli/src/telemetry.js +141 -0
  19. package/dist/packages/cli/src/ui/App.js +137 -0
  20. package/dist/packages/cli/src/ui/InitApp.js +391 -0
  21. package/dist/packages/cli/src/ui/LoginApp.js +51 -0
  22. package/dist/packages/cli/src/ui/NpmWrapperApp.js +73 -0
  23. package/dist/packages/cli/src/ui/PipWrapperApp.js +72 -0
  24. package/dist/packages/cli/src/ui/components/ConfirmPrompt.js +24 -0
  25. package/dist/packages/cli/src/ui/components/DemoScanAnimation.js +26 -0
  26. package/dist/packages/cli/src/ui/components/DurationLine.js +7 -0
  27. package/dist/packages/cli/src/ui/components/ErrorView.js +30 -0
  28. package/dist/packages/cli/src/ui/components/FileSavePrompt.js +210 -0
  29. package/dist/packages/cli/src/ui/components/InteractiveResultsView.js +557 -0
  30. package/dist/packages/cli/src/ui/components/Mascot.js +33 -0
  31. package/dist/packages/cli/src/ui/components/ProgressBar.js +51 -0
  32. package/dist/packages/cli/src/ui/components/ProgressDots.js +35 -0
  33. package/dist/packages/cli/src/ui/components/ProjectSelector.js +60 -0
  34. package/dist/packages/cli/src/ui/components/ResultsView.js +105 -0
  35. package/dist/packages/cli/src/ui/components/ScanResultCard.js +54 -0
  36. package/dist/packages/cli/src/ui/components/ScoreHeader.js +142 -0
  37. package/dist/packages/cli/src/ui/components/SetupBanner.js +17 -0
  38. package/dist/packages/cli/src/ui/components/Spinner.js +11 -0
  39. package/dist/packages/cli/src/ui/hooks/useExpandAnimation.js +44 -0
  40. package/dist/packages/cli/src/ui/hooks/useInit.js +341 -0
  41. package/dist/packages/cli/src/ui/hooks/useLogin.js +121 -0
  42. package/dist/packages/cli/src/ui/hooks/useNpmWrapper.js +192 -0
  43. package/dist/packages/cli/src/ui/hooks/usePipWrapper.js +195 -0
  44. package/dist/packages/cli/src/ui/hooks/useScan.js +202 -0
  45. package/dist/packages/cli/src/ui/hooks/useTerminalSize.js +29 -0
  46. package/dist/packages/cli/src/update-check.js +152 -0
  47. package/dist/packages/cli/src/wizard-demo-data.js +63 -0
  48. package/dist/src/ecosystem.js +2 -0
  49. package/dist/src/lockfile/diff.js +38 -0
  50. package/dist/src/lockfile/parse_package_json.js +41 -0
  51. package/dist/src/lockfile/parse_package_lock.js +55 -0
  52. package/dist/src/lockfile/parse_pipfile_lock.js +69 -0
  53. package/dist/src/lockfile/parse_pnpm_lock.js +62 -0
  54. package/dist/src/lockfile/parse_poetry_lock.js +71 -0
  55. package/dist/src/lockfile/parse_requirements.js +83 -0
  56. package/dist/src/lockfile/parse_yarn_lock.js +66 -0
  57. package/dist/src/logger.js +21 -0
  58. package/dist/src/npm/h2pool.js +161 -0
  59. package/dist/src/npm/registry.js +299 -0
  60. package/dist/src/npm/tarball.js +274 -0
  61. package/dist/src/pypi/registry.js +299 -0
  62. package/dist/src/pypi/tarball.js +361 -0
  63. package/dist/src/types.js +2 -0
  64. package/package.json +6 -3
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ // Alternate screen buffer helpers.
3
+ //
4
+ // Entered BEFORE Ink's render() is called — not from inside a React useEffect
5
+ // — so Ink's very first paint lands in the alt buffer and its internal diff
6
+ // tracker matches the actual terminal state from frame 1.
7
+ //
8
+ // If the enter happens inside a useEffect, Ink has already painted the first
9
+ // frame to the main buffer; flipping to a fresh-empty alt buffer after the
10
+ // fact leaves Ink's diff tracker stale, and the user stares at a blank screen
11
+ // until some state change (usually a keypress via useInput) forces Ink to
12
+ // re-emit a full redraw. That was the `dg kitty` "blank until keypress" bug.
13
+ //
14
+ // These helpers are idempotent and safe to call in either order multiple
15
+ // times. The SIGINT/SIGTERM handlers in bin.ts call leaveAltScreen() so
16
+ // Ctrl+C from inside the wizard restores the user's terminal cleanly.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.enterAltScreen = enterAltScreen;
19
+ exports.leaveAltScreen = leaveAltScreen;
20
+ exports.altScreenActive = altScreenActive;
21
+ let active = false;
22
+ function enterAltScreen() {
23
+ if (!process.stdout.isTTY || active)
24
+ return;
25
+ process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
26
+ active = true;
27
+ }
28
+ function leaveAltScreen() {
29
+ if (!active)
30
+ return;
31
+ process.stdout.write("\x1b[?1049l\x1b[?25h");
32
+ active = false;
33
+ }
34
+ function altScreenActive() {
35
+ return active;
36
+ }
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClientOutdatedError = exports.TrialExhaustedError = exports.APIError = void 0;
4
+ exports.callAnalyzeAPI = callAnalyzeAPI;
5
+ exports.callPyPIAnalyzeAPI = callPyPIAnalyzeAPI;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const config_1 = require("./config");
8
+ const sanitize_1 = require("./sanitize");
9
+ const update_check_1 = require("./update-check");
10
+ class APIError extends Error {
11
+ constructor(message, statusCode, body) {
12
+ super(message);
13
+ this.statusCode = statusCode;
14
+ this.body = body;
15
+ this.name = "APIError";
16
+ }
17
+ }
18
+ exports.APIError = APIError;
19
+ class TrialExhaustedError extends Error {
20
+ constructor(scansUsed, maxScans) {
21
+ super("Free trial scans used up. Run `dg login` to create a free account and continue scanning.");
22
+ this.scansUsed = scansUsed;
23
+ this.maxScans = maxScans;
24
+ this.name = "TrialExhaustedError";
25
+ }
26
+ }
27
+ exports.TrialExhaustedError = TrialExhaustedError;
28
+ class ClientOutdatedError extends Error {
29
+ constructor(minVersion, currentVersion, securityRelease) {
30
+ super(securityRelease
31
+ ? `Your CLI (${currentVersion}) is older than the minimum required version (${minVersion}) for this security release.`
32
+ : `Your CLI (${currentVersion}) is older than the minimum required version (${minVersion}).`);
33
+ this.minVersion = minVersion;
34
+ this.currentVersion = currentVersion;
35
+ this.securityRelease = securityRelease;
36
+ this.name = "ClientOutdatedError";
37
+ }
38
+ }
39
+ exports.ClientOutdatedError = ClientOutdatedError;
40
+ const BATCH_SIZE = 200;
41
+ const ANON_BATCH_SIZE = 200; // Same batch size for all users — monthly limit gates abuse
42
+ const MAX_RETRIES = 2;
43
+ const RETRY_DELAY_MS = 5000;
44
+ // Single-threaded Node: callAnalyzeAPI and callPyPIAnalyzeAPI don't overlap.
45
+ // Set at each public entry point; all batches within one scan share the same ID
46
+ // so the server counts it as 1 scan.
47
+ let _currentScanId = "";
48
+ function buildHeaders(config) {
49
+ const headers = {
50
+ "Content-Type": "application/json",
51
+ "User-Agent": `dependency-guardian-cli/${(0, config_1.getVersion)()}`,
52
+ };
53
+ if (config.apiKey) {
54
+ headers["Authorization"] = `Bearer ${config.apiKey}`;
55
+ }
56
+ else {
57
+ headers["X-Device-Id"] = config.deviceId;
58
+ }
59
+ if (_currentScanId) {
60
+ headers["X-Scan-Id"] = _currentScanId;
61
+ }
62
+ return headers;
63
+ }
64
+ // Check the optional server-emitted version floor. If the server's minimum is
65
+ // newer than what's installed, throw — the bin.ts main-catch prints a user-facing
66
+ // "Run `dg update`" message. Absence of the field means "no floor" (graceful
67
+ // degradation for older API deploys). Server may not emit minClientVersion on older
68
+ // deploys — field was introduced alongside CLI 1.0.41 + the paired API build.
69
+ // Delete this helper's `raw.minClientVersion &&` guard once all API replicas confirm
70
+ // they emit the field.
71
+ function checkVersionFloor(raw) {
72
+ const r = raw;
73
+ if (r.minClientVersion && (0, update_check_1.isNewer)(r.minClientVersion, (0, config_1.getVersion)())) {
74
+ throw new ClientOutdatedError(r.minClientVersion, (0, config_1.getVersion)(), r.securityRelease === true);
75
+ }
76
+ }
77
+ async function handleTrialExhausted(response) {
78
+ if (response.status === 403) {
79
+ const body = await response.json().catch(() => ({}));
80
+ if (body && body.trialExhausted) {
81
+ const b = body;
82
+ throw new TrialExhaustedError(b.scansUsed, b.maxScans);
83
+ }
84
+ throw new APIError("Forbidden", 403, JSON.stringify(body));
85
+ }
86
+ }
87
+ async function callAnalyzeAPI(packages, config, onProgress) {
88
+ const batchSize = config.apiKey ? BATCH_SIZE : ANON_BATCH_SIZE;
89
+ // One scan ID for all batches — server counts this as 1 scan, not N
90
+ _currentScanId = (0, node_crypto_1.randomUUID)();
91
+ if (packages.length <= batchSize) {
92
+ if (onProgress)
93
+ onProgress(0, packages.length, packages.map(p => p.name));
94
+ const result = await callBatchWithRetry(packages, config);
95
+ if (onProgress)
96
+ onProgress(packages.length, packages.length, packages.map(p => p.name));
97
+ return result;
98
+ }
99
+ const batches = [];
100
+ for (let i = 0; i < packages.length; i += batchSize) {
101
+ batches.push(packages.slice(i, i + batchSize));
102
+ }
103
+ // Process batches sequentially — the engine handles internal concurrency
104
+ const results = [];
105
+ let completed = 0;
106
+ const tTotal = Date.now();
107
+ // Fire an initial progress tick BEFORE the first batch so callers (e.g. the
108
+ // wizard's ProgressBar) can render immediately at 0/total instead of waiting
109
+ // up to 20s for the first batch to complete. Empty currentBatch matches the
110
+ // "last-completed names" convention used below (nothing has completed yet).
111
+ if (onProgress)
112
+ onProgress(0, packages.length, []);
113
+ for (let i = 0; i < batches.length; i++) {
114
+ const batch = batches[i];
115
+ const tBatch = Date.now();
116
+ const result = await callBatchWithRetry(batch, config);
117
+ if (process.env.DG_PERF)
118
+ console.error(`[CLI-PERF] batch ${i + 1}/${batches.length}: ${batch.length} packages → ${Date.now() - tBatch}ms`);
119
+ completed += batch.length;
120
+ if (onProgress) {
121
+ // Pass the JUST-COMPLETED batch names as currentBatch so callers can
122
+ // show "last completed" as the progress label — matches useScan's
123
+ // convention of `packages.slice(done - 15, done)`.
124
+ onProgress(completed, packages.length, batch.map(p => p.name));
125
+ }
126
+ results.push(result);
127
+ }
128
+ if (process.env.DG_PERF)
129
+ console.error(`[CLI-PERF] total: ${packages.length} packages → ${Date.now() - tTotal}ms`);
130
+ return mergeResponses(results);
131
+ }
132
+ async function callBatchWithRetry(packages, config) {
133
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
134
+ try {
135
+ return await callAnalyzeBatch(packages, config);
136
+ }
137
+ catch (error) {
138
+ const is504 = error instanceof APIError && error.statusCode === 504;
139
+ const isTimeout = error instanceof APIError && error.statusCode === 408;
140
+ const is429 = error instanceof APIError && error.statusCode === 429;
141
+ if ((is504 || isTimeout) && attempt < MAX_RETRIES) {
142
+ // Server is still working — wait, then retry.
143
+ // Packages that finished before the timeout are now cached.
144
+ const delay = RETRY_DELAY_MS * (attempt + 1);
145
+ process.stderr.write(` Batch timed out, retrying in ${delay / 1000}s (attempt ${attempt + 2}/${MAX_RETRIES + 1})...\n`);
146
+ await new Promise((r) => setTimeout(r, delay));
147
+ continue;
148
+ }
149
+ if (is429 && attempt < MAX_RETRIES) {
150
+ // Honor Retry-After. The header is carried on APIError.body for 429.
151
+ // Clamp to [1, 60] seconds — refuse absurd values a misconfigured upstream might send.
152
+ const header = error.body;
153
+ const parsed = Number(header);
154
+ const seconds = Number.isFinite(parsed) && parsed > 0 ? Math.min(60, Math.max(1, Math.ceil(parsed))) : 5;
155
+ process.stderr.write(` Rate limited, retrying in ${seconds}s (attempt ${attempt + 2}/${MAX_RETRIES + 1})...\n`);
156
+ await new Promise((r) => setTimeout(r, seconds * 1000));
157
+ continue;
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+ // Unreachable, but TypeScript needs it
163
+ throw new Error("Exhausted retries");
164
+ }
165
+ function mergeResponses(results) {
166
+ const allPackages = results.flatMap((r) => r.packages);
167
+ const maxScore = Math.max(0, ...allPackages.map((p) => p.score));
168
+ const action = maxScore >= 70
169
+ ? "block"
170
+ : maxScore >= 60
171
+ ? "warn"
172
+ : "pass";
173
+ const safeVersions = {};
174
+ for (const r of results) {
175
+ Object.assign(safeVersions, r.safeVersions);
176
+ }
177
+ return {
178
+ score: maxScore,
179
+ action: action,
180
+ packages: allPackages,
181
+ safeVersions,
182
+ // Sum, not max — batches run sequentially, total wall-clock is the sum
183
+ durationMs: results.reduce((s, r) => s + (r.durationMs || 0), 0),
184
+ };
185
+ }
186
+ function formatApiErrorMessage(status, rawBody, config) {
187
+ const truncated = rawBody.length > 200 ? rawBody.slice(0, 200) + "…" : rawBody;
188
+ if (config.debug) {
189
+ return `API returned ${status}: ${truncated}`;
190
+ }
191
+ if (status >= 500) {
192
+ return `API error (HTTP ${status}). Retry or contact support.`;
193
+ }
194
+ return `API returned ${status}: ${truncated}`;
195
+ }
196
+ async function callAnalyzeBatch(packages, config) {
197
+ const url = `${config.apiUrl}/v1/analyze`;
198
+ const payload = {
199
+ packages: packages.map((p) => ({
200
+ name: p.name,
201
+ version: p.version,
202
+ previousVersion: p.previousVersion,
203
+ isNew: p.isNew,
204
+ })),
205
+ };
206
+ const controller = new AbortController();
207
+ const timeoutId = setTimeout(() => controller.abort(), 180000);
208
+ let response;
209
+ try {
210
+ response = await fetch(url, {
211
+ method: "POST",
212
+ headers: buildHeaders(config),
213
+ body: JSON.stringify(payload),
214
+ signal: controller.signal,
215
+ });
216
+ }
217
+ catch (error) {
218
+ clearTimeout(timeoutId);
219
+ if (error instanceof Error && error.name === "AbortError") {
220
+ throw new APIError("Request timed out after 180s. Try scanning fewer packages.", 408, "");
221
+ }
222
+ // Node.js fetch wraps the real error in .cause
223
+ const cause = error?.cause;
224
+ const causeMsg = cause?.message || cause?.code || "";
225
+ const errMsg = error instanceof Error ? error.message : String(error);
226
+ const detail = causeMsg ? `: ${causeMsg}` : errMsg !== "fetch failed" ? `: ${errMsg}` : "";
227
+ throw new Error(`API unreachable${detail}`);
228
+ }
229
+ clearTimeout(timeoutId);
230
+ await handleTrialExhausted(response);
231
+ if (response.status === 401) {
232
+ throw new APIError("Invalid API key. Run `dg logout` then `dg login` to re-authenticate.", 401, "");
233
+ }
234
+ if (response.status === 429) {
235
+ // Carry the Retry-After header on the error so callBatchWithRetry can honor it
236
+ const retryAfter = response.headers.get("retry-after") ?? "";
237
+ throw new APIError("Rate limit exceeded. Upgrade your plan at https://westbayberry.com/pricing", 429, retryAfter);
238
+ }
239
+ if (!response.ok) {
240
+ const body = await response.text();
241
+ throw new APIError(formatApiErrorMessage(response.status, body, config), response.status, body);
242
+ }
243
+ const raw = (await response.json());
244
+ if (!raw || typeof raw.score !== "number" || !Array.isArray(raw.packages)) {
245
+ throw new APIError("Invalid API response format", 0, "");
246
+ }
247
+ checkVersionFloor(raw);
248
+ return (0, sanitize_1.sanitizeResponse)(raw);
249
+ }
250
+ async function callPyPIAnalyzeAPI(packages, config, onProgress) {
251
+ const batchSize = config.apiKey ? BATCH_SIZE : ANON_BATCH_SIZE;
252
+ _currentScanId = (0, node_crypto_1.randomUUID)();
253
+ if (packages.length <= batchSize) {
254
+ return callPyPIBatch(packages, config);
255
+ }
256
+ const batches = [];
257
+ for (let i = 0; i < packages.length; i += batchSize) {
258
+ batches.push(packages.slice(i, i + batchSize));
259
+ }
260
+ const results = [];
261
+ let completed = 0;
262
+ for (const batch of batches) {
263
+ const result = await callPyPIBatch(batch, config);
264
+ completed += batch.length;
265
+ if (onProgress)
266
+ onProgress(completed, packages.length);
267
+ results.push(result);
268
+ }
269
+ return mergeResponses(results);
270
+ }
271
+ async function callPyPIBatch(packages, config) {
272
+ const url = `${config.apiUrl}/v1/pypi/analyze`;
273
+ const payload = {
274
+ packages: packages.map((p) => ({
275
+ name: p.name,
276
+ version: p.version,
277
+ previousVersion: p.previousVersion ?? null,
278
+ isNew: p.isNew ?? true,
279
+ })),
280
+ };
281
+ const controller = new AbortController();
282
+ const timeoutId = setTimeout(() => controller.abort(), 180000);
283
+ let response;
284
+ try {
285
+ response = await fetch(url, {
286
+ method: "POST",
287
+ headers: buildHeaders(config),
288
+ body: JSON.stringify(payload),
289
+ signal: controller.signal,
290
+ });
291
+ }
292
+ catch (error) {
293
+ clearTimeout(timeoutId);
294
+ if (error instanceof Error && error.name === "AbortError") {
295
+ throw new APIError("Request timed out after 120s.", 408, "");
296
+ }
297
+ const cause = error instanceof Error
298
+ ? error.cause
299
+ : undefined;
300
+ const detail = cause ? `: ${cause.message || String(cause)}` : "";
301
+ throw new Error(`fetch failed${detail}`);
302
+ }
303
+ clearTimeout(timeoutId);
304
+ await handleTrialExhausted(response);
305
+ if (response.status === 401) {
306
+ throw new APIError("Invalid API key. Run `dg logout` then `dg login` to re-authenticate.", 401, "");
307
+ }
308
+ if (response.status === 429) {
309
+ const retryAfter = response.headers.get("retry-after") ?? "";
310
+ throw new APIError("Rate limit exceeded. Upgrade your plan at https://westbayberry.com/pricing", 429, retryAfter);
311
+ }
312
+ if (!response.ok) {
313
+ const body = await response.text();
314
+ throw new APIError(formatApiErrorMessage(response.status, body, config), response.status, body);
315
+ }
316
+ const raw = (await response.json());
317
+ if (!raw || typeof raw.score !== "number" || !Array.isArray(raw.packages)) {
318
+ throw new APIError("Invalid API response format", 0, "");
319
+ }
320
+ checkVersionFloor(raw);
321
+ return (0, sanitize_1.sanitizeResponse)(raw);
322
+ }
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAuthSession = createAuthSession;
4
+ exports.pollAuthSession = pollAuthSession;
5
+ exports.saveCredentials = saveCredentials;
6
+ exports.clearCredentials = clearCredentials;
7
+ exports.getStoredApiKey = getStoredApiKey;
8
+ exports.maskKey = maskKey;
9
+ exports.getOrCreateDeviceId = getOrCreateDeviceId;
10
+ exports.getFirstRunCompletedAt = getFirstRunCompletedAt;
11
+ exports.markFirstRunComplete = markFirstRunComplete;
12
+ exports.openBrowser = openBrowser;
13
+ const node_fs_1 = require("node:fs");
14
+ const node_path_1 = require("node:path");
15
+ const node_os_1 = require("node:os");
16
+ const node_child_process_1 = require("node:child_process");
17
+ const node_crypto_1 = require("node:crypto");
18
+ const WEB_BASE = "https://westbayberry.com";
19
+ const CONFIG_FILE = ".dgrc.json";
20
+ /**
21
+ * Create a new CLI auth session by calling POST /cli/auth/sessions on the
22
+ * website. Returns session info including the user code and verification URL.
23
+ */
24
+ async function createAuthSession() {
25
+ let res;
26
+ try {
27
+ res = await globalThis.fetch(`${WEB_BASE}/cli/auth/sessions`, {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ });
31
+ }
32
+ catch {
33
+ throw new Error("Could not connect to westbayberry.com");
34
+ }
35
+ if (!res.ok) {
36
+ const body = await res.text().catch(() => "");
37
+ throw new Error(`Failed to create auth session (HTTP ${res.status})${body ? `: ${body}` : ""}`);
38
+ }
39
+ const json = (await res.json());
40
+ return {
41
+ sessionId: json.session_id,
42
+ verifyUrl: json.verify_url,
43
+ expiresIn: json.expires_in,
44
+ };
45
+ }
46
+ /**
47
+ * Poll for auth completion. Returns the current status of the session.
48
+ */
49
+ async function pollAuthSession(sessionId) {
50
+ let res;
51
+ try {
52
+ res = await globalThis.fetch(`${WEB_BASE}/cli/auth/sessions/${sessionId}/token`);
53
+ }
54
+ catch {
55
+ return { status: "expired" };
56
+ }
57
+ if (res.status === 404) {
58
+ return { status: "expired" };
59
+ }
60
+ if (!res.ok) {
61
+ return { status: "expired" };
62
+ }
63
+ const json = (await res.json());
64
+ return {
65
+ status: json.status,
66
+ apiKey: json.api_key,
67
+ email: json.email,
68
+ };
69
+ }
70
+ function configPath() {
71
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), CONFIG_FILE);
72
+ }
73
+ function readConfig() {
74
+ let raw;
75
+ try {
76
+ raw = (0, node_fs_1.readFileSync)(configPath(), "utf-8");
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ try {
82
+ const parsed = JSON.parse(raw);
83
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
84
+ return parsed;
85
+ }
86
+ return {};
87
+ }
88
+ catch {
89
+ process.stderr.write(`Warning: Failed to parse ${configPath()}, ignoring.\n`);
90
+ return {};
91
+ }
92
+ }
93
+ /** Write the dgrc config and ENFORCE 0o600 perms whether the file existed or
94
+ * not. fs.writeFileSync's mode option only applies on file creation; if the
95
+ * file pre-existed (e.g. from an older dg version, manual creation, or a
96
+ * different umask) the mode is unchanged on write. We chmod afterwards to
97
+ * guarantee the api key isn't world-readable.
98
+ *
99
+ * chmodSync errors are swallowed because some filesystems (FAT, exotic
100
+ * network mounts) don't support permission bits — failing here would prevent
101
+ * saving credentials, which is worse than the perms not being tightened. */
102
+ function writeDgrc(payload) {
103
+ const p = configPath();
104
+ (0, node_fs_1.writeFileSync)(p, JSON.stringify(payload, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
105
+ try {
106
+ (0, node_fs_1.chmodSync)(p, 0o600);
107
+ }
108
+ catch { /* fs may not support perms */ }
109
+ }
110
+ function saveCredentials(apiKey) {
111
+ const data = readConfig();
112
+ data.apiKey = apiKey;
113
+ writeDgrc(data);
114
+ }
115
+ function clearCredentials() {
116
+ const p = configPath();
117
+ if (!(0, node_fs_1.existsSync)(p))
118
+ return;
119
+ const data = readConfig();
120
+ delete data.apiKey;
121
+ if (Object.keys(data).length === 0) {
122
+ (0, node_fs_1.unlinkSync)(p);
123
+ }
124
+ else {
125
+ writeDgrc(data);
126
+ }
127
+ }
128
+ function getStoredApiKey() {
129
+ const data = readConfig();
130
+ if (typeof data.apiKey === "string" && data.apiKey.length > 0) {
131
+ return data.apiKey;
132
+ }
133
+ return null;
134
+ }
135
+ // Display-safe mask for API keys. Never include any of the random suffix.
136
+ function maskKey(key) {
137
+ if (key.startsWith("dg_test_"))
138
+ return "dg_test_****";
139
+ if (key.startsWith("dg_live_"))
140
+ return "dg_live_****";
141
+ return "dg_****";
142
+ }
143
+ function getOrCreateDeviceId() {
144
+ const data = readConfig();
145
+ if (typeof data.deviceId === "string" && data.deviceId.length > 0) {
146
+ return data.deviceId;
147
+ }
148
+ const deviceId = (0, node_crypto_1.randomUUID)();
149
+ data.deviceId = deviceId;
150
+ writeDgrc(data);
151
+ return deviceId;
152
+ }
153
+ // ── First-run sentinel ──────────────────────────────────────────────────────
154
+ //
155
+ // Tracks whether the user has completed (or explicitly dismissed) the first-run
156
+ // guided tour wizard. Stored in ~/.dgrc.json as `firstRunCompletedAt: <ISO8601>`
157
+ // so it lives next to apiKey + deviceId in the same per-user state file.
158
+ //
159
+ // Sticky once set: the wizard never re-prompts for a user who has already been
160
+ // through it. To re-trigger, the user deletes the field manually from
161
+ // ~/.dgrc.json (we deliberately do NOT add a `dg setup` command to satisfy
162
+ // the re-run case — most users only need setup once per machine).
163
+ /** Read the first-run sentinel timestamp. Returns null if the user has not
164
+ * yet been through the first-run wizard on this machine. */
165
+ function getFirstRunCompletedAt() {
166
+ const data = readConfig();
167
+ if (typeof data.firstRunCompletedAt === "string" && data.firstRunCompletedAt.length > 0) {
168
+ return data.firstRunCompletedAt;
169
+ }
170
+ return null;
171
+ }
172
+ /** Mark the first-run wizard as complete for this user. Idempotent — calling
173
+ * twice updates the timestamp without crashing or clobbering apiKey/deviceId. */
174
+ function markFirstRunComplete() {
175
+ const data = readConfig();
176
+ data.firstRunCompletedAt = new Date().toISOString();
177
+ writeDgrc(data);
178
+ }
179
+ /**
180
+ * Open a URL in the default browser.
181
+ * Fire-and-forget: errors are silently ignored.
182
+ */
183
+ function openBrowser(url) {
184
+ // Validate URL is proper http(s) — no shell injection possible with spawn array args
185
+ try {
186
+ const parsed = new URL(url);
187
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
188
+ return;
189
+ }
190
+ catch {
191
+ return;
192
+ }
193
+ let cmd;
194
+ let args;
195
+ switch (process.platform) {
196
+ case "darwin":
197
+ cmd = "open";
198
+ args = [url];
199
+ break;
200
+ case "linux":
201
+ cmd = "xdg-open";
202
+ args = [url];
203
+ break;
204
+ case "win32":
205
+ cmd = "cmd.exe";
206
+ args = ["/c", "start", "", url];
207
+ break;
208
+ default:
209
+ return;
210
+ }
211
+ try {
212
+ const child = (0, node_child_process_1.spawn)(cmd, args, { stdio: "ignore", detached: true });
213
+ child.unref();
214
+ }
215
+ catch {
216
+ // Intentionally ignored — the URL is always printed as fallback.
217
+ }
218
+ }