@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.
Files changed (77) hide show
  1. package/CLOUDFLARE.md +2 -2
  2. package/COMMANDS.md +128 -42
  3. package/LOCAL.md +99 -38
  4. package/ONE.md +1 -1
  5. package/README.md +217 -66
  6. package/SECURITY.md +1 -1
  7. package/dist/auth/acl.d.ts +6 -0
  8. package/dist/auth/acl.js +45 -0
  9. package/dist/auth/device-auth.d.ts +3 -0
  10. package/dist/auth/device-auth.js +13 -3
  11. package/dist/auth/provider.js +4 -4
  12. package/dist/auth/refresh.d.ts +5 -4
  13. package/dist/auth/refresh.js +82 -41
  14. package/dist/auth/store.d.ts +25 -0
  15. package/dist/auth/store.js +91 -11
  16. package/dist/bin.js +13 -1
  17. package/dist/cli.js +205 -81
  18. package/dist/commands/account.js +11 -7
  19. package/dist/commands/auth.d.ts +6 -0
  20. package/dist/commands/auth.js +75 -24
  21. package/dist/commands/billing.js +2 -2
  22. package/dist/commands/cloudflare.d.ts +1 -1
  23. package/dist/commands/cloudflare.js +31 -17
  24. package/dist/commands/helpers.js +3 -3
  25. package/dist/commands/index.d.ts +1 -0
  26. package/dist/commands/index.js +2 -1
  27. package/dist/commands/legacy-validation.d.ts +20 -0
  28. package/dist/commands/legacy-validation.js +77 -3
  29. package/dist/commands/local.js +186 -65
  30. package/dist/commands/one.js +16 -13
  31. package/dist/commands/public-tools.js +66 -35
  32. package/dist/commands/runs.js +5 -5
  33. package/dist/commands/security.d.ts +1 -1
  34. package/dist/commands/security.js +41 -37
  35. package/dist/commands/watchdog.js +5 -5
  36. package/dist/lib/banner.d.ts +5 -11
  37. package/dist/lib/banner.js +11 -31
  38. package/dist/lib/client.d.ts +9 -0
  39. package/dist/lib/client.js +31 -2
  40. package/dist/lib/cloudflare-client.d.ts +2 -2
  41. package/dist/lib/config.d.ts +16 -0
  42. package/dist/lib/config.js +77 -19
  43. package/dist/lib/format.d.ts +5 -1
  44. package/dist/lib/format.js +110 -35
  45. package/dist/lib/help.js +57 -6
  46. package/dist/lib/network.d.ts +12 -3
  47. package/dist/lib/network.js +73 -9
  48. package/dist/lib/one-client.d.ts +2 -0
  49. package/dist/lib/one-client.js +86 -12
  50. package/dist/lib/open-url.js +2 -1
  51. package/dist/lib/platform.d.ts +2 -0
  52. package/dist/lib/platform.js +5 -0
  53. package/dist/lib/progress.d.ts +18 -3
  54. package/dist/lib/progress.js +72 -10
  55. package/dist/lib/system-trust.d.ts +2 -0
  56. package/dist/lib/system-trust.js +6 -0
  57. package/dist/lib/tui.d.ts +1 -1
  58. package/dist/lib/tui.js +5 -7
  59. package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
  60. package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
  61. package/dist/local/cloudflare-checkup/cf/install.js +55 -24
  62. package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
  63. package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
  64. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
  65. package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
  66. package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
  67. package/dist/local/cloudflare-checkup/contract.js +80 -14
  68. package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
  69. package/dist/local/cloudflare-checkup/errors.js +36 -5
  70. package/dist/local/cloudflare-checkup/output.d.ts +6 -0
  71. package/dist/local/cloudflare-checkup/output.js +9 -0
  72. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
  73. package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
  74. package/dist/local/cloudflare-checkup/submit-client.js +7 -1
  75. package/dist/types.d.ts +6 -1
  76. package/esm-only.cjs +3 -0
  77. package/package.json +7 -4
@@ -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
- 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 */ }
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`.', { status: response.status, body: parsed, code: errorDetails.code, hint });
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})`, { status: response.status, body: parsed, code: errorDetails.code, hint });
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.', { status: response.status, body: parsed, code: errorDetails.code });
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
- return this.requestJson(`/api/tools/${encodeURIComponent(toolName)}/execute`, {
287
- method: 'POST',
288
- headers: { 'Content-Type': 'application/json' },
289
- body: JSON.stringify({ target }),
290
- }, { allowStatuses: [422] });
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
+ }
@@ -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;