@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.
Files changed (73) hide show
  1. package/CLOUDFLARE.md +2 -2
  2. package/COMMANDS.md +111 -44
  3. package/LOCAL.md +99 -38
  4. package/ONE.md +1 -1
  5. package/README.md +215 -67
  6. package/dist/auth/acl.d.ts +6 -0
  7. package/dist/auth/acl.js +45 -0
  8. package/dist/auth/device-auth.js +1 -0
  9. package/dist/auth/provider.js +4 -4
  10. package/dist/auth/refresh.d.ts +5 -4
  11. package/dist/auth/refresh.js +82 -41
  12. package/dist/auth/store.d.ts +25 -0
  13. package/dist/auth/store.js +91 -11
  14. package/dist/bin.js +13 -1
  15. package/dist/cli.js +205 -81
  16. package/dist/commands/account.js +11 -7
  17. package/dist/commands/auth.d.ts +6 -0
  18. package/dist/commands/auth.js +75 -24
  19. package/dist/commands/billing.js +2 -2
  20. package/dist/commands/cloudflare.d.ts +1 -1
  21. package/dist/commands/cloudflare.js +31 -17
  22. package/dist/commands/helpers.js +3 -3
  23. package/dist/commands/index.d.ts +1 -0
  24. package/dist/commands/index.js +2 -1
  25. package/dist/commands/legacy-validation.d.ts +20 -0
  26. package/dist/commands/legacy-validation.js +77 -3
  27. package/dist/commands/local.js +186 -65
  28. package/dist/commands/one.js +16 -13
  29. package/dist/commands/public-tools.js +52 -27
  30. package/dist/commands/runs.js +5 -5
  31. package/dist/commands/security.d.ts +1 -1
  32. package/dist/commands/security.js +41 -37
  33. package/dist/commands/watchdog.js +5 -5
  34. package/dist/lib/banner.d.ts +5 -11
  35. package/dist/lib/banner.js +11 -31
  36. package/dist/lib/client.js +19 -1
  37. package/dist/lib/cloudflare-client.d.ts +2 -2
  38. package/dist/lib/config.d.ts +16 -0
  39. package/dist/lib/config.js +77 -19
  40. package/dist/lib/format.d.ts +1 -1
  41. package/dist/lib/format.js +75 -24
  42. package/dist/lib/help.js +57 -6
  43. package/dist/lib/network.d.ts +12 -3
  44. package/dist/lib/network.js +73 -9
  45. package/dist/lib/one-client.js +9 -2
  46. package/dist/lib/open-url.js +2 -1
  47. package/dist/lib/platform.d.ts +2 -0
  48. package/dist/lib/platform.js +5 -0
  49. package/dist/lib/progress.d.ts +18 -3
  50. package/dist/lib/progress.js +72 -10
  51. package/dist/lib/system-trust.d.ts +2 -0
  52. package/dist/lib/system-trust.js +6 -0
  53. package/dist/lib/tui.d.ts +1 -1
  54. package/dist/lib/tui.js +5 -7
  55. package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
  56. package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
  57. package/dist/local/cloudflare-checkup/cf/install.js +55 -24
  58. package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
  59. package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
  60. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
  61. package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
  62. package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
  63. package/dist/local/cloudflare-checkup/contract.js +80 -14
  64. package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
  65. package/dist/local/cloudflare-checkup/errors.js +36 -5
  66. package/dist/local/cloudflare-checkup/output.d.ts +6 -0
  67. package/dist/local/cloudflare-checkup/output.js +9 -0
  68. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
  69. package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
  70. package/dist/local/cloudflare-checkup/submit-client.js +7 -1
  71. package/dist/types.d.ts +2 -1
  72. package/esm-only.cjs +3 -0
  73. package/package.json +7 -4
@@ -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
- constructor(message, code, cause) {
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).origin;
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
- return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}). Use Node 22.19+ or Node 24.5+ for automatic system certificate trust. If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error);
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. Run \`vigilbase doctor\` for connectivity checks.`, code, error);
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
- /** Keep the timeout active until the complete response body has been read. */
101
- export async function requestText(config, url, init = {}) {
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 forward token-bearing POST bodies (or credentials) through redirects.
109
- // 'manual' is honoured because it surfaces the 3xx without following it.
110
- const response = await fetch(url, { ...init, signal, redirect: init.redirect === 'manual' ? 'manual' : 'error' });
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
  }
@@ -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
- const origin = oneBaseUrl.replace(/\/+$/, '');
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);
@@ -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.CI === '1' || env.CI === 'true')
27
+ if (isCi(env))
27
28
  return true;
28
29
  if (env.GITHUB_ACTIONS === 'true')
29
30
  return true;
@@ -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;
@@ -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
+ }
@@ -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
- * Disabled for --json, non-TTY, CI, and NO_COLOR-style quiet environments.
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 {};
@@ -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
- * Disabled for --json, non-TTY, CI, and NO_COLOR-style quiet environments.
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
- const line = `\r${spin} ${label} ${renderBar(soft)} ${elapsedSec}s`;
35
- stream.write(line);
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
- closed = true;
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.CI === '1' || env.CI === 'true')
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 {};
@@ -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 exits 130 after cleaning up). */
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 clean up, then exit 130) still run; with
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
- /** Set, and not `0` or `false`. */
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' || isSet(env.CI))
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 exits 130 after cleaning up). */
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
- const names = Object.keys(buildChildEnv(parent, { purpose: 'api', authMode, accountId: '0'.repeat(32), platform }));
70
- if (authMode === 'env' && !names.includes(CF_TOKEN_ENV))
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 staging directory. Staging
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 staging directory. Staging
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
- * Ctrl-C while npm runs: kill npm, wait briefly for it to exit, remove the
224
- * staging directory, then exit 130 (as `withCfRunner` does for cf).
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 onInterrupt(staging, running, before) {
227
- return () => {
228
- before?.();
229
- const child = running();
230
- const closed = child && child.exitCode === null && child.signalCode === null
231
- ? new Promise((resolve) => {
232
- child.once('close', () => resolve());
233
- setTimeout(resolve, 2_000).unref?.();
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 interrupted = onInterrupt(staging, () => npmChild, options.onInterrupt);
261
- process.once('SIGINT', interrupted);
262
- try {
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
- process.removeListener('SIGINT', interrupted);
297
- await rm(staging, { recursive: true, force: true }).catch(() => undefined);
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 ?? process.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;