@westbayberry/dg 1.0.52 → 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.
- package/README.md +5 -1
- package/dist/index.mjs +349 -168
- package/dist/packages/cli/src/alt-screen.js +36 -0
- package/dist/packages/cli/src/api.js +322 -0
- package/dist/packages/cli/src/auth.js +218 -0
- package/dist/packages/cli/src/bin.js +386 -0
- package/dist/packages/cli/src/config.js +228 -0
- package/dist/packages/cli/src/discover.js +126 -0
- package/dist/packages/cli/src/first-run.js +135 -0
- package/dist/packages/cli/src/hook.js +360 -0
- package/dist/packages/cli/src/lockfile.js +303 -0
- package/dist/packages/cli/src/npm-wrapper.js +218 -0
- package/dist/packages/cli/src/pip-wrapper.js +273 -0
- package/dist/packages/cli/src/sanitize.js +38 -0
- package/dist/packages/cli/src/scan-core.js +144 -0
- package/dist/packages/cli/src/setup-status.js +46 -0
- package/dist/packages/cli/src/static-output.js +625 -0
- package/dist/packages/cli/src/telemetry.js +141 -0
- package/dist/packages/cli/src/ui/App.js +137 -0
- package/dist/packages/cli/src/ui/InitApp.js +391 -0
- package/dist/packages/cli/src/ui/LoginApp.js +51 -0
- package/dist/packages/cli/src/ui/NpmWrapperApp.js +73 -0
- package/dist/packages/cli/src/ui/PipWrapperApp.js +72 -0
- package/dist/packages/cli/src/ui/components/ConfirmPrompt.js +24 -0
- package/dist/packages/cli/src/ui/components/DemoScanAnimation.js +26 -0
- package/dist/packages/cli/src/ui/components/DurationLine.js +7 -0
- package/dist/packages/cli/src/ui/components/ErrorView.js +30 -0
- package/dist/packages/cli/src/ui/components/FileSavePrompt.js +210 -0
- package/dist/packages/cli/src/ui/components/InteractiveResultsView.js +557 -0
- package/dist/packages/cli/src/ui/components/Mascot.js +33 -0
- package/dist/packages/cli/src/ui/components/ProgressBar.js +51 -0
- package/dist/packages/cli/src/ui/components/ProgressDots.js +35 -0
- package/dist/packages/cli/src/ui/components/ProjectSelector.js +60 -0
- package/dist/packages/cli/src/ui/components/ResultsView.js +105 -0
- package/dist/packages/cli/src/ui/components/ScanResultCard.js +54 -0
- package/dist/packages/cli/src/ui/components/ScoreHeader.js +142 -0
- package/dist/packages/cli/src/ui/components/SetupBanner.js +17 -0
- package/dist/packages/cli/src/ui/components/Spinner.js +11 -0
- package/dist/packages/cli/src/ui/hooks/useExpandAnimation.js +44 -0
- package/dist/packages/cli/src/ui/hooks/useInit.js +341 -0
- package/dist/packages/cli/src/ui/hooks/useLogin.js +121 -0
- package/dist/packages/cli/src/ui/hooks/useNpmWrapper.js +192 -0
- package/dist/packages/cli/src/ui/hooks/usePipWrapper.js +195 -0
- package/dist/packages/cli/src/ui/hooks/useScan.js +202 -0
- package/dist/packages/cli/src/ui/hooks/useTerminalSize.js +29 -0
- package/dist/packages/cli/src/update-check.js +152 -0
- package/dist/packages/cli/src/wizard-demo-data.js +63 -0
- package/dist/src/ecosystem.js +2 -0
- package/dist/src/lockfile/diff.js +38 -0
- package/dist/src/lockfile/parse_package_json.js +41 -0
- package/dist/src/lockfile/parse_package_lock.js +55 -0
- package/dist/src/lockfile/parse_pipfile_lock.js +69 -0
- package/dist/src/lockfile/parse_pnpm_lock.js +62 -0
- package/dist/src/lockfile/parse_poetry_lock.js +71 -0
- package/dist/src/lockfile/parse_requirements.js +83 -0
- package/dist/src/lockfile/parse_yarn_lock.js +66 -0
- package/dist/src/logger.js +21 -0
- package/dist/src/npm/h2pool.js +161 -0
- package/dist/src/npm/registry.js +299 -0
- package/dist/src/npm/tarball.js +274 -0
- package/dist/src/pypi/registry.js +299 -0
- package/dist/src/pypi/tarball.js +361 -0
- package/dist/src/types.js +2 -0
- 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
|
+
}
|