@vigilbase/cli 0.2.3 → 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 +111 -44
- package/LOCAL.md +99 -38
- package/ONE.md +1 -1
- package/README.md +215 -67
- package/dist/auth/acl.d.ts +6 -0
- package/dist/auth/acl.js +45 -0
- package/dist/auth/device-auth.js +1 -0
- 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 +52 -27
- 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.js +19 -1
- 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 +1 -1
- package/dist/lib/format.js +75 -24
- 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.js +9 -2
- 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 +2 -1
- package/esm-only.cjs +3 -0
- package/package.json +7 -4
package/dist/lib/network.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { isSystemTrustConfigured } from './system-trust.js';
|
|
1
2
|
export class NetworkError extends Error {
|
|
2
3
|
code;
|
|
4
|
+
hint;
|
|
3
5
|
status = 0;
|
|
4
|
-
|
|
6
|
+
/** `hint` is printed after the error; doctor, which is the connectivity check, leaves it out. */
|
|
7
|
+
constructor(message, code, cause, hint) {
|
|
5
8
|
super(message, { cause });
|
|
6
9
|
this.code = code;
|
|
10
|
+
this.hint = hint;
|
|
7
11
|
this.name = 'NetworkError';
|
|
8
12
|
}
|
|
9
13
|
}
|
|
@@ -18,21 +22,70 @@ function errorCodes(error, seen = new Set()) {
|
|
|
18
22
|
...(Array.isArray(item.errors) ? item.errors.flatMap((e) => errorCodes(e, seen)) : []),
|
|
19
23
|
];
|
|
20
24
|
}
|
|
25
|
+
/** Messages along the cause chain: undici reports a blocked port or a refused redirect only by message. */
|
|
26
|
+
function causeMessages(error, seen = new Set()) {
|
|
27
|
+
if (!error || typeof error !== 'object' || seen.has(error))
|
|
28
|
+
return [];
|
|
29
|
+
seen.add(error);
|
|
30
|
+
const item = error;
|
|
31
|
+
return [...(typeof item.message === 'string' ? [item.message] : []), ...causeMessages(item.cause, seen)];
|
|
32
|
+
}
|
|
21
33
|
export function networkError(error, url, timeoutMs, timedOut) {
|
|
22
34
|
// Never echo query strings, URL credentials, request bodies, or raw causes.
|
|
23
|
-
const endpoint = new URL(url)
|
|
35
|
+
const { origin: endpoint, port } = new URL(url);
|
|
24
36
|
const codes = errorCodes(error);
|
|
25
37
|
const code = codes[0] ?? 'NETWORK_ERROR';
|
|
38
|
+
const messages = causeMessages(error);
|
|
26
39
|
if (timedOut || codes.some((c) => /TIMEOUT|ETIMEDOUT/.test(c))) {
|
|
27
40
|
return new NetworkError(`Request to ${endpoint} timed out after ${timeoutMs}ms. Check your connection or increase --timeout.`, 'TIMEOUT', error);
|
|
28
41
|
}
|
|
42
|
+
if (messages.includes('bad port')) {
|
|
43
|
+
return new NetworkError(`Node's fetch refuses port ${port} of ${endpoint} (a port the Fetch standard blocks). Use another port.`, 'BLOCKED_PORT', error);
|
|
44
|
+
}
|
|
45
|
+
if (messages.includes('unexpected redirect')) {
|
|
46
|
+
return new NetworkError(`${endpoint} answered with a redirect, which the CLI does not follow.`, 'UNEXPECTED_REDIRECT', error);
|
|
47
|
+
}
|
|
29
48
|
if (codes.some((c) => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(c))) {
|
|
30
|
-
|
|
49
|
+
// Without system trust (older Node), the OS certificate store isn't used at all.
|
|
50
|
+
const upgrade = isSystemTrustConfigured() ? '' : ' Use Node 22.19+ or Node 24.5+ for automatic system certificate trust.';
|
|
51
|
+
return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}).${upgrade} If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error);
|
|
31
52
|
}
|
|
32
53
|
if (codes.some((c) => c === 'ENOTFOUND' || c === 'EAI_AGAIN')) {
|
|
33
54
|
return new NetworkError(`Cannot resolve ${endpoint} (${code}). Check DNS, your VPN, and the configured API URL.`, code, error);
|
|
34
55
|
}
|
|
35
|
-
return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL
|
|
56
|
+
return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL.`, code, error, 'Run `vigilbase doctor` for connectivity checks.');
|
|
57
|
+
}
|
|
58
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
59
|
+
/** The option that sets this origin, when the caller didn't name it. */
|
|
60
|
+
function optionFor(config, origin) {
|
|
61
|
+
if (origin === config.publicBaseUrl)
|
|
62
|
+
return '--base-url';
|
|
63
|
+
if (origin === config.oneBaseUrl)
|
|
64
|
+
return '--one-url';
|
|
65
|
+
if (origin === config.identityBaseUrl)
|
|
66
|
+
return '--auth-url';
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** A redirect the CLI refuses, naming only the target's origin: its path and query may carry tokens. */
|
|
70
|
+
function redirectError(config, url, response, named) {
|
|
71
|
+
const { origin } = new URL(url);
|
|
72
|
+
let target;
|
|
73
|
+
try {
|
|
74
|
+
const location = new URL(response.headers.get('location') ?? '', url);
|
|
75
|
+
if ((location.protocol === 'https:' || location.protocol === 'http:') && location.origin !== origin)
|
|
76
|
+
target = location.origin;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// An unparsable Location: say only that it redirected.
|
|
80
|
+
}
|
|
81
|
+
// Public, One and Identity may share an origin, so the caller's service decides the option.
|
|
82
|
+
const option = named ?? optionFor(config, origin);
|
|
83
|
+
const message = !target
|
|
84
|
+
? `${origin} answered with a redirect (HTTP ${response.status}), which the CLI does not follow.`
|
|
85
|
+
: option
|
|
86
|
+
? `${origin} redirected (HTTP ${response.status}) to ${target}. Set ${option} to that origin.`
|
|
87
|
+
: `${origin} redirected (HTTP ${response.status}) to ${target}, which the CLI does not follow.`;
|
|
88
|
+
return new NetworkError(message, 'UNEXPECTED_REDIRECT');
|
|
36
89
|
}
|
|
37
90
|
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
38
91
|
/** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */
|
|
@@ -97,22 +150,33 @@ export function anySignal(signals) {
|
|
|
97
150
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
98
151
|
return { signal: controller.signal, dispose };
|
|
99
152
|
}
|
|
100
|
-
/**
|
|
101
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Keep the timeout active until the complete response body has been read. `option` names the option that sets the
|
|
155
|
+
* service's origin, for the message when it redirects.
|
|
156
|
+
*/
|
|
157
|
+
export async function requestText(config, url, { option, ...init } = {}) {
|
|
102
158
|
const controller = new AbortController();
|
|
103
159
|
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
104
160
|
const { signal, dispose } = init.signal
|
|
105
161
|
? anySignal([init.signal, controller.signal])
|
|
106
162
|
: { signal: controller.signal, dispose: () => undefined };
|
|
107
163
|
try {
|
|
108
|
-
// Never
|
|
109
|
-
// 'manual' is
|
|
110
|
-
const response = await fetch(url, { ...init, signal, redirect:
|
|
164
|
+
// Never follow redirects: they could carry token-bearing POST bodies (or credentials) elsewhere.
|
|
165
|
+
// 'manual' surfaces the 3xx, which is refused here unless the caller asked for it.
|
|
166
|
+
const response = await fetch(url, { ...init, signal, redirect: 'manual' });
|
|
167
|
+
if (init.redirect !== 'manual' && REDIRECT_STATUSES.has(response.status) && response.headers.has('location')) {
|
|
168
|
+
await response.body?.cancel().catch(() => undefined);
|
|
169
|
+
throw redirectError(config, url, response, option);
|
|
170
|
+
}
|
|
111
171
|
return { response, text: await readBoundedBody(response, signal) };
|
|
112
172
|
}
|
|
113
173
|
catch (error) {
|
|
114
174
|
if (error instanceof NetworkError)
|
|
115
175
|
throw error;
|
|
176
|
+
// Fetch refuses a header value (a token with a newline) before sending anything. Its message holds the value.
|
|
177
|
+
if (error instanceof TypeError && error.cause === undefined && /header/i.test(error.message)) {
|
|
178
|
+
throw Object.assign(new Error('A request header contains characters HTTP does not allow, so nothing was sent.'), { code: 'INVALID_ARGUMENT' });
|
|
179
|
+
}
|
|
116
180
|
if (init.signal?.aborted) {
|
|
117
181
|
throw new NetworkError('Request cancelled.', 'CANCELLED', error);
|
|
118
182
|
}
|
package/dist/lib/one-client.js
CHANGED
|
@@ -60,7 +60,13 @@ export function resolveToolDefinition(toolName, publicTools, oneBaseUrl) {
|
|
|
60
60
|
if (!isOneAuthRequiredTool(toolName))
|
|
61
61
|
return null;
|
|
62
62
|
const authRequired = ONE_AUTH_REQUIRED_TOOL_DEFINITIONS[toolName];
|
|
63
|
-
|
|
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 */ }
|
|
64
70
|
return {
|
|
65
71
|
...authRequired,
|
|
66
72
|
uiUrl: authRequired.uiUrl.startsWith('http')
|
|
@@ -102,6 +108,7 @@ export class VigilbaseOneClient {
|
|
|
102
108
|
}
|
|
103
109
|
const send = () => requestText(this.config, url, {
|
|
104
110
|
...init,
|
|
111
|
+
option: '--one-url',
|
|
105
112
|
headers: {
|
|
106
113
|
Accept: 'application/json',
|
|
107
114
|
...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
|
|
@@ -177,7 +184,7 @@ export class VigilbaseOneClient {
|
|
|
177
184
|
throw new VigilbaseApiError('Authentication required.', { status: 401 });
|
|
178
185
|
this.accessToken = token;
|
|
179
186
|
}
|
|
180
|
-
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 } });
|
|
181
188
|
let { response, text } = await load();
|
|
182
189
|
if (response.status === 401 && this.managed) {
|
|
183
190
|
const token = await managedAccessToken(this.config, this.accessToken);
|
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;
|