@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,8 +1,11 @@
1
+ import { CheckupCancelledError, type CancelSignal, type CancelSignalSource } from '../errors.js';
1
2
  export declare const CF_CALL_TIMEOUT_MS = 45000;
2
3
  export declare const STDOUT_CAP_BYTES: number;
3
4
  /** `rulesets get` bodies can be large managed rulesets. */
4
5
  export declare const RULESET_STDOUT_CAP_BYTES: number;
5
6
  export declare const STDERR_CAP_BYTES: number;
7
+ /** After a cancel, how long withCfRunner waits for `work` to finish (or undo) what it was doing. */
8
+ export declare const WORK_SETTLE_MS = 2000;
6
9
  /**
7
10
  * Flags that skip confirmations, switch cf to local simulation or project
8
11
  * config, or send a request body. No allowlisted argv contains them; the
@@ -30,6 +33,9 @@ export declare class CfProcessRunner {
30
33
  readonly privateDir: string;
31
34
  private readonly active;
32
35
  private disposed;
36
+ private disposing;
37
+ /** Set by `cancel`: every pending and later call rejects with it. */
38
+ private cancelledBy;
33
39
  /** Set once the runner kills its children (abort or shutdown), so those kills are not read as timeouts. */
34
40
  private terminating;
35
41
  private constructor();
@@ -46,13 +52,22 @@ export declare class CfProcessRunner {
46
52
  get activeCount(): number;
47
53
  /** SIGTERM every live child, then SIGKILL whatever is still running after 2 s. */
48
54
  terminateAll(): Promise<void>;
55
+ /** Stop every child and remove the private directory; every caller waits for the same cleanup. */
49
56
  dispose(): Promise<void>;
57
+ /** The run was interrupted by `signal`: calls reject with CANCELLED from now on, then everything is disposed. */
58
+ cancel(signal: CancelSignal): Promise<void>;
59
+ get cancellation(): CheckupCancelledError | null;
50
60
  }
51
61
  /**
52
- * Run `work` with a private cf runner; the directory is removed afterwards and
53
- * on SIGINT, when live children get SIGTERM and then SIGKILL.
62
+ * Run `work` with a private cf runner; the directory is removed afterwards.
63
+ * On SIGINT, SIGTERM or SIGHUP live children get SIGTERM (then SIGKILL), and
64
+ * this rejects with CANCELLED (`CheckupCancelledError`) once the directory is
65
+ * gone and `work` has settled (every cf call rejects by then; whatever else it
66
+ * waits on gets WORK_SETTLE_MS), so no result of a cancelled run is printed and
67
+ * nothing `work` writes lands after CANCELLED. `work` checks `runner.cancellation`
68
+ * before (and after) writing anything.
54
69
  */
55
- export declare function withCfRunner<T>(entry: string, work: (runner: CfProcessRunner) => Promise<T>): Promise<T>;
70
+ export declare function withCfRunner<T>(entry: string, work: (runner: CfProcessRunner) => Promise<T>, signals?: CancelSignalSource): Promise<T>;
56
71
  export type CfFailure = {
57
72
  kind: 'node_unsupported';
58
73
  } | {
@@ -12,6 +12,7 @@ import { chmod, mkdtemp, rm } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import { stripVTControlCharacters } from 'node:util';
15
+ import { CANCEL_SIGNALS, CheckupCancelledError } from '../errors.js';
15
16
  import { CF_TOKEN_ENV } from './child-env.js';
16
17
  export const CF_CALL_TIMEOUT_MS = 45_000;
17
18
  export const STDOUT_CAP_BYTES = 8 * 1024 * 1024;
@@ -19,6 +20,8 @@ export const STDOUT_CAP_BYTES = 8 * 1024 * 1024;
19
20
  export const RULESET_STDOUT_CAP_BYTES = 16 * 1024 * 1024;
20
21
  export const STDERR_CAP_BYTES = 64 * 1024;
21
22
  const KILL_GRACE_MS = 2_000;
23
+ /** After a cancel, how long withCfRunner waits for `work` to finish (or undo) what it was doing. */
24
+ export const WORK_SETTLE_MS = 2_000;
22
25
  /**
23
26
  * Flags that skip confirmations, switch cf to local simulation or project
24
27
  * config, or send a request body. No allowlisted argv contains them; the
@@ -38,6 +41,9 @@ export class CfProcessRunner {
38
41
  privateDir;
39
42
  active = new Set();
40
43
  disposed = false;
44
+ disposing = null;
45
+ /** Set by `cancel`: every pending and later call rejects with it. */
46
+ cancelledBy = null;
41
47
  /** Set once the runner kills its children (abort or shutdown), so those kills are not read as timeouts. */
42
48
  terminating = false;
43
49
  constructor(entry, privateDir) {
@@ -51,11 +57,13 @@ export class CfProcessRunner {
51
57
  }
52
58
  run(argv, options) {
53
59
  assertSafeCfArgv(argv);
60
+ if (this.cancelledBy)
61
+ return Promise.reject(this.cancelledBy);
54
62
  if (this.disposed)
55
63
  return Promise.resolve(failedToStart());
56
64
  const timeoutMs = options.timeoutMs ?? CF_CALL_TIMEOUT_MS;
57
65
  const stdoutCap = options.stdoutCapBytes ?? STDOUT_CAP_BYTES;
58
- return new Promise((resolve) => {
66
+ return new Promise((resolve, reject) => {
59
67
  let child;
60
68
  try {
61
69
  child = spawn(process.execPath, [this.entry, ...argv], {
@@ -112,6 +120,10 @@ export class CfProcessRunner {
112
120
  settled = true;
113
121
  clearTimeout(timer);
114
122
  this.active.delete(child);
123
+ if (this.cancelledBy) {
124
+ reject(this.cancelledBy);
125
+ return;
126
+ }
115
127
  // A child killed by a signal we did not send for the stdout cap or a
116
128
  // shutdown was killed by the spawn timeout.
117
129
  if (signal !== null && !stdoutTooLarge && !this.terminating)
@@ -136,6 +148,8 @@ export class CfProcessRunner {
136
148
  */
137
149
  runInteractive(argv, env, options = {}) {
138
150
  assertSafeCfArgv(argv);
151
+ if (this.cancelledBy)
152
+ return Promise.reject(this.cancelledBy);
139
153
  return new Promise((resolve, reject) => {
140
154
  const child = spawn(process.execPath, [this.entry, ...argv], {
141
155
  shell: false, cwd: this.privateDir, env, stdio: ['inherit', options.stdoutToStderr ? 2 : 'inherit', 'inherit'], windowsHide: true,
@@ -147,7 +161,10 @@ export class CfProcessRunner {
147
161
  });
148
162
  child.once('close', (code) => {
149
163
  this.active.delete(child);
150
- resolve(typeof code === 'number' ? code : 1);
164
+ if (this.cancelledBy)
165
+ reject(this.cancelledBy);
166
+ else
167
+ resolve(typeof code === 'number' ? code : 1);
151
168
  });
152
169
  });
153
170
  }
@@ -178,32 +195,73 @@ export class CfProcessRunner {
178
195
  }, () => undefined);
179
196
  });
180
197
  }
181
- async dispose() {
182
- if (this.disposed)
183
- return;
184
- this.disposed = true;
185
- await this.terminateAll();
186
- await rm(this.privateDir, { recursive: true, force: true });
198
+ /** Stop every child and remove the private directory; every caller waits for the same cleanup. */
199
+ dispose() {
200
+ this.disposing ??= (async () => {
201
+ this.disposed = true;
202
+ await this.terminateAll();
203
+ await rm(this.privateDir, { recursive: true, force: true });
204
+ })();
205
+ return this.disposing;
206
+ }
207
+ /** The run was interrupted by `signal`: calls reject with CANCELLED from now on, then everything is disposed. */
208
+ cancel(signal) {
209
+ this.cancelledBy ??= new CheckupCancelledError(signal);
210
+ return this.dispose();
211
+ }
212
+ get cancellation() {
213
+ return this.cancelledBy;
187
214
  }
188
215
  }
189
216
  function failedToStart() {
190
217
  return { exitCode: null, stdout: '', stderr: '', timedOut: false, stdoutTooLarge: false, spawnFailed: true };
191
218
  }
219
+ /** Wait for `promise` to settle, at most `ms`. */
220
+ async function settleWithin(promise, ms) {
221
+ let timer;
222
+ await Promise.race([
223
+ promise.then(() => undefined, () => undefined),
224
+ new Promise((resolve) => { timer = setTimeout(resolve, ms); }),
225
+ ]);
226
+ clearTimeout(timer);
227
+ }
192
228
  /**
193
- * Run `work` with a private cf runner; the directory is removed afterwards and
194
- * on SIGINT, when live children get SIGTERM and then SIGKILL.
229
+ * Run `work` with a private cf runner; the directory is removed afterwards.
230
+ * On SIGINT, SIGTERM or SIGHUP live children get SIGTERM (then SIGKILL), and
231
+ * this rejects with CANCELLED (`CheckupCancelledError`) once the directory is
232
+ * gone and `work` has settled (every cf call rejects by then; whatever else it
233
+ * waits on gets WORK_SETTLE_MS), so no result of a cancelled run is printed and
234
+ * nothing `work` writes lands after CANCELLED. `work` checks `runner.cancellation`
235
+ * before (and after) writing anything.
195
236
  */
196
- export async function withCfRunner(entry, work) {
237
+ export async function withCfRunner(entry, work, signals = process) {
197
238
  const runner = await CfProcessRunner.create(entry);
198
- const onSignal = () => {
199
- void runner.dispose().finally(() => process.exit(130));
200
- };
201
- process.once('SIGINT', onSignal);
239
+ let interrupt = () => undefined;
240
+ const interrupted = new Promise((_resolve, reject) => { interrupt = reject; });
241
+ const listeners = CANCEL_SIGNALS.map((signal) => {
242
+ const listener = () => {
243
+ runner.cancel(signal).catch(() => undefined);
244
+ interrupt(runner.cancellation);
245
+ };
246
+ signals.on(signal, listener);
247
+ return { signal, listener };
248
+ });
249
+ let working;
202
250
  try {
203
- return await work(runner);
251
+ working = work(runner);
252
+ const result = await Promise.race([working, interrupted]);
253
+ if (runner.cancellation)
254
+ throw runner.cancellation;
255
+ return result;
256
+ }
257
+ catch (error) {
258
+ if (runner.cancellation && working)
259
+ await settleWithin(working, WORK_SETTLE_MS);
260
+ throw runner.cancellation ?? error;
204
261
  }
205
262
  finally {
206
- process.removeListener('SIGINT', onSignal);
263
+ for (const { signal, listener } of listeners)
264
+ signals.removeListener(signal, listener);
207
265
  await runner.dispose();
208
266
  }
209
267
  }
@@ -205,9 +205,9 @@ export declare const RulesetDetailData: z.ZodObject<{
205
205
  export declare const PageRuleData: z.ZodObject<{
206
206
  actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
207
207
  id: z.ZodEnum<{
208
- waf: "waf";
209
208
  security_level: "security_level";
210
209
  browser_check: "browser_check";
210
+ waf: "waf";
211
211
  disable_security: "disable_security";
212
212
  disable_performance: "disable_performance";
213
213
  }>;
@@ -560,7 +560,6 @@ export declare const SnapshotEntrySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
560
560
  kind: z.ZodLiteral<"zone_setting">;
561
561
  zoneId: z.ZodString;
562
562
  settingId: z.ZodEnum<{
563
- waf: "waf";
564
563
  ssl: "ssl";
565
564
  always_use_https: "always_use_https";
566
565
  min_tls_version: "min_tls_version";
@@ -575,6 +574,7 @@ export declare const SnapshotEntrySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
575
574
  minify: "minify";
576
575
  early_hints: "early_hints";
577
576
  rocket_loader: "rocket_loader";
577
+ waf: "waf";
578
578
  automatic_https_rewrites: "automatic_https_rewrites";
579
579
  security_header: "security_header";
580
580
  opportunistic_encryption: "opportunistic_encryption";
@@ -790,9 +790,9 @@ export declare const SnapshotEntrySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
790
790
  data: z.ZodArray<z.ZodObject<{
791
791
  actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
792
792
  id: z.ZodEnum<{
793
- waf: "waf";
794
793
  security_level: "security_level";
795
794
  browser_check: "browser_check";
795
+ waf: "waf";
796
796
  disable_security: "disable_security";
797
797
  disable_performance: "disable_performance";
798
798
  }>;
@@ -1263,7 +1263,6 @@ declare const SnapshotObjectSchema: z.ZodObject<{
1263
1263
  kind: z.ZodLiteral<"zone_setting">;
1264
1264
  zoneId: z.ZodString;
1265
1265
  settingId: z.ZodEnum<{
1266
- waf: "waf";
1267
1266
  ssl: "ssl";
1268
1267
  always_use_https: "always_use_https";
1269
1268
  min_tls_version: "min_tls_version";
@@ -1278,6 +1277,7 @@ declare const SnapshotObjectSchema: z.ZodObject<{
1278
1277
  minify: "minify";
1279
1278
  early_hints: "early_hints";
1280
1279
  rocket_loader: "rocket_loader";
1280
+ waf: "waf";
1281
1281
  automatic_https_rewrites: "automatic_https_rewrites";
1282
1282
  security_header: "security_header";
1283
1283
  opportunistic_encryption: "opportunistic_encryption";
@@ -1493,9 +1493,9 @@ declare const SnapshotObjectSchema: z.ZodObject<{
1493
1493
  data: z.ZodArray<z.ZodObject<{
1494
1494
  actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
1495
1495
  id: z.ZodEnum<{
1496
- waf: "waf";
1497
1496
  security_level: "security_level";
1498
1497
  browser_check: "browser_check";
1498
+ waf: "waf";
1499
1499
  disable_security: "disable_security";
1500
1500
  disable_performance: "disable_performance";
1501
1501
  }>;
@@ -1962,7 +1962,6 @@ export declare const CloudflareCheckupSnapshotV1Schema: z.ZodObject<{
1962
1962
  kind: z.ZodLiteral<"zone_setting">;
1963
1963
  zoneId: z.ZodString;
1964
1964
  settingId: z.ZodEnum<{
1965
- waf: "waf";
1966
1965
  ssl: "ssl";
1967
1966
  always_use_https: "always_use_https";
1968
1967
  min_tls_version: "min_tls_version";
@@ -1977,6 +1976,7 @@ export declare const CloudflareCheckupSnapshotV1Schema: z.ZodObject<{
1977
1976
  minify: "minify";
1978
1977
  early_hints: "early_hints";
1979
1978
  rocket_loader: "rocket_loader";
1979
+ waf: "waf";
1980
1980
  automatic_https_rewrites: "automatic_https_rewrites";
1981
1981
  security_header: "security_header";
1982
1982
  opportunistic_encryption: "opportunistic_encryption";
@@ -2192,9 +2192,9 @@ export declare const CloudflareCheckupSnapshotV1Schema: z.ZodObject<{
2192
2192
  data: z.ZodArray<z.ZodObject<{
2193
2193
  actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
2194
2194
  id: z.ZodEnum<{
2195
- waf: "waf";
2196
2195
  security_level: "security_level";
2197
2196
  browser_check: "browser_check";
2197
+ waf: "waf";
2198
2198
  disable_security: "disable_security";
2199
2199
  disable_performance: "disable_performance";
2200
2200
  }>;
@@ -2637,6 +2637,12 @@ export declare function createRuleBodyEncoder(): RuleBodyEncoder;
2637
2637
  export interface IpPseudonymizer {
2638
2638
  /** Pseudonymise one A/AAAA content string; memoised by the exact string. */
2639
2639
  pseudonymize(content: string): ProjectionResult<string>;
2640
+ /**
2641
+ * Pseudonymise one record list, all or nothing: when one content fails, the
2642
+ * numbering is left as it was, so later pseudonyms never skip a number
2643
+ * (`validateSnapshotSemantics` rejects a gap).
2644
+ */
2645
+ pseudonymizeAll(contents: readonly string[]): ProjectionResult<string[]>;
2640
2646
  }
2641
2647
  /**
2642
2648
  * Per-prefix sequential pseudonyms, applied in zone list order and record API
@@ -2672,7 +2678,8 @@ export interface AssembleSnapshotInput {
2672
2678
  * failure in list order) and keeps no `zone_ruleset` entries, so the scanner
2673
2679
  * never falls back to rule-less heads (874-891).
2674
2680
  * 2. IP pseudonymisation over `zone_dns_records` in zone list order and record
2675
- * order; a list with unrepresentable content becomes `collection_failed`.
2681
+ * order; a list with unrepresentable content becomes `collection_failed`
2682
+ * and uses up no pseudonym numbers.
2676
2683
  * 3. Canonical entry order (`expectedEntryKeys`); unexpected entries go last.
2677
2684
  * 4. Rule bodies encoded and deduplicated in that order.
2678
2685
  * The result is not validated: parse it with `CloudflareCheckupSnapshotV1Schema`.
@@ -2697,9 +2704,9 @@ export declare const LocalSessionResponseSchema: z.ZodObject<{
2697
2704
  requester: z.ZodString;
2698
2705
  expiresAt: z.ZodISODateTime;
2699
2706
  state: z.ZodEnum<{
2700
- completed: "completed";
2701
2707
  awaiting_upload: "awaiting_upload";
2702
2708
  processing: "processing";
2709
+ completed: "completed";
2703
2710
  }>;
2704
2711
  accepts: z.ZodObject<{
2705
2712
  schemaVersions: z.ZodArray<z.ZodString>;
@@ -579,12 +579,60 @@ export function validateSnapshotSemantics(snapshot, ctx) {
579
579
  if (!(started <= finished) || finished - started > SNAPSHOT_LIMITS.maxCollectionHours * 3_600_000) {
580
580
  issue('collection_window', ['collector', 'finishedAt']);
581
581
  }
582
- // 6. Credential tripwire over every string (and record key) in the snapshot.
582
+ // 6. DNS contents are `per_prefix_sequential/1` pseudonyms: each prefix
583
+ // numbers its addresses 1, 2, 3, … with no gap, so an address that is not a
584
+ // pseudonym (a real one, or numbers past the count) is rejected.
585
+ const numbersByPrefix = new Map();
586
+ entries.forEach((entry, i) => {
587
+ if (entry.kind !== 'zone_dns_records' || !entry.result.ok)
588
+ return;
589
+ entry.result.data.forEach((record, r) => {
590
+ const path = ['entries', i, 'result', 'data', r, 'content'];
591
+ const pseudonym = parsePseudonym(record.content);
592
+ if (pseudonym === null) {
593
+ issue('ip_not_pseudonymized', path);
594
+ return;
595
+ }
596
+ const numbers = numbersByPrefix.get(pseudonym.prefix) ?? new Map();
597
+ if (!numbers.has(pseudonym.n))
598
+ numbers.set(pseudonym.n, path);
599
+ numbersByPrefix.set(pseudonym.prefix, numbers);
600
+ });
601
+ });
602
+ for (const numbers of numbersByPrefix.values()) {
603
+ for (const [n, path] of numbers)
604
+ if (n > numbers.size)
605
+ issue('ip_not_pseudonymized', path);
606
+ }
607
+ // 7. Credential tripwire over every string (and record key) in the snapshot.
583
608
  walkStrings(snapshot, [], (value, path) => {
584
609
  if (containsSecretLikeValue(value))
585
610
  issue('secret_like_value', path, 'secret_like_value');
586
611
  });
587
612
  }
613
+ const PSEUDONYM_V4_PART_RE = /^(?:0|[1-9][0-9]{0,2})$/;
614
+ const PSEUDONYM_V6_N_RE = /^[1-9a-f][0-9a-f]{0,3}$/;
615
+ /**
616
+ * The family and prefix (`createIpPseudonymizer`'s counter key) and the number
617
+ * `n` of a pseudonym, or null when `content` is not one: IPv4 `a.b.${n >> 8}.${n & 255}`
618
+ * or IPv6 `${g0}:${g1}:${g2}:ffff:${n.toString(16)}`, with 1 <= n <= 65535.
619
+ */
620
+ function parsePseudonym(content) {
621
+ if (content.includes(':')) {
622
+ const groups = content.split(':');
623
+ if (groups.length !== 5 || groups[3] !== 'ffff' || !PSEUDONYM_V6_N_RE.test(groups[4]))
624
+ return null;
625
+ return { prefix: `6|${groups.slice(0, 3).join(':')}`, n: parseInt(groups[4], 16) };
626
+ }
627
+ const parts = content.split('.');
628
+ if (parts.length !== 4 || !PSEUDONYM_V4_PART_RE.test(parts[2]) || !PSEUDONYM_V4_PART_RE.test(parts[3]))
629
+ return null;
630
+ const high = Number(parts[2]);
631
+ const low = Number(parts[3]);
632
+ if (high > 255 || low > 255 || high + low === 0)
633
+ return null;
634
+ return { prefix: `4|${parts[0]}.${parts[1]}`, n: high * 256 + low };
635
+ }
588
636
  /**
589
637
  * The `wafAnalysis.issues` one reference to a rule body produces under a
590
638
  * ruleset phase, and the UTF-8 bytes of the rule text those issues print
@@ -1375,7 +1423,7 @@ const MAX_PSEUDONYMS_PER_PREFIX = 65_535;
1375
1423
  export function createIpPseudonymizer() {
1376
1424
  const memo = new Map();
1377
1425
  const counters = new Map();
1378
- return {
1426
+ const pseudonymizer = {
1379
1427
  pseudonymize(content) {
1380
1428
  const known = memo.get(content);
1381
1429
  if (known !== undefined)
@@ -1411,7 +1459,29 @@ export function createIpPseudonymizer() {
1411
1459
  memo.set(content, pseudonym);
1412
1460
  return { ok: true, data: pseudonym };
1413
1461
  },
1462
+ pseudonymizeAll(contents) {
1463
+ const savedCounters = new Map(counters);
1464
+ const added = [];
1465
+ const out = [];
1466
+ for (const content of contents) {
1467
+ const known = memo.has(content);
1468
+ const pseudonym = pseudonymizer.pseudonymize(content);
1469
+ if (!pseudonym.ok) {
1470
+ for (const key of added)
1471
+ memo.delete(key);
1472
+ counters.clear();
1473
+ for (const [key, n] of savedCounters)
1474
+ counters.set(key, n);
1475
+ return pseudonym;
1476
+ }
1477
+ if (!known)
1478
+ added.push(content);
1479
+ out.push(pseudonym.data);
1480
+ }
1481
+ return { ok: true, data: out };
1482
+ },
1414
1483
  };
1484
+ return pseudonymizer;
1415
1485
  }
1416
1486
  /**
1417
1487
  * Build the snapshot from collected entries. In order:
@@ -1420,7 +1490,8 @@ export function createIpPseudonymizer() {
1420
1490
  * failure in list order) and keeps no `zone_ruleset` entries, so the scanner
1421
1491
  * never falls back to rule-less heads (874-891).
1422
1492
  * 2. IP pseudonymisation over `zone_dns_records` in zone list order and record
1423
- * order; a list with unrepresentable content becomes `collection_failed`.
1493
+ * order; a list with unrepresentable content becomes `collection_failed`
1494
+ * and uses up no pseudonym numbers.
1424
1495
  * 3. Canonical entry order (`expectedEntryKeys`); unexpected entries go last.
1425
1496
  * 4. Rule bodies encoded and deduplicated in that order.
1426
1497
  * The result is not validated: parse it with `CloudflareCheckupSnapshotV1Schema`.
@@ -1463,20 +1534,15 @@ export function assembleSnapshot(input) {
1463
1534
  continue;
1464
1535
  const records = entry.result.data;
1465
1536
  const invalidContent = records.some((record) => !pseudonymFormatOk(record.content));
1466
- let failed = invalidContent ? 'invalid_output' : undefined;
1467
- const mapped = [];
1468
- for (const record of failed ? [] : records) {
1469
- const pseudonym = pseudonymizer.pseudonymize(record.content);
1470
- if (!pseudonym.ok) {
1471
- failed = pseudonym.detail;
1472
- break;
1473
- }
1474
- mapped.push({ ...record, content: pseudonym.data });
1475
- }
1537
+ const pseudonyms = invalidContent
1538
+ ? { ok: false, detail: 'invalid_output' }
1539
+ : pseudonymizer.pseudonymizeAll(records.map((record) => record.content));
1476
1540
  pseudonymized.set(entry, {
1477
1541
  kind: 'zone_dns_records',
1478
1542
  zoneId,
1479
- result: failed ? collectionFailedResult(failed) : { ok: true, data: mapped },
1543
+ result: pseudonyms.ok
1544
+ ? { ok: true, data: records.map((record, r) => ({ ...record, content: pseudonyms.data[r] })) }
1545
+ : collectionFailedResult(pseudonyms.detail),
1480
1546
  });
1481
1547
  }
1482
1548
  }
@@ -1,4 +1,4 @@
1
- export declare const LOCAL_ERROR_CODES: readonly ["NODE_UNSUPPORTED", "CF_NOT_FOUND", "CF_WRONG_BINARY", "CF_UNSUPPORTED_VERSION", "CF_ALLOWLIST_MISMATCH", "CF_INSTALL_FAILED", "CF_AUTH_REQUIRED", "CF_PERMISSION_DENIED", "CF_RATE_LIMITED", "CF_ERROR", "ACCOUNT_SELECTION_REQUIRED", "ACCOUNT_NOT_VISIBLE", "ACCOUNT_TOO_LARGE", "ACCOUNT_MISMATCH", "OUTPUT_EXISTS", "SNAPSHOT_INVALID", "SNAPSHOT_TOO_LARGE", "SNAPSHOT_STALE", "SNAPSHOT_SECRET_LIKE_VALUE", "EMAIL_INVALID", "EMAIL_CODE_INVALID", "VERIFICATION_INVALID", "VERIFICATION_EXPIRED", "SESSION_INVALID", "SESSION_EXPIRED", "SESSION_USED", "SESSION_BUSY", "SESSION_CLOSED", "SUBMIT_ORIGIN_UNTRUSTED", "CONFIRMATION_REQUIRED", "CLI_UPGRADE_REQUIRED", "LOCAL_MODE_DISABLED", "VIGILBASE_RATE_LIMITED", "VIGILBASE_CHALLENGED", "SUBMIT_FAILED"];
1
+ export declare const LOCAL_ERROR_CODES: readonly ["NODE_UNSUPPORTED", "CWD_UNAVAILABLE", "CF_NOT_FOUND", "CF_WRONG_BINARY", "CF_UNSUPPORTED_VERSION", "CF_ALLOWLIST_MISMATCH", "CF_INSTALL_FAILED", "CF_AUTH_REQUIRED", "CF_PERMISSION_DENIED", "CF_RATE_LIMITED", "CF_ERROR", "CANCELLED", "ACCOUNT_SELECTION_REQUIRED", "ACCOUNT_NOT_VISIBLE", "ACCOUNT_TOO_LARGE", "ACCOUNT_MISMATCH", "OUTPUT_EXISTS", "SNAPSHOT_INVALID", "SNAPSHOT_TOO_LARGE", "SNAPSHOT_STALE", "SNAPSHOT_SECRET_LIKE_VALUE", "EMAIL_INVALID", "EMAIL_CODE_INVALID", "VERIFICATION_INVALID", "VERIFICATION_EXPIRED", "SESSION_INVALID", "SESSION_EXPIRED", "SESSION_USED", "SESSION_BUSY", "SESSION_CLOSED", "SUBMIT_ORIGIN_UNTRUSTED", "CONFIRMATION_REQUIRED", "CLI_UPGRADE_REQUIRED", "LOCAL_MODE_DISABLED", "VIGILBASE_RATE_LIMITED", "VIGILBASE_CHALLENGED", "SUBMIT_FAILED"];
2
2
  export type LocalCheckupErrorCode = (typeof LOCAL_ERROR_CODES)[number];
3
3
  /** LOCAL_MODE_DISABLED: the browser checkup on the origin the CLI talks to (vigilbase.com, or a loopback dev server). */
4
4
  export declare function browserCheckupHint(origin: string): string;
@@ -12,6 +12,20 @@ export declare const LOCAL_ERROR_HINTS: Record<LocalCheckupErrorCode, string>;
12
12
  export declare const ENV_TOKEN_AUTH_HINT = "Set CLOUDFLARE_API_TOKEN in this shell to a valid read-only Cloudflare API token (read -rs CLOUDFLARE_API_TOKEN && export CLOUDFLARE_API_TOKEN) and run the same command again, or unset it (or use --cf-auth profile) to sign in with your browser instead.";
13
13
  /** SNAPSHOT_INVALID for an `inspect` or `submit` path with no file: nothing to collect again, so how to make one. */
14
14
  export declare const SNAPSHOT_MISSING_HINT = "Check the file path. To collect a snapshot into a file, run `npx @vigilbase/cli cloudflare-checkup collect --out <file>`.";
15
+ /** SNAPSHOT_INVALID for a file that cannot be read: its permissions, not its contents. */
16
+ export declare const SNAPSHOT_UNREADABLE_HINT = "Check the permissions of the file and of its directories, or copy it somewhere you can read it.";
17
+ /**
18
+ * A snapshot file that cannot be uploaded, from a command that only reads it
19
+ * (inspect, submit, or the re-read before the one command uploads): running
20
+ * that command again reads the same file, so how to collect a fresh one.
21
+ */
22
+ export declare const SNAPSHOT_INVALID_FILE_HINT = "The file is not a valid snapshot. Collect a fresh one with `npx @vigilbase/cli cloudflare-checkup` (or `npx @vigilbase/cli cloudflare-checkup collect --out <file>`).";
23
+ export declare const SNAPSHOT_STALE_FILE_HINT = "Snapshots are accepted for 24 hours. Collect a fresh one with `npx @vigilbase/cli cloudflare-checkup` (or `npx @vigilbase/cli cloudflare-checkup collect --out <file>`).";
24
+ export declare const SNAPSHOT_SECRET_FILE_HINT = "Remove the credential-like value from your Cloudflare settings. Collect a fresh one with `npx @vigilbase/cli cloudflare-checkup` (or `npx @vigilbase/cli cloudflare-checkup collect --out <file>`).";
25
+ /** CF_NOT_FOUND from the advanced commands (and `--cf-bin`), which never install cf. */
26
+ export declare const CF_NOT_FOUND_ADVANCED_HINT = "Run `npx @vigilbase/cli cloudflare-checkup` without --cf-bin (it can install cf), install cf with `npm install --global cf@1.0.0-beta.13`, or pass --cf-bin <path> to a cf package.";
27
+ /** CF_WRONG_BINARY when a cf package was found but `cf --version` fails. */
28
+ export declare const CF_DAMAGED_HINT = "Reinstall cf with `npm install --global cf@1.0.0-beta.13`, or run `npx @vigilbase/cli cloudflare-checkup` without --cf-bin and let it install the tested cf.";
15
29
  /** CF_PERMISSION_DENIED in env mode: the token lacks a read permission; the browser sign-in is the other way out. */
16
30
  export declare const ENV_TOKEN_PERMISSION_HINT = "The API token in CLOUDFLARE_API_TOKEN cannot read everything the checkup needs. Add the read permissions to it, or unset it (or use --cf-auth profile) to sign in with your browser instead, then run the same command again.";
17
31
  export declare class LocalCheckupError extends Error {
@@ -29,3 +43,22 @@ export declare class LocalCheckupError extends Error {
29
43
  get isRateLimited(): boolean;
30
44
  }
31
45
  export declare function isLocalCheckupError(error: unknown): error is LocalCheckupError;
46
+ /** The signals that cancel a cf run or the cf install, and the exit code each gives (128 + its number). */
47
+ export declare const CANCEL_EXIT_CODES: Readonly<{
48
+ readonly SIGINT: 130;
49
+ readonly SIGTERM: 143;
50
+ readonly SIGHUP: 129;
51
+ }>;
52
+ export type CancelSignal = keyof typeof CANCEL_EXIT_CODES;
53
+ export declare const CANCEL_SIGNALS: CancelSignal[];
54
+ /** Where cancellation is heard: the process, or a plain emitter in tests. */
55
+ export type CancelSignalSource = {
56
+ on(event: CancelSignal, listener: () => void): unknown;
57
+ removeListener(event: CancelSignal, listener: () => void): unknown;
58
+ };
59
+ /** CANCELLED: cf (or npm) was stopped and its files removed; `runCli` exits with `exitCode` and prints no result. */
60
+ export declare class CheckupCancelledError extends LocalCheckupError {
61
+ readonly signal: CancelSignal;
62
+ readonly exitCode: (typeof CANCEL_EXIT_CODES)[CancelSignal];
63
+ constructor(signal: CancelSignal);
64
+ }
@@ -11,8 +11,8 @@ import { CF_TOKEN_ENV } from './cf/child-env.js';
11
11
  import { PRODUCTION_ORIGIN } from './contract.js';
12
12
  export const LOCAL_ERROR_CODES = [
13
13
  // Environment and cf
14
- 'NODE_UNSUPPORTED', 'CF_NOT_FOUND', 'CF_WRONG_BINARY', 'CF_UNSUPPORTED_VERSION', 'CF_ALLOWLIST_MISMATCH',
15
- 'CF_INSTALL_FAILED', 'CF_AUTH_REQUIRED', 'CF_PERMISSION_DENIED', 'CF_RATE_LIMITED', 'CF_ERROR',
14
+ 'NODE_UNSUPPORTED', 'CWD_UNAVAILABLE', 'CF_NOT_FOUND', 'CF_WRONG_BINARY', 'CF_UNSUPPORTED_VERSION', 'CF_ALLOWLIST_MISMATCH',
15
+ 'CF_INSTALL_FAILED', 'CF_AUTH_REQUIRED', 'CF_PERMISSION_DENIED', 'CF_RATE_LIMITED', 'CF_ERROR', 'CANCELLED',
16
16
  // Account
17
17
  'ACCOUNT_SELECTION_REQUIRED', 'ACCOUNT_NOT_VISIBLE', 'ACCOUNT_TOO_LARGE', 'ACCOUNT_MISMATCH',
18
18
  // Files and snapshots
@@ -26,6 +26,8 @@ export const LOCAL_ERROR_CODES = [
26
26
  'VIGILBASE_RATE_LIMITED', 'VIGILBASE_CHALLENGED', 'SUBMIT_FAILED',
27
27
  ];
28
28
  const INSTALL_CF = '`npm install --global cf@1.0.0-beta.13`';
29
+ /** How to get a fresh snapshot, for hints of the commands that only read one (inspect, submit). */
30
+ const COLLECT_FRESH = 'Collect a fresh one with `npx @vigilbase/cli cloudflare-checkup` (or `npx @vigilbase/cli cloudflare-checkup collect --out <file>`).';
29
31
  /** LOCAL_MODE_DISABLED: the browser checkup on the origin the CLI talks to (vigilbase.com, or a loopback dev server). */
30
32
  export function browserCheckupHint(origin) {
31
33
  return `Run the Cloudflare Checkup in your browser instead: ${origin}/tools/cloudflare-checkup`;
@@ -33,6 +35,7 @@ export function browserCheckupHint(origin) {
33
35
  /** One fix per code; shown after the error and in `--json` as `hint`. */
34
36
  export const LOCAL_ERROR_HINTS = {
35
37
  NODE_UNSUPPORTED: 'Install Node.js 22.18 or newer from https://nodejs.org (cf runs under the same Node), then run the same command again.',
38
+ CWD_UNAVAILABLE: 'Change to a directory that exists and that you can read (for example `cd ~`), then run the same command again.',
36
39
  CF_NOT_FOUND: `Run the same command again in a terminal and let it install cf (add --yes when no terminal is available), or install it yourself with ${INSTALL_CF}.`,
37
40
  CF_WRONG_BINARY: `Another program named cf (for example the Cloud Foundry CLI, or a pnpm or Volta shim) was found. Run the same command again without --cf-bin and let it install cf, or pass --cf-bin "$(npm root -g)/cf".`,
38
41
  CF_UNSUPPORTED_VERSION: `Run the same command again without --cf-bin and let it install the tested cf 1.0.0-beta.13, or install it yourself with ${INSTALL_CF}.`,
@@ -45,13 +48,13 @@ export const LOCAL_ERROR_HINTS = {
45
48
  CF_ERROR: 'Run the same command again in a few minutes. If it persists, contact help@vigilbase.com.',
46
49
  ACCOUNT_SELECTION_REQUIRED: 'Run the same command again in a terminal to choose the account, or add --account-id <ACCOUNT_ID> with one of the IDs listed.',
47
50
  ACCOUNT_NOT_VISIBLE: 'Check that your Cloudflare user can read the account and its zones, then run the same command again.',
48
- ACCOUNT_TOO_LARGE: 'Local collection supports up to 100 zones. Use the browser token mode on vigilbase.com instead.',
51
+ ACCOUNT_TOO_LARGE: `Local collection supports up to 100 zones. ${browserCheckupHint(PRODUCTION_ORIGIN)}`,
49
52
  ACCOUNT_MISMATCH: 'This checkup is for a different Cloudflare account. Run the same command again without --account-id to start a new checkup.',
50
53
  OUTPUT_EXISTS: 'Choose another --out path, or pass --overwrite to replace the file.',
51
54
  SNAPSHOT_INVALID: 'The file is not a valid snapshot. Run the same command again to collect a fresh one.',
52
- SNAPSHOT_TOO_LARGE: 'The snapshot is over the upload limit. Use the browser token mode on vigilbase.com instead.',
55
+ SNAPSHOT_TOO_LARGE: `The snapshot is over the upload limit. ${browserCheckupHint(PRODUCTION_ORIGIN)}`,
53
56
  SNAPSHOT_STALE: 'Snapshots are accepted for 24 hours. Run the same command again to collect a fresh one.',
54
- SNAPSHOT_SECRET_LIKE_VALUE: 'The snapshot contains a value that looks like a credential. Remove it from the Cloudflare rule description or name, then run the same command again.',
57
+ SNAPSHOT_SECRET_LIKE_VALUE: 'The snapshot contains a value that looks like a credential. Remove it from your Cloudflare settings (a name, description or other text field), then run the same command again.',
55
58
  EMAIL_INVALID: 'Use your work email address (personal and disposable addresses are not accepted), then run the same command again.',
56
59
  EMAIL_CODE_INVALID: 'Run the same command again to get a new code, and enter the 6 digits from the newest email.',
57
60
  VERIFICATION_INVALID: 'Run the same command again to get a new code by email.',
@@ -68,6 +71,7 @@ export const LOCAL_ERROR_HINTS = {
68
71
  VIGILBASE_RATE_LIMITED: 'Too many requests to vigilbase.com. Wait as long as the error says (a few minutes if it says nothing), then run the same command again.',
69
72
  VIGILBASE_CHALLENGED: 'vigilbase.com asked for a browser challenge. Try another network, or contact help@vigilbase.com.',
70
73
  SUBMIT_FAILED: 'Run the same command again in a few minutes. If it persists, contact help@vigilbase.com.',
74
+ CANCELLED: 'Nothing was uploaded. Run the same command again when you are ready.',
71
75
  };
72
76
  /**
73
77
  * CF_AUTH_REQUIRED in env mode (`--cf-auth env`, or `auto` with the variable
@@ -77,6 +81,20 @@ export const LOCAL_ERROR_HINTS = {
77
81
  export const ENV_TOKEN_AUTH_HINT = `Set ${CF_TOKEN_ENV} in this shell to a valid read-only Cloudflare API token (read -rs ${CF_TOKEN_ENV} && export ${CF_TOKEN_ENV}) and run the same command again, or unset it (or use --cf-auth profile) to sign in with your browser instead.`;
78
82
  /** SNAPSHOT_INVALID for an `inspect` or `submit` path with no file: nothing to collect again, so how to make one. */
79
83
  export const SNAPSHOT_MISSING_HINT = 'Check the file path. To collect a snapshot into a file, run `npx @vigilbase/cli cloudflare-checkup collect --out <file>`.';
84
+ /** SNAPSHOT_INVALID for a file that cannot be read: its permissions, not its contents. */
85
+ export const SNAPSHOT_UNREADABLE_HINT = 'Check the permissions of the file and of its directories, or copy it somewhere you can read it.';
86
+ /**
87
+ * A snapshot file that cannot be uploaded, from a command that only reads it
88
+ * (inspect, submit, or the re-read before the one command uploads): running
89
+ * that command again reads the same file, so how to collect a fresh one.
90
+ */
91
+ export const SNAPSHOT_INVALID_FILE_HINT = `The file is not a valid snapshot. ${COLLECT_FRESH}`;
92
+ export const SNAPSHOT_STALE_FILE_HINT = `Snapshots are accepted for 24 hours. ${COLLECT_FRESH}`;
93
+ export const SNAPSHOT_SECRET_FILE_HINT = `Remove the credential-like value from your Cloudflare settings. ${COLLECT_FRESH}`;
94
+ /** CF_NOT_FOUND from the advanced commands (and `--cf-bin`), which never install cf. */
95
+ export const CF_NOT_FOUND_ADVANCED_HINT = `Run \`npx @vigilbase/cli cloudflare-checkup\` without --cf-bin (it can install cf), install cf with ${INSTALL_CF}, or pass --cf-bin <path> to a cf package.`;
96
+ /** CF_WRONG_BINARY when a cf package was found but `cf --version` fails. */
97
+ export const CF_DAMAGED_HINT = `Reinstall cf with ${INSTALL_CF}, or run \`npx @vigilbase/cli cloudflare-checkup\` without --cf-bin and let it install the tested cf.`;
80
98
  /** CF_PERMISSION_DENIED in env mode: the token lacks a read permission; the browser sign-in is the other way out. */
81
99
  export const ENV_TOKEN_PERMISSION_HINT = `The API token in ${CF_TOKEN_ENV} cannot read everything the checkup needs. Add the read permissions to it, or unset it (or use --cf-auth profile) to sign in with your browser instead, then run the same command again.`;
82
100
  export class LocalCheckupError extends Error {
@@ -103,3 +121,16 @@ export class LocalCheckupError extends Error {
103
121
  export function isLocalCheckupError(error) {
104
122
  return error instanceof LocalCheckupError;
105
123
  }
124
+ /** The signals that cancel a cf run or the cf install, and the exit code each gives (128 + its number). */
125
+ export const CANCEL_EXIT_CODES = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
126
+ export const CANCEL_SIGNALS = Object.keys(CANCEL_EXIT_CODES);
127
+ /** CANCELLED: cf (or npm) was stopped and its files removed; `runCli` exits with `exitCode` and prints no result. */
128
+ export class CheckupCancelledError extends LocalCheckupError {
129
+ signal;
130
+ exitCode;
131
+ constructor(signal) {
132
+ super('CANCELLED', 'Cancelled.');
133
+ this.signal = signal;
134
+ this.exitCode = CANCEL_EXIT_CODES[signal];
135
+ }
136
+ }
@@ -5,6 +5,12 @@ import type { EntryCounts } from './snapshot-file.js';
5
5
  * (`...` on an ASCII-only console).
6
6
  */
7
7
  export declare function displayText(value: string, max: number, ellipsis?: string): string;
8
+ /**
9
+ * A file name the user passed, in a line of plain output: controls, line
10
+ * breaks and invisible format characters become visible escapes (`\n`,
11
+ * `\u001b`), so a crafted name cannot add or rewrite lines.
12
+ */
13
+ export declare function displayFileName(value: string): string;
8
14
  /** `0123…cdef` (`0123...cdef` with the ASCII `ellipsis` of the terminal UI). */
9
15
  export declare function maskId(id: string, ellipsis?: string): string;
10
16
  /** `9f2c…a71e` */
@@ -14,6 +14,15 @@ export function displayText(value, max, ellipsis = '…') {
14
14
  const flat = sanitizeTerminal(value.replace(/[\s\u0085\u2028\u2029]+/g, ' ')).trim();
15
15
  return flat.length > max ? `${flat.slice(0, Math.max(0, max - ellipsis.length))}${ellipsis}` : flat;
16
16
  }
17
+ /**
18
+ * A file name the user passed, in a line of plain output: controls, line
19
+ * breaks and invisible format characters become visible escapes (`\n`,
20
+ * `\u001b`), so a crafted name cannot add or rewrite lines.
21
+ */
22
+ export function displayFileName(value) {
23
+ const named = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
24
+ return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, (char) => named[char] ?? `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`);
25
+ }
17
26
  /** `0123…cdef` (`0123...cdef` with the ASCII `ellipsis` of the terminal UI). */
18
27
  export function maskId(id, ellipsis = '…') {
19
28
  return id.length > 8 ? `${id.slice(0, 4)}${ellipsis}${id.slice(-4)}` : id;