@vigilbase/cli 0.2.2 → 0.2.4
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/CLOUDFLARE.md +2 -2
- package/COMMANDS.md +128 -42
- package/LOCAL.md +99 -38
- package/ONE.md +1 -1
- package/README.md +217 -66
- package/SECURITY.md +1 -1
- package/dist/auth/acl.d.ts +6 -0
- package/dist/auth/acl.js +45 -0
- package/dist/auth/device-auth.d.ts +3 -0
- package/dist/auth/device-auth.js +13 -3
- package/dist/auth/provider.js +4 -4
- package/dist/auth/refresh.d.ts +5 -4
- package/dist/auth/refresh.js +82 -41
- package/dist/auth/store.d.ts +25 -0
- package/dist/auth/store.js +91 -11
- package/dist/bin.js +13 -1
- package/dist/cli.js +205 -81
- package/dist/commands/account.js +11 -7
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.js +75 -24
- package/dist/commands/billing.js +2 -2
- package/dist/commands/cloudflare.d.ts +1 -1
- package/dist/commands/cloudflare.js +31 -17
- package/dist/commands/helpers.js +3 -3
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +2 -1
- package/dist/commands/legacy-validation.d.ts +20 -0
- package/dist/commands/legacy-validation.js +77 -3
- package/dist/commands/local.js +186 -65
- package/dist/commands/one.js +16 -13
- package/dist/commands/public-tools.js +66 -35
- package/dist/commands/runs.js +5 -5
- package/dist/commands/security.d.ts +1 -1
- package/dist/commands/security.js +41 -37
- package/dist/commands/watchdog.js +5 -5
- package/dist/lib/banner.d.ts +5 -11
- package/dist/lib/banner.js +11 -31
- package/dist/lib/client.d.ts +9 -0
- package/dist/lib/client.js +31 -2
- package/dist/lib/cloudflare-client.d.ts +2 -2
- package/dist/lib/config.d.ts +16 -0
- package/dist/lib/config.js +77 -19
- package/dist/lib/format.d.ts +5 -1
- package/dist/lib/format.js +110 -35
- package/dist/lib/help.js +57 -6
- package/dist/lib/network.d.ts +12 -3
- package/dist/lib/network.js +73 -9
- package/dist/lib/one-client.d.ts +2 -0
- package/dist/lib/one-client.js +86 -12
- package/dist/lib/open-url.js +2 -1
- package/dist/lib/platform.d.ts +2 -0
- package/dist/lib/platform.js +5 -0
- package/dist/lib/progress.d.ts +18 -3
- package/dist/lib/progress.js +72 -10
- package/dist/lib/system-trust.d.ts +2 -0
- package/dist/lib/system-trust.js +6 -0
- package/dist/lib/tui.d.ts +1 -1
- package/dist/lib/tui.js +5 -7
- package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
- package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
- package/dist/local/cloudflare-checkup/cf/install.js +55 -24
- package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
- package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
- package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
- package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
- package/dist/local/cloudflare-checkup/contract.js +80 -14
- package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
- package/dist/local/cloudflare-checkup/errors.js +36 -5
- package/dist/local/cloudflare-checkup/output.d.ts +6 -0
- package/dist/local/cloudflare-checkup/output.js +9 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
- package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
- package/dist/local/cloudflare-checkup/submit-client.js +7 -1
- package/dist/types.d.ts +6 -1
- package/esm-only.cjs +3 -0
- package/package.json +7 -4
package/dist/lib/one-client.js
CHANGED
|
@@ -7,6 +7,7 @@ import { validateOneOrigin } from './config.js';
|
|
|
7
7
|
import { validateStripePortalUrl } from './open-url.js';
|
|
8
8
|
import { parseJsonResponse, requestText } from './network.js';
|
|
9
9
|
import { oneErrorHint } from './one-error-hints.js';
|
|
10
|
+
import { SCAN_FAILED_MESSAGE } from './format.js';
|
|
10
11
|
import { retryAfterSeconds, VigilbaseApiError } from './client.js';
|
|
11
12
|
/**
|
|
12
13
|
* Tools that require Vigilbase One authentication (auth-required scanners).
|
|
@@ -59,7 +60,13 @@ export function resolveToolDefinition(toolName, publicTools, oneBaseUrl) {
|
|
|
59
60
|
if (!isOneAuthRequiredTool(toolName))
|
|
60
61
|
return null;
|
|
61
62
|
const authRequired = ONE_AUTH_REQUIRED_TOOL_DEFINITIONS[toolName];
|
|
62
|
-
|
|
63
|
+
// Public-only commands defer checking --one-url / VIGILBASE_ONE_URL. Don't echo a value that fails the check: it can
|
|
64
|
+
// hold URL credentials or a token in its path or query. The paths stay relative.
|
|
65
|
+
let origin = '';
|
|
66
|
+
try {
|
|
67
|
+
origin = validateOneOrigin(oneBaseUrl);
|
|
68
|
+
}
|
|
69
|
+
catch { /* left relative */ }
|
|
63
70
|
return {
|
|
64
71
|
...authRequired,
|
|
65
72
|
uiUrl: authRequired.uiUrl.startsWith('http')
|
|
@@ -101,6 +108,7 @@ export class VigilbaseOneClient {
|
|
|
101
108
|
}
|
|
102
109
|
const send = () => requestText(this.config, url, {
|
|
103
110
|
...init,
|
|
111
|
+
option: '--one-url',
|
|
104
112
|
headers: {
|
|
105
113
|
Accept: 'application/json',
|
|
106
114
|
...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
|
|
@@ -133,9 +141,12 @@ export class VigilbaseOneClient {
|
|
|
133
141
|
const hint = response.ok ? undefined : oneErrorHint({
|
|
134
142
|
method: init?.method ?? 'GET', path, status: response.status, code: errorDetails.code, hasCustomerScope: options?.hasCustomerScope,
|
|
135
143
|
});
|
|
144
|
+
// Every message below is the CLI's own; One's error text stays in the body, which is never printed.
|
|
136
145
|
if (response.status === 401 || response.status === 403) {
|
|
137
|
-
throw new VigilbaseApiError(message ||
|
|
138
|
-
'Authentication required. Run `vigilbase login`.'
|
|
146
|
+
throw new VigilbaseApiError(message || (response.status === 401
|
|
147
|
+
? 'Authentication required. Run `vigilbase login`.'
|
|
148
|
+
// Signed in, but One refuses this user or this request: logging in again won't help.
|
|
149
|
+
: "You don't have access to this."), { status: response.status, body: parsed, code: errorDetails.code, hint, isCliAuthored: true });
|
|
139
150
|
}
|
|
140
151
|
if (response.status === 429) {
|
|
141
152
|
throw new VigilbaseApiError(message || 'Rate limit exceeded.', {
|
|
@@ -143,18 +154,21 @@ export class VigilbaseOneClient {
|
|
|
143
154
|
body: parsed,
|
|
144
155
|
retryAfterSec: retryAfterSeconds(response, body),
|
|
145
156
|
code: errorDetails.code,
|
|
157
|
+
isCliAuthored: true,
|
|
146
158
|
});
|
|
147
159
|
}
|
|
148
160
|
const allowed = options?.allowStatuses ?? [];
|
|
149
161
|
if (!response.ok && !allowed.includes(response.status)) {
|
|
150
162
|
throw new VigilbaseApiError(message ||
|
|
151
|
-
`Request failed (HTTP ${response.status})
|
|
163
|
+
`Request failed (HTTP ${response.status}).`, { status: response.status, body: parsed, code: errorDetails.code, hint, isCliAuthored: true });
|
|
152
164
|
}
|
|
153
165
|
if (!z.record(z.string(), z.unknown()).safeParse(body).success) {
|
|
154
166
|
throw new VigilbaseApiError('Invalid One API response. Expected a JSON object.', { status: response.status });
|
|
155
167
|
}
|
|
156
168
|
if (parsed.success === false || ('ok' in parsed && parsed.ok === false)) {
|
|
157
|
-
throw new VigilbaseApiError('One API reported an unsuccessful operation.', {
|
|
169
|
+
throw new VigilbaseApiError('One API reported an unsuccessful operation.', {
|
|
170
|
+
status: response.status, body: parsed, code: errorDetails.code, isCliAuthored: true,
|
|
171
|
+
});
|
|
158
172
|
}
|
|
159
173
|
return parsed;
|
|
160
174
|
}
|
|
@@ -170,7 +184,7 @@ export class VigilbaseOneClient {
|
|
|
170
184
|
throw new VigilbaseApiError('Authentication required.', { status: 401 });
|
|
171
185
|
this.accessToken = token;
|
|
172
186
|
}
|
|
173
|
-
const load = () => requestText(this.config, provider.userinfoEndpoint, { headers: { Authorization: `Bearer ${this.accessToken}`, Accept: 'application/json', 'User-Agent': this.config.userAgent } });
|
|
187
|
+
const load = () => requestText(this.config, provider.userinfoEndpoint, { option: '--auth-url', headers: { Authorization: `Bearer ${this.accessToken}`, Accept: 'application/json', 'User-Agent': this.config.userAgent } });
|
|
174
188
|
let { response, text } = await load();
|
|
175
189
|
if (response.status === 401 && this.managed) {
|
|
176
190
|
const token = await managedAccessToken(this.config, this.accessToken);
|
|
@@ -188,7 +202,7 @@ export class VigilbaseOneClient {
|
|
|
188
202
|
}
|
|
189
203
|
const { response, body } = await this.request('/api/auth/get-session');
|
|
190
204
|
if (!response.ok) {
|
|
191
|
-
throw new VigilbaseApiError(`Failed to load One session (HTTP ${response.status})`, { status: response.status, body });
|
|
205
|
+
throw new VigilbaseApiError(`Failed to load One session (HTTP ${response.status})`, { status: response.status, body, isCliAuthored: true });
|
|
192
206
|
}
|
|
193
207
|
const schema = z.object({
|
|
194
208
|
session: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
@@ -283,10 +297,70 @@ export class VigilbaseOneClient {
|
|
|
283
297
|
return this.requestJson(`/api/tool-runs/${encodeURIComponent(id)}`);
|
|
284
298
|
}
|
|
285
299
|
async executeTool(toolName, target) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
300
|
+
try {
|
|
301
|
+
return await this.requestJson(`/api/tools/${encodeURIComponent(toolName)}/execute`, {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: { 'Content-Type': 'application/json' },
|
|
304
|
+
body: JSON.stringify({ target }),
|
|
305
|
+
}, { allowStatuses: [422] });
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
const unassessed = unassessedScan(error);
|
|
309
|
+
if (unassessed)
|
|
310
|
+
return unassessed;
|
|
311
|
+
throw failedScanError(error) ?? error;
|
|
312
|
+
}
|
|
291
313
|
}
|
|
292
314
|
}
|
|
315
|
+
/** One's answer when the scan itself failed, for example when com ran out of memory. */
|
|
316
|
+
const failedScanSchema = z.object({
|
|
317
|
+
success: z.literal(false),
|
|
318
|
+
data: z.object({ status: z.literal('failed'), toolRunId: z.unknown().optional() }).passthrough(),
|
|
319
|
+
}).passthrough();
|
|
320
|
+
const unassessedScanSchema = z.object({
|
|
321
|
+
code: z.literal('scan_unassessed'),
|
|
322
|
+
data: z.object({
|
|
323
|
+
toolRunId: z.unknown().optional(),
|
|
324
|
+
result: z.object({ status: z.literal('error'), message: z.string() }).passthrough(),
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
/**
|
|
328
|
+
* A scan the scanner ran but could not assess (a bot verification challenge, an
|
|
329
|
+
* error page). The result carries the scanner's message, which the formatter
|
|
330
|
+
* prints bounded like any scanner result; One's error text stays unused.
|
|
331
|
+
*/
|
|
332
|
+
function unassessedScan(error) {
|
|
333
|
+
if (!(error instanceof VigilbaseApiError) || error.status !== 422)
|
|
334
|
+
return undefined;
|
|
335
|
+
const unassessed = unassessedScanSchema.safeParse(error.body);
|
|
336
|
+
if (!unassessed.success)
|
|
337
|
+
return undefined;
|
|
338
|
+
const toolRunId = z.string().uuid().safeParse(unassessed.data.data.toolRunId);
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
code: 'scan_unassessed',
|
|
342
|
+
data: {
|
|
343
|
+
toolRunId: toolRunId.success ? toolRunId.data : undefined,
|
|
344
|
+
status: 'failed',
|
|
345
|
+
grade: null,
|
|
346
|
+
gradeScore: null,
|
|
347
|
+
result: unassessed.data.data.result,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
/** com's copy for a 422 failed scan, with the run One recorded; One's error text stays hidden. */
|
|
352
|
+
function failedScanError(error) {
|
|
353
|
+
if (!(error instanceof VigilbaseApiError) || error.status !== 422)
|
|
354
|
+
return undefined;
|
|
355
|
+
const failed = failedScanSchema.safeParse(error.body);
|
|
356
|
+
if (!failed.success)
|
|
357
|
+
return undefined;
|
|
358
|
+
const toolRunId = z.string().uuid().safeParse(failed.data.data.toolRunId);
|
|
359
|
+
return new VigilbaseApiError(SCAN_FAILED_MESSAGE, {
|
|
360
|
+
status: 422,
|
|
361
|
+
body: error.body,
|
|
362
|
+
code: error.code,
|
|
363
|
+
isCliAuthored: true,
|
|
364
|
+
toolRunId: toolRunId.success ? toolRunId.data : undefined,
|
|
365
|
+
});
|
|
366
|
+
}
|
package/dist/lib/open-url.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { validateOneOrigin } from './config.js';
|
|
3
|
+
import { isCi } from './platform.js';
|
|
3
4
|
/** Stripe Customer Portal uses this exact host; never trust a returned URL by type alone. */
|
|
4
5
|
export function validateStripePortalUrl(raw) {
|
|
5
6
|
if (typeof raw !== 'string' || /[\u0000-\u0020\u007f-\u009f]/.test(raw)) {
|
|
@@ -23,7 +24,7 @@ export function validateStripePortalUrl(raw) {
|
|
|
23
24
|
export function isHeadlessEnvironment(env = process.env) {
|
|
24
25
|
if (env.VIGILBASE_CLI_NO_OPEN === '1')
|
|
25
26
|
return true;
|
|
26
|
-
if (env
|
|
27
|
+
if (isCi(env))
|
|
27
28
|
return true;
|
|
28
29
|
if (env.GITHUB_ACTIONS === 'true')
|
|
29
30
|
return true;
|
package/dist/lib/platform.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** The host platform. A function rather than a constant so tests can simulate Windows. */
|
|
2
2
|
export declare function platform(): NodeJS.Platform;
|
|
3
|
+
/** Running in CI: `CI` set to anything but `0` or `false` (any case). */
|
|
4
|
+
export declare function isCi(env?: NodeJS.ProcessEnv): boolean;
|
package/dist/lib/platform.js
CHANGED
|
@@ -2,3 +2,8 @@
|
|
|
2
2
|
export function platform() {
|
|
3
3
|
return process.platform;
|
|
4
4
|
}
|
|
5
|
+
/** Running in CI: `CI` set to anything but `0` or `false` (any case). */
|
|
6
|
+
export function isCi(env = process.env) {
|
|
7
|
+
const value = env.CI;
|
|
8
|
+
return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
|
|
9
|
+
}
|
package/dist/lib/progress.d.ts
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import { type Writer } from './format.js';
|
|
2
|
+
/** Where SIGINT arrives: the process, or a plain emitter in tests. */
|
|
3
|
+
type SignalSource = {
|
|
4
|
+
once(event: 'SIGINT', listener: () => void): unknown;
|
|
5
|
+
removeListener(event: 'SIGINT', listener: () => void): unknown;
|
|
6
|
+
listenerCount(event: 'SIGINT'): number;
|
|
7
|
+
};
|
|
2
8
|
export type ProgressHandle = {
|
|
3
9
|
stop: (finalMessage?: string) => void;
|
|
4
10
|
};
|
|
5
11
|
/**
|
|
6
|
-
* Live indeterminate progress on stderr while a scan/request is in flight
|
|
7
|
-
*
|
|
12
|
+
* Live indeterminate progress on stderr while a scan/request is in flight; see
|
|
13
|
+
* shouldShowProgress for when it is on. Ctrl-C clears the line before the process exits.
|
|
8
14
|
*/
|
|
9
15
|
export declare function startProgress(label: string, options: {
|
|
10
16
|
isEnabled: boolean;
|
|
11
|
-
stderr?: NodeJS.WritableStream
|
|
17
|
+
stderr?: NodeJS.WritableStream & {
|
|
18
|
+
columns?: number;
|
|
19
|
+
};
|
|
12
20
|
/** Soft expected duration used to fill the bar (ms). */
|
|
13
21
|
expectedMs?: number;
|
|
22
|
+
signals?: SignalSource;
|
|
23
|
+
exit?: (code: number) => void;
|
|
14
24
|
}): ProgressHandle;
|
|
25
|
+
/**
|
|
26
|
+
* Progress shows for text output on a terminal stderr, except in CI, with TERM=dumb or with
|
|
27
|
+
* VIGILBASE_CLI_NO_PROGRESS=1. NO_COLOR does not turn it off: the spinner has no colour.
|
|
28
|
+
*/
|
|
15
29
|
export declare function shouldShowProgress(mode: 'text' | 'json', env?: NodeJS.ProcessEnv, stderr?: {
|
|
16
30
|
isTTY?: boolean;
|
|
17
31
|
}): boolean;
|
|
18
32
|
/** Tiny helper so call sites stay readable. */
|
|
19
33
|
export declare function withProgress<T>(label: string, mode: 'text' | 'json', work: () => Promise<T>, writer?: Writer): Promise<T>;
|
|
34
|
+
export {};
|
package/dist/lib/progress.js
CHANGED
|
@@ -1,15 +1,58 @@
|
|
|
1
1
|
import { sanitizeTerminal } from './format.js';
|
|
2
|
+
import { isCi } from './platform.js';
|
|
2
3
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
3
4
|
const BAR_WIDTH = 24;
|
|
5
|
+
/** Back to the start of the line and erase it. */
|
|
6
|
+
const CLEAR_LINE = '\r\x1b[2K';
|
|
4
7
|
function renderBar(ratio) {
|
|
5
8
|
const clamped = Math.max(0, Math.min(1, ratio));
|
|
6
9
|
const filled = Math.round(clamped * BAR_WIDTH);
|
|
7
10
|
const empty = BAR_WIDTH - filled;
|
|
8
11
|
return `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
|
|
9
12
|
}
|
|
13
|
+
const ZERO_WIDTH = /[\p{M}\u200b-\u200f\u2060-\u2064\ufe00-\ufe0f\ufeff]/u;
|
|
14
|
+
const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}\p{Extended_Pictographic}]/u;
|
|
15
|
+
/** Terminal columns of one code point: CJK and emoji take two, combining marks none. */
|
|
16
|
+
function charWidth(char) {
|
|
17
|
+
if (ZERO_WIDTH.test(char))
|
|
18
|
+
return 0;
|
|
19
|
+
return WIDE.test(char) ? 2 : 1;
|
|
20
|
+
}
|
|
21
|
+
function displayWidth(text) {
|
|
22
|
+
let width = 0;
|
|
23
|
+
for (const char of text)
|
|
24
|
+
width += charWidth(char);
|
|
25
|
+
return width;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One frame within `columns - 1` terminal columns, so the terminal never wraps it: the bar
|
|
29
|
+
* goes first, then the label is cut with an ellipsis.
|
|
30
|
+
*/
|
|
31
|
+
function fitFrame(spin, label, bar, elapsedSec, columns) {
|
|
32
|
+
const width = Math.max(1, columns - 1);
|
|
33
|
+
const suffix = ` ${elapsedSec}s`;
|
|
34
|
+
const full = `${spin} ${label} ${bar}${suffix}`;
|
|
35
|
+
if (displayWidth(full) <= width)
|
|
36
|
+
return full;
|
|
37
|
+
const room = width - 2 - suffix.length;
|
|
38
|
+
if (displayWidth(label) <= room)
|
|
39
|
+
return `${spin} ${label}${suffix}`;
|
|
40
|
+
if (room < 2)
|
|
41
|
+
return spin;
|
|
42
|
+
let cut = '';
|
|
43
|
+
let used = 0;
|
|
44
|
+
for (const char of label) {
|
|
45
|
+
const next = charWidth(char);
|
|
46
|
+
if (used + next > room - 1)
|
|
47
|
+
break;
|
|
48
|
+
cut += char;
|
|
49
|
+
used += next;
|
|
50
|
+
}
|
|
51
|
+
return `${spin} ${cut.trimEnd()}…${suffix}`;
|
|
52
|
+
}
|
|
10
53
|
/**
|
|
11
|
-
* Live indeterminate progress on stderr while a scan/request is in flight
|
|
12
|
-
*
|
|
54
|
+
* Live indeterminate progress on stderr while a scan/request is in flight; see
|
|
55
|
+
* shouldShowProgress for when it is on. Ctrl-C clears the line before the process exits.
|
|
13
56
|
*/
|
|
14
57
|
export function startProgress(label, options) {
|
|
15
58
|
label = sanitizeTerminal(label).replace(/[\n\t]/g, ' ');
|
|
@@ -17,6 +60,8 @@ export function startProgress(label, options) {
|
|
|
17
60
|
if (!options.isEnabled) {
|
|
18
61
|
return { stop: () => undefined };
|
|
19
62
|
}
|
|
63
|
+
const signals = options.signals ?? process;
|
|
64
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
20
65
|
const expectedMs = options.expectedMs ?? 8_000;
|
|
21
66
|
const started = Date.now();
|
|
22
67
|
let frame = 0;
|
|
@@ -31,31 +76,48 @@ export function startProgress(label, options) {
|
|
|
31
76
|
const soft = Math.min(0.92, ratio);
|
|
32
77
|
const spin = SPINNER[frame % SPINNER.length];
|
|
33
78
|
frame += 1;
|
|
34
|
-
|
|
35
|
-
stream.write(
|
|
79
|
+
// Read the width every frame: the terminal may be resized.
|
|
80
|
+
stream.write(`${CLEAR_LINE}${fitFrame(spin, label, renderBar(soft), elapsedSec, stream.columns || 80)}`);
|
|
81
|
+
};
|
|
82
|
+
const close = () => {
|
|
83
|
+
closed = true;
|
|
84
|
+
clearInterval(timer);
|
|
85
|
+
signals.removeListener('SIGINT', onSigint);
|
|
86
|
+
stream.write(CLEAR_LINE);
|
|
87
|
+
};
|
|
88
|
+
// Without this, Ctrl-C kills the process mid-frame and the prompt lands on the spinner line.
|
|
89
|
+
// Another SIGINT listener (the checkup's cleanup) exits itself; alone, exit as Ctrl-C would.
|
|
90
|
+
const onSigint = () => {
|
|
91
|
+
if (closed)
|
|
92
|
+
return;
|
|
93
|
+
close();
|
|
94
|
+
if (signals.listenerCount('SIGINT') === 0)
|
|
95
|
+
exit(130);
|
|
36
96
|
};
|
|
37
97
|
draw();
|
|
38
98
|
const timer = setInterval(draw, 80);
|
|
39
99
|
timer.unref?.();
|
|
100
|
+
signals.once('SIGINT', onSigint);
|
|
40
101
|
return {
|
|
41
102
|
stop: (finalMessage) => {
|
|
42
103
|
if (closed)
|
|
43
104
|
return;
|
|
44
|
-
|
|
45
|
-
clearInterval(timer);
|
|
46
|
-
const elapsedSec = ((Date.now() - started) / 1000).toFixed(1);
|
|
47
|
-
const clear = `\r${' '.repeat(Math.max(60, label.length + BAR_WIDTH + 20))}\r`;
|
|
48
|
-
stream.write(clear);
|
|
105
|
+
close();
|
|
49
106
|
if (finalMessage) {
|
|
107
|
+
const elapsedSec = ((Date.now() - started) / 1000).toFixed(1);
|
|
50
108
|
stream.write(`${sanitizeTerminal(finalMessage).replace(/[\n\t]/g, ' ')} (${elapsedSec}s)\n`);
|
|
51
109
|
}
|
|
52
110
|
},
|
|
53
111
|
};
|
|
54
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Progress shows for text output on a terminal stderr, except in CI, with TERM=dumb or with
|
|
115
|
+
* VIGILBASE_CLI_NO_PROGRESS=1. NO_COLOR does not turn it off: the spinner has no colour.
|
|
116
|
+
*/
|
|
55
117
|
export function shouldShowProgress(mode, env = process.env, stderr = process.stderr) {
|
|
56
118
|
if (mode === 'json')
|
|
57
119
|
return false;
|
|
58
|
-
if (env.
|
|
120
|
+
if (isCi(env) || (env.TERM ?? '').toLowerCase() === 'dumb')
|
|
59
121
|
return false;
|
|
60
122
|
if (env.VIGILBASE_CLI_NO_PROGRESS === '1')
|
|
61
123
|
return false;
|
|
@@ -4,4 +4,6 @@ type SystemTrustApi = {
|
|
|
4
4
|
};
|
|
5
5
|
/** CLI startup only: preserve bundled/extra CAs and add OS-managed trust. */
|
|
6
6
|
export declare function configureSystemTrust(api?: SystemTrustApi): boolean;
|
|
7
|
+
/** Whether configureSystemTrust added the OS certificates; TLS advice suggests a newer Node only when it couldn't. */
|
|
8
|
+
export declare function isSystemTrustConfigured(): boolean;
|
|
7
9
|
export {};
|
package/dist/lib/system-trust.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as tls from 'node:tls';
|
|
2
|
+
let isConfigured = false;
|
|
2
3
|
/** CLI startup only: preserve bundled/extra CAs and add OS-managed trust. */
|
|
3
4
|
export function configureSystemTrust(api = tls) {
|
|
4
5
|
if (!api.getCACertificates || !api.setDefaultCACertificates)
|
|
@@ -6,5 +7,10 @@ export function configureSystemTrust(api = tls) {
|
|
|
6
7
|
api.setDefaultCACertificates([
|
|
7
8
|
...new Set([...api.getCACertificates('default'), ...api.getCACertificates('system')]),
|
|
8
9
|
]);
|
|
10
|
+
isConfigured = true;
|
|
9
11
|
return true;
|
|
10
12
|
}
|
|
13
|
+
/** Whether configureSystemTrust added the OS certificates; TLS advice suggests a newer Node only when it couldn't. */
|
|
14
|
+
export function isSystemTrustConfigured() {
|
|
15
|
+
return isConfigured;
|
|
16
|
+
}
|
package/dist/lib/tui.d.ts
CHANGED
|
@@ -290,7 +290,7 @@ export declare class Tui {
|
|
|
290
290
|
/** Raw mode off, the frame cleared, the cursor shown; nothing more is drawn. */
|
|
291
291
|
private restore;
|
|
292
292
|
private detachSignals;
|
|
293
|
-
/** Print the cancel message; exit with `code` unless another listener for `signal` will (it
|
|
293
|
+
/** Print the cancel message; exit with `code` unless another listener for `signal` will (it cleans up and ends the command with CANCELLED). */
|
|
294
294
|
private cancel;
|
|
295
295
|
private readonly onSigint;
|
|
296
296
|
private readonly onSigterm;
|
package/dist/lib/tui.js
CHANGED
|
@@ -9,15 +9,13 @@
|
|
|
9
9
|
* echoed into the timeline, and Ctrl-C arrives as a key), a hidden cursor, and
|
|
10
10
|
* SIGINT, SIGTERM and exit listeners that always restore both. Ctrl-C prints
|
|
11
11
|
* the cancel message and is re-raised as SIGINT, so the other SIGINT listeners
|
|
12
|
-
* (the cf runner and the cf install
|
|
12
|
+
* (the cf runner and the cf install stop cf or npm, clean up and end the
|
|
13
|
+
* command with CANCELLED, which exits 130, or 143 for SIGTERM) still run; with
|
|
13
14
|
* none, the session exits 130 itself.
|
|
14
15
|
*/
|
|
15
16
|
import { StringDecoder } from 'node:string_decoder';
|
|
16
17
|
import { shouldUseColor } from './banner.js';
|
|
17
|
-
|
|
18
|
-
function isSet(value) {
|
|
19
|
-
return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
|
|
20
|
-
}
|
|
18
|
+
import { isCi } from './platform.js';
|
|
21
19
|
/**
|
|
22
20
|
* The rich UI's features, or null for the plain line output: `--json`, stdin
|
|
23
21
|
* or stderr not a terminal (pipes, files), TERM=dumb, or CI set.
|
|
@@ -28,7 +26,7 @@ export function terminalFeatures(options) {
|
|
|
28
26
|
return null;
|
|
29
27
|
if (!stdin?.isTTY || typeof stdin.setRawMode !== 'function' || !stderr?.isTTY)
|
|
30
28
|
return null;
|
|
31
|
-
if ((env.TERM ?? '').toLowerCase() === 'dumb' ||
|
|
29
|
+
if ((env.TERM ?? '').toLowerCase() === 'dumb' || isCi(env))
|
|
32
30
|
return null;
|
|
33
31
|
return {
|
|
34
32
|
color: shouldUseColor(stderr, env),
|
|
@@ -786,7 +784,7 @@ export class Tui {
|
|
|
786
784
|
this.signals.removeListener('SIGTERM', this.onSigterm);
|
|
787
785
|
this.signals.removeListener('exit', this.onExit);
|
|
788
786
|
}
|
|
789
|
-
/** Print the cancel message; exit with `code` unless another listener for `signal` will (it
|
|
787
|
+
/** Print the cancel message; exit with `code` unless another listener for `signal` will (it cleans up and ends the command with CANCELLED). */
|
|
790
788
|
cancel(signal, code) {
|
|
791
789
|
if (this.interrupted || this.closed)
|
|
792
790
|
return;
|
|
@@ -66,10 +66,8 @@ export function buildChildEnv(parent, options) {
|
|
|
66
66
|
}
|
|
67
67
|
/** Variable names (never values) a cf child would receive, for `--dry-run` output. */
|
|
68
68
|
export function childEnvVariableNames(parent, authMode, platform = process.platform) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
names.push(CF_TOKEN_ENV);
|
|
72
|
-
return names.sort();
|
|
69
|
+
// The token is listed only when it is set (and copied): without it a real run stops with CF_AUTH_REQUIRED.
|
|
70
|
+
return Object.keys(buildChildEnv(parent, { purpose: 'api', authMode, accountId: '0'.repeat(32), platform })).sort();
|
|
73
71
|
}
|
|
74
72
|
/** Shown in profile and default modes when the token variable is set in the parent shell. */
|
|
75
73
|
export function strippedTokenNotice(authMode) {
|
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
* directory is deleted (CF_INSTALL_FAILED). Only then is it renamed into
|
|
24
24
|
* place, so a half-finished install is never used. `locateCf` still applies
|
|
25
25
|
* its identity checks to the result.
|
|
26
|
-
* - Ctrl-C while npm runs kills npm and removes the
|
|
26
|
+
* - Ctrl-C (or SIGTERM, SIGHUP) while npm runs kills npm and removes the
|
|
27
|
+
* staging directory, then the install rejects with CANCELLED. Staging
|
|
27
28
|
* directories left by a crash or a closed terminal are removed by the next
|
|
28
29
|
* install once they are older than the npm timeout (a concurrent install is
|
|
29
30
|
* always younger), and by `logout`.
|
|
30
31
|
*/
|
|
31
32
|
import { type ChildProcess, type SpawnOptions } from 'node:child_process';
|
|
32
|
-
import { LocalCheckupError } from '../errors.js';
|
|
33
|
+
import { LocalCheckupError, type CancelSignalSource } from '../errors.js';
|
|
33
34
|
/** The cf release the CLI installs; `integrity` is npm's `dist.integrity` for it. */
|
|
34
35
|
export declare const MANAGED_CF: Readonly<{
|
|
35
36
|
version: "1.0.0-beta.13";
|
|
@@ -97,6 +98,7 @@ export declare function installManagedCf(options: {
|
|
|
97
98
|
replace?: boolean;
|
|
98
99
|
/** Runs first on Ctrl-C during the install (the caller stops its spinner). */
|
|
99
100
|
onInterrupt?: () => void;
|
|
101
|
+
signals?: CancelSignalSource;
|
|
100
102
|
}): Promise<string>;
|
|
101
103
|
/** `logout`: remove every managed cf version (and any staging leftovers). */
|
|
102
104
|
export declare function removeManagedCf(configDir: string): Promise<{
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* directory is deleted (CF_INSTALL_FAILED). Only then is it renamed into
|
|
24
24
|
* place, so a half-finished install is never used. `locateCf` still applies
|
|
25
25
|
* its identity checks to the result.
|
|
26
|
-
* - Ctrl-C while npm runs kills npm and removes the
|
|
26
|
+
* - Ctrl-C (or SIGTERM, SIGHUP) while npm runs kills npm and removes the
|
|
27
|
+
* staging directory, then the install rejects with CANCELLED. Staging
|
|
27
28
|
* directories left by a crash or a closed terminal are removed by the next
|
|
28
29
|
* install once they are older than the npm timeout (a concurrent install is
|
|
29
30
|
* always younger), and by `logout`.
|
|
@@ -32,7 +33,7 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|
|
32
33
|
import { existsSync } from 'node:fs';
|
|
33
34
|
import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
34
35
|
import path from 'node:path';
|
|
35
|
-
import { LocalCheckupError } from '../errors.js';
|
|
36
|
+
import { CANCEL_SIGNALS, CheckupCancelledError, LocalCheckupError } from '../errors.js';
|
|
36
37
|
/** The cf release the CLI installs; `integrity` is npm's `dist.integrity` for it. */
|
|
37
38
|
export const MANAGED_CF = Object.freeze({
|
|
38
39
|
version: '1.0.0-beta.13',
|
|
@@ -220,25 +221,38 @@ export async function removeStaleStaging(root, maxAgeMs = STALE_STAGING_MS, now
|
|
|
220
221
|
return removed;
|
|
221
222
|
}
|
|
222
223
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
224
|
+
* SIGINT, SIGTERM or SIGHUP while installing: kill npm and reject `interrupted`
|
|
225
|
+
* with CANCELLED; `closed` settles once npm has exited (2 s at most).
|
|
225
226
|
*/
|
|
226
|
-
function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
})
|
|
235
|
-
: Promise.resolve();
|
|
236
|
-
child?.kill('SIGKILL');
|
|
237
|
-
void closed
|
|
238
|
-
.then(() => rm(staging, { recursive: true, force: true, maxRetries: 3 }))
|
|
239
|
-
.catch(() => undefined)
|
|
240
|
-
.finally(() => process.exit(130));
|
|
227
|
+
function listenForCancel(signals, running, before) {
|
|
228
|
+
let interrupt = () => undefined;
|
|
229
|
+
const state = {
|
|
230
|
+
error: null,
|
|
231
|
+
closed: Promise.resolve(),
|
|
232
|
+
interrupted: new Promise((_resolve, reject) => { interrupt = reject; }),
|
|
233
|
+
stop: () => { for (const { signal, listener } of listeners)
|
|
234
|
+
signals.removeListener(signal, listener); },
|
|
241
235
|
};
|
|
236
|
+
const listeners = CANCEL_SIGNALS.map((signal) => {
|
|
237
|
+
const listener = () => {
|
|
238
|
+
if (state.error)
|
|
239
|
+
return;
|
|
240
|
+
state.error = new CheckupCancelledError(signal);
|
|
241
|
+
before?.();
|
|
242
|
+
const child = running();
|
|
243
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
244
|
+
state.closed = new Promise((resolve) => {
|
|
245
|
+
child.once('close', () => resolve());
|
|
246
|
+
setTimeout(resolve, 2_000).unref?.();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
child?.kill('SIGKILL');
|
|
250
|
+
interrupt(state.error);
|
|
251
|
+
};
|
|
252
|
+
signals.on(signal, listener);
|
|
253
|
+
return { signal, listener };
|
|
254
|
+
});
|
|
255
|
+
return state;
|
|
242
256
|
}
|
|
243
257
|
/**
|
|
244
258
|
* Install the pinned cf into `<configDir>/cf/<version>/` and return its
|
|
@@ -257,17 +271,21 @@ export async function installManagedCf(options) {
|
|
|
257
271
|
// realpath: npm keys its lockfile relative to the real prefix, so a symlinked path would not match `node_modules/cf`.
|
|
258
272
|
const staging = await realpath(await mkdtemp(path.join(root, `${STAGING_PREFIX}${MANAGED_CF.version}-`)));
|
|
259
273
|
let npmChild = null;
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
274
|
+
const cancel = listenForCancel(options.signals ?? process, () => npmChild, options.onInterrupt);
|
|
275
|
+
// After a cancel, the install stops at its next step instead of carrying on beside the cleanup.
|
|
276
|
+
const stopIfCancelled = () => { if (cancel.error)
|
|
277
|
+
throw cancel.error; };
|
|
278
|
+
const install = async () => {
|
|
263
279
|
if (process.platform !== 'win32')
|
|
264
280
|
await chmod(staging, 0o700);
|
|
281
|
+
stopIfCancelled();
|
|
265
282
|
const result = await runNpm(options.spawn ?? nodeSpawn, npmInstallArgv(npmCli, staging), {
|
|
266
283
|
cwd: staging,
|
|
267
284
|
env: npmChildEnv(options.env),
|
|
268
285
|
timeoutMs: options.timeoutMs ?? NPM_INSTALL_TIMEOUT_MS,
|
|
269
286
|
onSpawn: (child) => { npmChild = child; },
|
|
270
287
|
});
|
|
288
|
+
stopIfCancelled();
|
|
271
289
|
if (result.spawnFailed)
|
|
272
290
|
throw new CfInstallError('npm could not be started, so cf was not installed.');
|
|
273
291
|
if (result.timedOut)
|
|
@@ -277,6 +295,7 @@ export async function installManagedCf(options) {
|
|
|
277
295
|
if (!(await verifyManagedInstall(staging))) {
|
|
278
296
|
throw new CfInstallError(`The installed cf does not match the pinned cf@${MANAGED_CF.version} integrity; it was deleted.`);
|
|
279
297
|
}
|
|
298
|
+
stopIfCancelled();
|
|
280
299
|
const installDir = managedCfInstallDir(options.configDir);
|
|
281
300
|
// A leftover install that failed verification is replaced; a verified one (a concurrent run) is kept unless it is being replaced.
|
|
282
301
|
if (!options.replace && await findManagedCf(options.configDir))
|
|
@@ -291,10 +310,22 @@ export async function installManagedCf(options) {
|
|
|
291
310
|
throw new CfInstallError('The installed cf could not be moved into place.');
|
|
292
311
|
}
|
|
293
312
|
return managedCfPackageDir(installDir);
|
|
313
|
+
};
|
|
314
|
+
const task = install();
|
|
315
|
+
try {
|
|
316
|
+
return await Promise.race([task, cancel.interrupted]);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
throw cancel.error ?? error;
|
|
294
320
|
}
|
|
295
321
|
finally {
|
|
296
|
-
|
|
297
|
-
await
|
|
322
|
+
cancel.stop();
|
|
323
|
+
await cancel.closed;
|
|
324
|
+
// A cancelled install must stop before staging is removed under it; an npm that never closes holds it up 2 s at most.
|
|
325
|
+
if (cancel.error) {
|
|
326
|
+
await Promise.race([task.catch(() => undefined), new Promise((resolve) => setTimeout(resolve, 2_000))]);
|
|
327
|
+
}
|
|
328
|
+
await rm(staging, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined);
|
|
298
329
|
}
|
|
299
330
|
}
|
|
300
331
|
/** `logout`: remove every managed cf version (and any staging leftovers). */
|
|
@@ -24,6 +24,8 @@ export type CfLocation = {
|
|
|
24
24
|
export declare function isNodeSupported(version?: string): boolean;
|
|
25
25
|
/** cf runs under this Node via process.execPath; workerd ships no win32-arm64 build. */
|
|
26
26
|
export declare function assertLocalRuntimeSupported(version?: string, platform?: NodeJS.Platform, arch?: string): void;
|
|
27
|
+
/** process.cwd(), which throws for a deleted or unreadable working directory: CWD_UNAVAILABLE instead. */
|
|
28
|
+
export declare function currentDirectory(): string;
|
|
27
29
|
export declare function locateCf(options: {
|
|
28
30
|
cfBin?: string;
|
|
29
31
|
/** The verified managed cf package directory (`findManagedCf`), tried after `--cf-bin`. */
|
|
@@ -56,11 +56,20 @@ function repositoryUrl(repository) {
|
|
|
56
56
|
}
|
|
57
57
|
return '';
|
|
58
58
|
}
|
|
59
|
+
/** process.cwd(), which throws for a deleted or unreadable working directory: CWD_UNAVAILABLE instead. */
|
|
60
|
+
export function currentDirectory() {
|
|
61
|
+
try {
|
|
62
|
+
return process.cwd();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new LocalCheckupError('CWD_UNAVAILABLE', 'The current directory no longer exists or cannot be read.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
59
68
|
export async function locateCf(options) {
|
|
60
69
|
const platform = options.platform ?? process.platform;
|
|
61
70
|
const p = platform === 'win32' ? path.win32 : path.posix;
|
|
62
71
|
const fs = options.fs ?? nodeFs;
|
|
63
|
-
const cwd = options.cwd ??
|
|
72
|
+
const cwd = options.cwd ?? currentDirectory();
|
|
64
73
|
const home = options.home ?? (envLookup(options.env, platform, platform === 'win32' ? 'USERPROFILE' : 'HOME') || homedir());
|
|
65
74
|
const cwdIsHome = Boolean(home) && p.isAbsolute(home) && p.relative(home, cwd) === '';
|
|
66
75
|
let sawOther = false;
|