@vigilbase/cli 0.2.3 → 0.2.5

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 (75) hide show
  1. package/CLOUDFLARE.md +2 -2
  2. package/COMMANDS.md +111 -44
  3. package/LOCAL.md +99 -38
  4. package/ONE.md +2 -2
  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/one-error-hints.d.ts +1 -0
  47. package/dist/lib/one-error-hints.js +10 -0
  48. package/dist/lib/open-url.js +2 -1
  49. package/dist/lib/platform.d.ts +2 -0
  50. package/dist/lib/platform.js +5 -0
  51. package/dist/lib/progress.d.ts +18 -3
  52. package/dist/lib/progress.js +72 -10
  53. package/dist/lib/system-trust.d.ts +2 -0
  54. package/dist/lib/system-trust.js +6 -0
  55. package/dist/lib/tui.d.ts +1 -1
  56. package/dist/lib/tui.js +5 -7
  57. package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
  58. package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
  59. package/dist/local/cloudflare-checkup/cf/install.js +55 -24
  60. package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
  61. package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
  62. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
  63. package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
  64. package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
  65. package/dist/local/cloudflare-checkup/contract.js +80 -14
  66. package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
  67. package/dist/local/cloudflare-checkup/errors.js +36 -5
  68. package/dist/local/cloudflare-checkup/output.d.ts +6 -0
  69. package/dist/local/cloudflare-checkup/output.js +9 -0
  70. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
  71. package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
  72. package/dist/local/cloudflare-checkup/submit-client.js +7 -1
  73. package/dist/types.d.ts +2 -1
  74. package/esm-only.cjs +3 -0
  75. package/package.json +7 -4
@@ -12,7 +12,7 @@
12
12
  * cf owns the Cloudflare login: this process never reads, prints, stores or
13
13
  * uploads a Cloudflare credential. See LOCAL.md.
14
14
  */
15
- import { access, constants, stat } from 'node:fs/promises';
15
+ import { access, constants, lstat, rm, stat } from 'node:fs/promises';
16
16
  import { homedir } from 'node:os';
17
17
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
18
18
  import { z } from 'zod';
@@ -21,9 +21,9 @@ import { resolveConfigDir } from '../lib/config.js';
21
21
  import { isHeadlessEnvironment, openUrlInBrowser } from '../lib/open-url.js';
22
22
  import { shouldShowProgress } from '../lib/progress.js';
23
23
  import { CF_SUPPORTED, NOT_COLLECTABLE_ZONE_KINDS, PRODUCTION_ORIGIN, RECOMMENDED_CF_SCOPES, SNAPSHOT_COLLECTOR_NAME, SNAPSHOT_LIMITS, SNAPSHOT_SCHEMA_VERSION, } from '../local/cloudflare-checkup/contract.js';
24
- import { ENV_TOKEN_AUTH_HINT, LOCAL_ERROR_HINTS, LocalCheckupError } from '../local/cloudflare-checkup/errors.js';
24
+ import { CF_DAMAGED_HINT, CF_NOT_FOUND_ADVANCED_HINT, ENV_TOKEN_AUTH_HINT, LOCAL_ERROR_HINTS, LocalCheckupError, SNAPSHOT_STALE_FILE_HINT, } from '../local/cloudflare-checkup/errors.js';
25
25
  import { CF_TOKEN_ENV, buildChildEnv, childEnvVariableNames, hasEnvToken, missingEnvTokenMessage, strippedTokenNotice, } from '../local/cloudflare-checkup/cf/child-env.js';
26
- import { assertLocalRuntimeSupported, checkCfVersion, isNodeSupported, locateCf } from '../local/cloudflare-checkup/cf/locate.js';
26
+ import { assertLocalRuntimeSupported, checkCfVersion, currentDirectory, isNodeSupported, locateCf } from '../local/cloudflare-checkup/cf/locate.js';
27
27
  import { CfInstallError, MANAGED_CF, ensurePrivateDir, findManagedCf, installManagedCf, managedCfRoot, removeManagedCf } from '../local/cloudflare-checkup/cf/install.js';
28
28
  import { withCfRunner } from '../local/cloudflare-checkup/cf/run-cf.js';
29
29
  import { CRITICAL_TEMPLATES, DEFAULT_PROFILE, PREFLIGHT_ACCOUNT_ID, TEMPLATES, TEMPLATE_IDS, dryRunMatches, loginArgv, logoutArgv, preflightChecks, templateDisplay, validateProfileName, versionArgv, whoamiArgv, } from '../local/cloudflare-checkup/cf/allowlist.js';
@@ -34,9 +34,9 @@ import { accountName, countEntries, countEntriesByKind, dataCategories, loadSnap
34
34
  import { confirmDefaultYes, confirmUpload, hasInteractiveInput, promptLine } from '../local/cloudflare-checkup/prompts.js';
35
35
  import { SAME_ADDRESS_WAIT, emailLocale, parseEmailFlag, verifyEmail } from '../local/cloudflare-checkup/email-verification.js';
36
36
  import { SubmitClient, VerificationClient, assertServerAccepts, displayRequester, isLoopbackOrigin, resolveSubmitOrigin, } from '../local/cloudflare-checkup/submit-client.js';
37
- import { displayText, formatBytes, formatClock, formatDuration, maskId, readsSummary, shortSha, startCountsProgress } from '../local/cloudflare-checkup/output.js';
37
+ import { displayFileName, displayText, formatBytes, formatClock, formatDuration, maskId, readsSummary, shortSha, startCountsProgress } from '../local/cloudflare-checkup/output.js';
38
38
  import { CheckupTui } from '../local/cloudflare-checkup/terminal-ui.js';
39
- import { EXIT, assertCommandFlags, ok, outputJson } from './helpers.js';
39
+ import { EXIT, ok, outputJson } from './helpers.js';
40
40
  /**
41
41
  * How the page, the docs and the hints spell the command: the bare package,
42
42
  * no version. vigilbase.com refuses CLIs older than it accepts, and that
@@ -63,17 +63,22 @@ uploads it.
63
63
  Options (the default command, also called run):
64
64
  --email <address> Your work email (asked for when omitted)
65
65
  --account-id <id> Skip the account picker
66
- --out <file> Snapshot path (default: <config dir>/checkups/…);
67
- --overwrite replaces an existing file
66
+ --out <file> Where to save the snapshot (default:
67
+ <config dir>/checkups/…); --overwrite
68
+ replaces an existing file
68
69
  --yes Install cf and upload without asking
69
70
  (--confirm answers only the upload question)
70
71
  --no-open Print the results link; do not open a browser
71
72
  --cf-auth auto|profile|env|default
72
- auto (default): env when ${CF_TOKEN_ENV} is set
73
- in this shell (cf reads it itself), else profile
73
+ auto (default): env when
74
+ ${CF_TOKEN_ENV} is set in this shell
75
+ (cf reads it itself), else profile
74
76
  --profile <name> cf profile (default ${DEFAULT_PROFILE})
75
77
  --cf-bin <path> Use this cf package; never install one
76
- --max-requests-per-5min <n> Cloudflare request budget, 100..1100 (default 900)
78
+ --max-requests-per-5min <n> Cloudflare requests per 5 minutes,
79
+ 100..1100 (default 900)
80
+ --base-url <origin> Upload origin: ${PRODUCTION_ORIGIN}
81
+ (default) or a loopback origin
77
82
  --timeout <milliseconds> · --json
78
83
 
79
84
  Without a terminal, add --yes: the email (unless --email is given) and the
@@ -82,18 +87,22 @@ code are read from stdin, one line each, after their prompts on stderr.
82
87
  Advanced commands:
83
88
  login Create or refresh the read-only cf profile
84
89
  [--profile] [--no-open] [--dry-run] [--cf-bin]
85
- doctor Check Node, cf, the offline command mapping and cf auth
90
+ doctor Check Node, cf, the offline command mapping
91
+ and cf auth
86
92
  accounts List the Cloudflare account IDs cf can read
87
- collect --out <file> Collect only; --dry-run shows every cf command (offline)
93
+ collect --out <file> Collect only [--overwrite] [--account-id];
94
+ --dry-run shows every cf command (offline)
88
95
  inspect <file> Validate a snapshot and show what it contains
89
- submit <file> Verify your email, then upload a collected snapshot
90
- [--email] [--yes] [--no-open]
91
- logout Delete the read-only cf profile and the cf this CLI
92
- installed [--profile] [--cf-bin]
96
+ submit <file> Verify your email, then upload a snapshot
97
+ [--email] [--yes|--confirm] [--no-open]
98
+ [--base-url] [--timeout]
99
+ logout Delete the read-only cf profile and the cf
100
+ this CLI installed [--profile] [--cf-bin]
93
101
 
94
102
  Results go to the verified email address. Uploads go only to
95
103
  ${PRODUCTION_ORIGIN} (or a loopback --base-url).
96
- Exit codes: 0 success, 1 error, 2 rate limited. See LOCAL.md for details.`;
104
+ Exit codes: 0 success, 1 error, 2 rate limited, 130 cancelled (Ctrl-C).
105
+ See LOCAL.md for details.`;
97
106
  const CF_FLAGS = ['profile', 'cf-auth', 'cf-bin'];
98
107
  const COLLECT_FLAGS = [...CF_FLAGS, 'account-id', 'out', 'isOverwrite', 'max-requests-per-5min'];
99
108
  const SUBMIT_FLAGS = ['email', 'isNoOpen', 'isConfirm', 'isYes', 'base-url', 'timeout'];
@@ -107,15 +116,18 @@ const ALLOWED_FLAGS = {
107
116
  submit: [...SUBMIT_FLAGS, 'isJson'],
108
117
  logout: ['profile', 'cf-bin', 'isJson'],
109
118
  };
110
- /** Validate flags before any spawn, file or network access. */
119
+ /**
120
+ * Validate flags before any spawn, file or network access. Every refused flag
121
+ * (including the global origin flags, which local commands do not take) is
122
+ * named with the subcommand.
123
+ */
111
124
  function assertLocalFlags(ctx, subcommand) {
112
125
  const allowed = ALLOWED_FLAGS[subcommand];
113
- assertCommandFlags(ctx, allowed);
114
- // assertCommandFlags always permits the global origin flags; local commands do not.
115
- for (const key of ['base-url', 'one-url', 'auth-url', 'client-id', 'timeout']) {
116
- if (ctx.values[key] !== undefined && !allowed.includes(key)) {
117
- throw new Error(`--${key} is not supported for cloudflare-checkup ${subcommand}.`);
118
- }
126
+ for (const [key, value] of Object.entries(ctx.values)) {
127
+ if (value === undefined || value === false || ['isHelp', 'isVersion'].includes(key) || allowed.includes(key))
128
+ continue;
129
+ const flag = key.startsWith('is') ? key.slice(2).replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() : key;
130
+ throw new Error(`--${flag} is not supported for cloudflare-checkup ${subcommand}.`);
119
131
  }
120
132
  }
121
133
  const accountIdSchema = z.string()
@@ -167,7 +179,8 @@ function parseLocalOptions(ctx, subcommand, args) {
167
179
  authAuto: requested === 'auto',
168
180
  ...(cfBin !== undefined ? { cfBin } : {}),
169
181
  ...(values['account-id'] !== undefined ? { accountId: parse(accountIdSchema, values['account-id']) } : {}),
170
- ...(out !== undefined ? { out: resolve(out) } : {}),
182
+ // A relative --out never reaches resolve()'s implicit process.cwd(), which fails raw in a deleted directory.
183
+ ...(out !== undefined ? { out: isAbsolute(out) ? resolve(out) : resolve(currentDirectory(), out) } : {}),
171
184
  maxRequests: parseRequestBudget(values['max-requests-per-5min']),
172
185
  // Environment variables never choose the submit origin.
173
186
  ...(subcommand === 'submit' || subcommand === 'run' ? { origin: resolveSubmitOrigin(values['base-url']) } : {}),
@@ -296,13 +309,16 @@ function warn(ctx, warnings, warning) {
296
309
  if (ctx.mode !== 'json')
297
310
  ctx.writer.stderr(`Warning: ${warning.message}`);
298
311
  }
312
+ /** `--cf-auth env` needs the token variable in this shell (presence only; the value is never read). */
313
+ function assertEnvToken(ctx, options) {
314
+ if (options.auth.authMode === 'env' && !hasEnvToken(ctx.env)) {
315
+ throw new LocalCheckupError('CF_AUTH_REQUIRED', missingEnvTokenMessage(), { hint: ENV_TOKEN_AUTH_HINT });
316
+ }
317
+ }
299
318
  /** Env-mode presence check and the profile-mode notice; the value is never read. */
300
319
  function checkAuthEnvironment(ctx, options, warnings) {
301
- if (options.auth.authMode === 'env') {
302
- if (!hasEnvToken(ctx.env))
303
- throw new LocalCheckupError('CF_AUTH_REQUIRED', missingEnvTokenMessage(), { hint: ENV_TOKEN_AUTH_HINT });
304
- }
305
- else if (hasEnvToken(ctx.env)) {
320
+ assertEnvToken(ctx, options);
321
+ if (options.auth.authMode !== 'env' && hasEnvToken(ctx.env)) {
306
322
  warn(ctx, warnings, { code: 'ENV_TOKEN_NOT_USED', message: strippedTokenNotice(options.auth.authMode) });
307
323
  }
308
324
  }
@@ -311,13 +327,29 @@ async function managedDirFor(options) {
311
327
  return null;
312
328
  return findManagedCf(options.configDir);
313
329
  }
314
- async function locateAndWarn(ctx, options, warnings, managedDir) {
330
+ /**
331
+ * Find cf (filesystem only, nothing is spawned). Only the one command without
332
+ * `--cf-bin` can install cf, so everywhere else CF_NOT_FOUND names the one
333
+ * command, npm and `--cf-bin` instead.
334
+ */
335
+ async function locate(ctx, options, managedDir) {
315
336
  const managed = managedDir === undefined ? await managedDirFor(options) : managedDir;
316
- const location = await locateCf({
317
- ...(options.cfBin !== undefined ? { cfBin: options.cfBin } : {}),
318
- ...(managed ? { managedDir: managed } : {}),
319
- env: ctx.env,
320
- });
337
+ try {
338
+ return await locateCf({
339
+ ...(options.cfBin !== undefined ? { cfBin: options.cfBin } : {}),
340
+ ...(managed ? { managedDir: managed } : {}),
341
+ env: ctx.env,
342
+ });
343
+ }
344
+ catch (error) {
345
+ if (error instanceof LocalCheckupError && error.code === 'CF_NOT_FOUND' && (options.subcommand !== 'run' || options.cfBin !== undefined)) {
346
+ throw new LocalCheckupError('CF_NOT_FOUND', error.message, { hint: CF_NOT_FOUND_ADVANCED_HINT });
347
+ }
348
+ throw error;
349
+ }
350
+ }
351
+ async function locateAndWarn(ctx, options, warnings, managedDir) {
352
+ const location = await locate(ctx, options, managedDir);
321
353
  if (location.insideCwd) {
322
354
  warn(ctx, warnings, { code: 'CF_INSIDE_CWD', message: 'The cf package found lies inside the current directory; make sure it is the copy you installed.' });
323
355
  }
@@ -331,7 +363,7 @@ async function checkVersion(ctx, runner, location, authMode, warnings) {
331
363
  if (result.exitCode !== 0) {
332
364
  if (/cf requires Node\.js/.test(result.stderr))
333
365
  throw new LocalCheckupError('NODE_UNSUPPORTED', 'cf refused to run under this Node.js version.');
334
- throw new LocalCheckupError('CF_WRONG_BINARY', 'cf --version failed; the package may be damaged. Reinstall cf.');
366
+ throw new LocalCheckupError('CF_WRONG_BINARY', 'cf --version failed; the package may be damaged.', { hint: CF_DAMAGED_HINT });
335
367
  }
336
368
  const prepared = checkCfVersion(result.stdout, location.packageVersion);
337
369
  if (!prepared.tested) {
@@ -505,6 +537,9 @@ async function writeToTarget(target, accountId, startedAt, text, overwrite) {
505
537
  async function runLogin(ctx, options) {
506
538
  const argv = loginArgv(options.auth.profile, RECOMMENDED_CF_SCOPES, { noBrowser: Boolean(ctx.values.isNoOpen) });
507
539
  if (ctx.values.isDryRun) {
540
+ // A --cf-bin that a real login would refuse is refused here too (filesystem only, nothing runs).
541
+ if (options.cfBin !== undefined)
542
+ await locate(ctx, options);
508
543
  if (ctx.mode === 'json')
509
544
  outputJson(ctx, { success: true, dryRun: true, argv: ['cf', ...argv] });
510
545
  else
@@ -571,8 +606,8 @@ async function runLogout(ctx, options) {
571
606
  });
572
607
  }
573
608
  catch (error) {
574
- // A cf that cannot run must not stop a broken managed install from being removed (unless --cf-bin named it).
575
- if (options.cfBin !== undefined || !(error instanceof LocalCheckupError))
609
+ // A cf that cannot run must not stop a broken managed install from being removed (unless --cf-bin named it); Ctrl-C stops logout.
610
+ if (options.cfBin !== undefined || !(error instanceof LocalCheckupError) || error.code === 'CANCELLED')
576
611
  throw error;
577
612
  cfProblem = error;
578
613
  }
@@ -645,6 +680,8 @@ async function runDoctor(ctx, options) {
645
680
  authenticated: false,
646
681
  scopes: 'unknown',
647
682
  };
683
+ /** Whether the sign-in was looked at; without a cf that runs it cannot be. */
684
+ let authChecked = false;
648
685
  let runtimeOk = true;
649
686
  try {
650
687
  assertLocalRuntimeSupported();
@@ -678,6 +715,7 @@ async function runDoctor(ctx, options) {
678
715
  if (preflight.mismatched.length > 0) {
679
716
  problems.push({ code: 'CF_ALLOWLIST_MISMATCH', message: `${preflight.mismatched.length} of ${preflight.total} command templates do not map in this cf release.` });
680
717
  }
718
+ authChecked = true;
681
719
  try {
682
720
  checkAuthEnvironment(ctx, options, warnings);
683
721
  auth = doctorAuth(await createScheduler(ctx, runner, options.auth, DOCTOR_BUDGET).start(), options.auth.authMode, options.auth.profile);
@@ -692,7 +730,7 @@ async function runDoctor(ctx, options) {
692
730
  }
693
731
  const success = nodeSupported && cf.supported && commands.mismatched.length === 0 && auth.authenticated && problems.length === 0;
694
732
  if (ctx.mode === 'json') {
695
- outputJson(ctx, { success, node: { version: process.versions.node, supported: nodeSupported }, cf, auth, commands, warnings, problems });
733
+ outputJson(ctx, { success, node: { version: process.versions.node, supported: nodeSupported }, cf, auth: { ...auth, checked: authChecked }, commands, warnings, problems });
696
734
  return success ? EXIT.OK : EXIT.ERROR;
697
735
  }
698
736
  const mark = (isOk) => (isOk ? '✓' : '✗');
@@ -700,7 +738,8 @@ async function runDoctor(ctx, options) {
700
738
  ctx.writer.stdout(`${mark(cf.supported)} cf ${cf.version ?? (cf.found ? '(version check failed)' : 'not found')}${cf.path ? ` ${cf.path}` : ''}${cf.supported && !cf.tested ? ' (untested)' : ''}`);
701
739
  ctx.writer.stdout(`${mark(commands.mismatched.length === 0 && commands.verified > 0)} ${commands.verified} command templates mapped offline${commands.mismatched.length ? `; not mapped: ${commands.mismatched.join(', ')}` : ''}`);
702
740
  const authLabel = options.auth.authMode === 'profile' ? `profile "${options.auth.profile}"` : options.auth.authMode === 'env' ? 'API token from this shell' : 'default profile';
703
- ctx.writer.stdout(`${mark(auth.authenticated)} cf auth (${authLabel}): ${auth.authenticated ? 'signed in' : 'not signed in'}${auth.expiresAt ? ` · expires ${formatClock(auth.expiresAt)}` : ''}`);
741
+ const authState = auth.authenticated ? 'signed in' : authChecked ? 'not signed in' : `not checked (${cf.found ? 'cf could not run' : 'cf not found'})`;
742
+ ctx.writer.stdout(`${mark(auth.authenticated)} cf auth (${authLabel}): ${authState}${auth.expiresAt ? ` · expires ${formatClock(auth.expiresAt)}` : ''}`);
704
743
  if (auth.authenticated && auth.scopes === 'unknown')
705
744
  ctx.writer.stdout(' scopes: unknown (API token permissions cannot be introspected)');
706
745
  if (auth.missingRecommendedScopes?.length)
@@ -748,6 +787,15 @@ const REQUEST_FORMULA = 'about 19 + extra pages + zones × (33 + rulesets per zo
748
787
  async function runCollectDryRun(ctx, options) {
749
788
  assertLocalRuntimeSupported();
750
789
  const warnings = [];
790
+ // What a real collect would say about the shell token, as warnings: a dry run makes no authenticated call.
791
+ try {
792
+ checkAuthEnvironment(ctx, options, warnings);
793
+ }
794
+ catch (error) {
795
+ if (!(error instanceof LocalCheckupError))
796
+ throw error;
797
+ warn(ctx, warnings, { code: error.code, message: `${error.message} Without it, a real collect would stop here.` });
798
+ }
751
799
  const location = await locateAndWarn(ctx, options, warnings);
752
800
  const { prepared, preflight } = await withCfRunner(location.entry, async (runner) => ({
753
801
  prepared: await checkVersion(ctx, runner, location, options.auth.authMode, warnings),
@@ -799,13 +847,19 @@ async function runCollectDryRun(ctx, options) {
799
847
  /**
800
848
  * Advisory pre-flight for --out, before any cf spawn, so a typo cannot throw
801
849
  * away a whole collection. writeSnapshotFile stays the race-safe final guard.
850
+ * The entry itself is looked at (lstat): any symlink, even a dangling one, is
851
+ * an existing file, which --overwrite replaces without following it.
802
852
  */
803
853
  async function assertOutWritable(out, overwrite) {
804
- const existing = await stat(out).catch(() => null);
854
+ const existing = await lstat(out).catch(() => null);
855
+ // With --overwrite, the final write also refuses a link to a directory (or another non-file).
856
+ const linked = existing?.isSymbolicLink() && overwrite ? await stat(out).catch(() => null) : null;
857
+ const entry = linked ?? existing;
858
+ if (entry && !entry.isFile() && !entry.isSymbolicLink()) {
859
+ throw new LocalCheckupError('OUTPUT_EXISTS', `--out names a ${entry.isDirectory() ? 'directory' : 'special file'}; it must name a regular file. Nothing was collected.`, { hint: 'Choose a file path, for example <directory>/snapshot.json.' });
860
+ }
805
861
  if (existing && !overwrite)
806
862
  throw new LocalCheckupError('OUTPUT_EXISTS', 'The --out file already exists; nothing was collected.');
807
- if (existing && !existing.isFile())
808
- throw new Error('--out must name a regular file; nothing was collected.');
809
863
  const dir = dirname(out);
810
864
  const parent = await stat(dir).catch(() => null);
811
865
  if (!parent || !parent.isDirectory())
@@ -828,10 +882,40 @@ async function prepareOutput(ctx, options, overwrite) {
828
882
  await ensurePrivateDir(dir);
829
883
  }
830
884
  catch {
831
- throw new Error(`Could not create ${displayPath(dir, ctx.env)} for the snapshot; pass --out <file> instead.`);
885
+ throw checkupsDirError(ctx, dir);
832
886
  }
833
887
  return { kind: 'default', dir };
834
888
  }
889
+ function checkupsDirError(ctx, dir) {
890
+ return new Error(`Could not create ${displayPath(dir, ctx.env)} for the snapshot; pass --out <file> instead.`);
891
+ }
892
+ /**
893
+ * Before the email: prepareOutput's checks without writing anything. The
894
+ * checkups directory exists as a real directory this user can write to, or
895
+ * its nearest existing parent is one, so it can be created later.
896
+ */
897
+ async function assertOutputPossible(ctx, options, overwrite) {
898
+ if (options.out !== undefined)
899
+ return assertOutWritable(options.out, overwrite);
900
+ const dir = join(options.configDir, 'checkups');
901
+ const writable = (target) => access(target, constants.W_OK | constants.X_OK).then(() => true, () => false);
902
+ const leaf = await lstat(dir).catch(() => null);
903
+ if (leaf) {
904
+ if (!leaf.isDirectory() || !(await writable(dir)))
905
+ throw checkupsDirError(ctx, dir);
906
+ return;
907
+ }
908
+ for (let current = dirname(dir);; current = dirname(current)) {
909
+ const info = await stat(current).catch(() => null);
910
+ if (info) {
911
+ if (!info.isDirectory() || !(await writable(current)))
912
+ throw checkupsDirError(ctx, dir);
913
+ return;
914
+ }
915
+ if (dirname(current) === current)
916
+ throw checkupsDirError(ctx, dir);
917
+ }
918
+ }
835
919
  /**
836
920
  * Why a located cf cannot be used, found before any authenticated call: none
837
921
  * at all, a different program, an unsupported version, or (after `--version`
@@ -1069,7 +1153,14 @@ async function collectSnapshot(ctx, options, session) {
1069
1153
  message: `The snapshot is larger than ${SNAPSHOT_LIMITS.defaultServerMaxBytes / 1024 / 1024} MiB, the default upload limit.`,
1070
1154
  });
1071
1155
  }
1156
+ // A cancelled run writes no snapshot, and undoes one that was being written when it was cancelled.
1157
+ if (runner.cancellation)
1158
+ throw runner.cancellation;
1072
1159
  const written = await writeToTarget(target, accountId, startedAt, text, overwrite);
1160
+ if (runner.cancellation) {
1161
+ await rm(written.path, { force: true }).catch(() => undefined);
1162
+ throw runner.cancellation;
1163
+ }
1073
1164
  return { ...written, snapshot, version: prepared.version, cfInvocations: scheduler.invocations };
1074
1165
  }
1075
1166
  finally {
@@ -1162,16 +1253,24 @@ function clientOptions(ctx, origin) {
1162
1253
  }
1163
1254
  /**
1164
1255
  * Before the email is asked for (or `--email` is used): vigilbase.com says
1165
- * whether local checkups are on and which CLI and snapshot schema it accepts,
1166
- * so a switched-off local mode (LOCAL_MODE_DISABLED) or an old CLI
1256
+ * whether local checkups are on and which CLI, snapshot schema and size it
1257
+ * accepts, so a switched-off local mode (LOCAL_MODE_DISABLED) or an old CLI
1167
1258
  * (CLI_UPGRADE_REQUIRED) never costs an emailed code. Without a usable answer
1168
- * the run goes on, and the session check after the email decides as before.
1259
+ * (null) the run goes on, and the session check after the email decides as before.
1169
1260
  */
1170
1261
  async function checkAvailability(ctx, origin, schemaVersion) {
1171
1262
  const client = new VerificationClient(clientOptions(ctx, origin));
1172
- const availability = await busy(ctx, 'Checking vigilbase.com', () => client.availability());
1263
+ const availability = await busy(ctx, `Checking ${new URL(origin).host}`, () => client.availability());
1173
1264
  if (availability)
1174
1265
  assertServerAccepts(availability, CLI_VERSION, schemaVersion);
1266
+ return availability ?? null;
1267
+ }
1268
+ /** The size and zone limits vigilbase.com announced (availability) or set for the checkup (session). */
1269
+ function assertWithinLimits(server, loaded) {
1270
+ const { maxBytes, maxZones } = server.accepts;
1271
+ if (loaded.bytes.byteLength > maxBytes || zoneCount(loaded.snapshot) > maxZones) {
1272
+ throw new LocalCheckupError('SNAPSHOT_TOO_LARGE', `The snapshot is over the vigilbase.com limit (${formatBytes(maxBytes)}, ${maxZones} zones). Nothing was uploaded.`);
1273
+ }
1175
1274
  }
1176
1275
  /**
1177
1276
  * The email verification (prompts on stderr, answers from piped stdin
@@ -1308,9 +1407,12 @@ async function runOneCommand(ctx, options) {
1308
1407
  else
1309
1408
  say(ctx, BANNER);
1310
1409
  assertLocalRuntimeSupported();
1311
- // A bad --out never costs an emailed code (collectSnapshot checks it again).
1312
- if (options.out !== undefined)
1313
- await assertOutWritable(options.out, Boolean(ctx.values.isOverwrite));
1410
+ // What can be checked on this machine never costs an emailed code (collectSnapshot checks it again):
1411
+ // the token for --cf-auth env, the --out path or the checkups directory, and a --cf-bin (nothing runs).
1412
+ assertEnvToken(ctx, options);
1413
+ await assertOutputPossible(ctx, options, Boolean(ctx.values.isOverwrite));
1414
+ if (options.cfBin !== undefined)
1415
+ await locate(ctx, options);
1314
1416
  // 1-3. Availability, then the email, then the checkup it started are checked before cf is touched.
1315
1417
  await checkAvailability(ctx, origin, SNAPSHOT_SCHEMA_VERSION);
1316
1418
  const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
@@ -1367,6 +1469,14 @@ async function runInspect(ctx, options) {
1367
1469
  const name = accountName(snapshot);
1368
1470
  const categories = dataCategories(snapshot);
1369
1471
  const byKind = countEntriesByKind(snapshot.entries);
1472
+ const freshness = snapshotFreshness(snapshot);
1473
+ const warnings = [];
1474
+ if (loaded.bytes.byteLength > SNAPSHOT_LIMITS.defaultServerMaxBytes) {
1475
+ warn(ctx, warnings, {
1476
+ code: 'SNAPSHOT_OVER_DEFAULT_UPLOAD_LIMIT',
1477
+ message: `The snapshot is larger than ${SNAPSHOT_LIMITS.defaultServerMaxBytes / 1024 / 1024} MiB, the default upload limit.`,
1478
+ });
1479
+ }
1370
1480
  if (ctx.mode === 'json') {
1371
1481
  outputJson(ctx, {
1372
1482
  success: true,
@@ -1380,20 +1490,25 @@ async function runInspect(ctx, options) {
1380
1490
  zoneCount: zoneCount(snapshot),
1381
1491
  collectedAt: snapshot.collector.finishedAt,
1382
1492
  collector: snapshot.collector,
1493
+ uploadable: freshness === 'fresh',
1494
+ ...(freshness !== 'fresh' ? { notUploadableReason: freshness } : {}),
1383
1495
  },
1384
1496
  entries: { byKind },
1385
1497
  dataCategories: categories,
1498
+ warnings,
1386
1499
  });
1387
1500
  return ok();
1388
1501
  }
1389
1502
  const counts = countEntries(snapshot.entries);
1390
1503
  const { collector } = snapshot;
1391
1504
  const lines = [
1392
- `Snapshot ${options.file} ${formatBytes(loaded.bytes.byteLength)} sha256 ${loaded.sha256}`,
1505
+ `Snapshot ${displayFileName(options.file)} ${formatBytes(loaded.bytes.byteLength)} sha256 ${loaded.sha256}`,
1393
1506
  `Schema ${snapshot.schemaVersion} (valid)`,
1394
1507
  `Account ${snapshot.accountId}${name !== null ? ` ${name} (as reported)` : ''}`,
1395
1508
  `Zones ${zoneCount(snapshot)}`,
1396
1509
  `Collected ${collector.finishedAt} by ${collector.name} ${collector.version} with cf ${collector.cf.version} (${collector.authSource}, ${collector.platform}, ${collector.cfInvocations} cf calls)`,
1510
+ ...(freshness === 'older_than_24_hours' ? ['Upload no longer accepted (collected more than 24 hours ago)']
1511
+ : freshness === 'dated_in_the_future' ? ['Upload not accepted (dated in the future)'] : []),
1397
1512
  `Reads ${counts.total} · ${counts.ok} ok · ${counts.httpError} HTTP error · ${counts.notCollected} not collectable with cf · ${counts.collectionFailed} collection failed`,
1398
1513
  ...Object.entries(byKind).map(([kind, kindCounts]) => {
1399
1514
  const notOk = kindCounts.total - kindCounts.ok;
@@ -1416,9 +1531,10 @@ function runSubmit(ctx, options) {
1416
1531
  async function submitFile(ctx, options) {
1417
1532
  const origin = options.origin ?? PRODUCTION_ORIGIN;
1418
1533
  const loaded = await loadSnapshotFile(options.file);
1419
- assertFresh(loaded.snapshot);
1534
+ // Running submit again reads the same file, so the hint says how to collect a fresh one.
1535
+ assertFresh(loaded.snapshot, SNAPSHOT_STALE_FILE_HINT);
1420
1536
  const zones = zoneCount(loaded.snapshot);
1421
- const head = `Snapshot ${options.file} ${formatBytes(loaded.bytes.byteLength)} sha256 ${shortSha(loaded.sha256)} account ${maskId(loaded.snapshot.accountId)} ${zones} zone${zones === 1 ? '' : 's'} collected ${formatClock(loaded.snapshot.collector.finishedAt)}`;
1537
+ const head = `Snapshot ${displayFileName(options.file)} ${formatBytes(loaded.bytes.byteLength)} sha256 ${shortSha(loaded.sha256)} account ${maskId(loaded.snapshot.accountId)} ${zones} zone${zones === 1 ? '' : 's'} collected ${formatClock(loaded.snapshot.collector.finishedAt)}`;
1422
1538
  if (ctx.tui) {
1423
1539
  ctx.tui.intro();
1424
1540
  const { ellipsis } = ctx.tui;
@@ -1434,8 +1550,10 @@ async function submitFile(ctx, options) {
1434
1550
  ctx.writer.stderr(head);
1435
1551
  else
1436
1552
  ctx.writer.stdout(head);
1437
- // The file, then vigilbase.com's availability, are checked before the email is verified, so neither costs a code.
1438
- await checkAvailability(ctx, origin, loaded.snapshot.schemaVersion);
1553
+ // The file, then vigilbase.com's availability and limits, are checked before the email is verified, so none costs a code.
1554
+ const availability = await checkAvailability(ctx, origin, loaded.snapshot.schemaVersion);
1555
+ if (availability)
1556
+ assertWithinLimits(availability, loaded);
1439
1557
  const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
1440
1558
  const session = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
1441
1559
  const result = await uploadLoaded(ctx, { checkup, session, origin, loaded });
@@ -1443,11 +1561,17 @@ async function submitFile(ctx, options) {
1443
1561
  outputJson(ctx, result);
1444
1562
  return ok();
1445
1563
  }
1446
- /** Refused locally: vigilbase.com accepts snapshots finished in the last 24 h (5 min clock skew). */
1447
- function assertFresh(snapshot, now = Date.now()) {
1564
+ /** vigilbase.com accepts snapshots finished in the last 24 h (5 min clock skew). */
1565
+ function snapshotFreshness(snapshot, now = Date.now()) {
1448
1566
  const finished = Date.parse(snapshot.collector.finishedAt);
1449
- if (!(finished >= now - SNAPSHOT_LIMITS.maxAgeHours * 3_600_000) || finished > now + SNAPSHOT_LIMITS.maxFutureSkewSeconds * 1_000) {
1450
- throw new LocalCheckupError('SNAPSHOT_STALE', 'The snapshot was collected more than 24 hours ago (or is dated in the future).');
1567
+ if (finished > now + SNAPSHOT_LIMITS.maxFutureSkewSeconds * 1_000)
1568
+ return 'dated_in_the_future';
1569
+ return finished >= now - SNAPSHOT_LIMITS.maxAgeHours * 3_600_000 ? 'fresh' : 'older_than_24_hours';
1570
+ }
1571
+ /** Refused locally, before anything is sent. */
1572
+ function assertFresh(snapshot, hint) {
1573
+ if (snapshotFreshness(snapshot) !== 'fresh') {
1574
+ throw new LocalCheckupError('SNAPSHOT_STALE', 'The snapshot was collected more than 24 hours ago (or is dated in the future).', hint !== undefined ? { hint } : {});
1451
1575
  }
1452
1576
  }
1453
1577
  function numberAt(value, ...path) {
@@ -1484,10 +1608,7 @@ async function uploadLoaded(ctx, input) {
1484
1608
  if (session.accountId !== null && session.accountId !== snapshot.accountId) {
1485
1609
  throw new LocalCheckupError('ACCOUNT_MISMATCH', `This checkup is bound to Cloudflare account ${maskId(session.accountId)}, but the snapshot is for ${maskId(snapshot.accountId)}. Nothing was uploaded.`);
1486
1610
  }
1487
- const zones = zoneCount(snapshot);
1488
- if (loaded.bytes.byteLength > session.accepts.maxBytes || zones > session.accepts.maxZones) {
1489
- throw new LocalCheckupError('SNAPSHOT_TOO_LARGE', `The snapshot is over the vigilbase.com limit (${formatBytes(session.accepts.maxBytes)}, ${session.accepts.maxZones} zones). Nothing was uploaded.`);
1490
- }
1611
+ assertWithinLimits(session, loaded);
1491
1612
  const requester = displayRequester(session.requester);
1492
1613
  const question = `Results go to ${requester}. Upload ${formatBytes(loaded.bytes.byteLength)} to ${origin}?`;
1493
1614
  const flag = ctx.values.isYes ? '--yes' : '--confirm';
@@ -1,19 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  import { executeOneOperation, formatOnePayload } from '../lib/one-operations.js';
3
3
  import { assertCommandFlags, ok, outputJson, requireOneClient, withSpinner } from './helpers.js';
4
- import { missingArguments } from './legacy-validation.js';
4
+ import { InvalidArgumentError, inRange, isoDateTime, missingArguments, oneOf, textValue, unknownSubcommand } from './legacy-validation.js';
5
5
  const regions = ['ams', 'nyc', 'lon', 'sgp', 'iad', 'sfo', 'fra', 'dub', 'tok', 'syd', 'cpt', 'gru', 'dxb', 'ewr', 'lhr', 'jnb', 'atl', 'blr', 'bom', 'cdg', 'del', 'dfw', 'hnl', 'icn', 'itm', 'lax', 'mad', 'man', 'mel', 'mex', 'mia', 'mxp', 'nrt', 'ord', 'sao', 'scl', 'sea', 'sjc', 'sto', 'tlv', 'waw', 'yto'];
6
6
  function flag(ctx, name, max = 2048) {
7
7
  const value = ctx.values[name];
8
- return value === undefined ? undefined : z.string().trim().min(1, `--${name} must not be empty.`).max(max).parse(value);
8
+ return value === undefined ? undefined : textValue(`--${name}`, String(value), max);
9
9
  }
10
10
  function integer(ctx, name, min, max) {
11
11
  const value = flag(ctx, name);
12
12
  if (value === undefined)
13
13
  return undefined;
14
14
  if (!/^\d+$/.test(value))
15
- throw new Error(`--${name} must be an integer.`);
16
- return z.number().int().min(min).max(max).parse(Number(value));
15
+ throw new InvalidArgumentError(`--${name} must be an integer.`);
16
+ return inRange(`--${name}`, Number(value), min, max);
17
17
  }
18
18
  function uuid(value) {
19
19
  const result = z.string().uuid().safeParse(value);
@@ -89,7 +89,7 @@ export function buildOneOperation(ctx) {
89
89
  if (before && cursor)
90
90
  throw new Error('--before and --cursor are mutually exclusive.');
91
91
  if (before)
92
- z.iso.datetime({ offset: true }).parse(before);
92
+ isoDateTime('--before', before);
93
93
  return operation(`/api/customers/${uuid(ctx.rest[1])}/vigil/investigations`, {
94
94
  limit: integer(ctx, 'limit', 1, 100), cursor, before, issueId: flag(ctx, 'issue-id', 256),
95
95
  });
@@ -100,13 +100,13 @@ export function buildOneOperation(ctx) {
100
100
  }
101
101
  if (command === 'contracts') {
102
102
  check(ctx, 2, ['status']);
103
- return operation(`/api/customers/${uuid(ctx.rest[1])}/contracts`, { status: z.enum(['draft', 'pending', 'active', 'expired', 'terminated']).optional().parse(flag(ctx, 'status')) });
103
+ return operation(`/api/customers/${uuid(ctx.rest[1])}/contracts`, { status: oneOf('--status', ['draft', 'pending', 'active', 'expired', 'terminated'], flag(ctx, 'status')) });
104
104
  }
105
105
  if (command === 'invoices') {
106
106
  check(ctx, 2, ['status', 'limit']);
107
107
  return operation(`/api/customers/${uuid(ctx.rest[1])}/stripe/invoices`, {
108
108
  limit: integer(ctx, 'limit', 1, 100),
109
- status: z.enum(['draft', 'open', 'paid', 'uncollectible', 'void']).optional().parse(flag(ctx, 'status')),
109
+ status: oneOf('--status', ['draft', 'open', 'paid', 'uncollectible', 'void'], flag(ctx, 'status')),
110
110
  });
111
111
  }
112
112
  }
@@ -149,8 +149,8 @@ export function buildOneOperation(ctx) {
149
149
  if (command === 'status')
150
150
  return operation(path, { light: ctx.values.isLight ? true : undefined });
151
151
  return operation(path, {
152
- range: z.enum(['24h', '7d', '30d']).optional().parse(flag(ctx, 'range')),
153
- region: z.enum(regions).optional().parse(flag(ctx, 'region')),
152
+ range: oneOf('--range', ['24h', '7d', '30d'], flag(ctx, 'range')),
153
+ region: oneOf('--region', regions, flag(ctx, 'region')),
154
154
  target_id: ctx.values['target-id'] === undefined ? undefined : uuid(ctx.values['target-id']),
155
155
  });
156
156
  }
@@ -162,7 +162,7 @@ export function buildOneOperation(ctx) {
162
162
  customerId: ctx.values['customer-id'] === undefined ? undefined : uuid(ctx.values['customer-id']),
163
163
  });
164
164
  }
165
- throw new Error(`Unknown ${ctx.command} command: ${command}. Run \`vigilbase ${ctx.command} --help\` for usage.`);
165
+ throw unknownSubcommand(ctx.command, command);
166
166
  }
167
167
  export const handleOne = async (ctx) => {
168
168
  const operation = buildOneOperation(ctx);
@@ -190,11 +190,14 @@ export const ONE_HELP = {
190
190
  watchdog-scans <customer-id> --limit 1..100 --offset 0..1000000 --target-id UUID
191
191
  reports <customer-id> Recon reports; --limit 1..100
192
192
  report <customer-id> <report-id> Recon report detail
193
- investigations <customer-id> --limit 1..100 --cursor TEXT or --before ISO; --issue-id TEXT
193
+ investigations <customer-id> --limit 1..100 --issue-id TEXT
194
+ --cursor TEXT or --before ISO
194
195
  investigation <customer-id> <id> --revision 1..2147483647
195
- contracts <customer-id> --status draft|pending|active|expired|terminated
196
+ contracts <customer-id> --status <status>, one of draft, pending,
197
+ active, expired, terminated
196
198
  billing <customer-id> Billing overview
197
- invoices <customer-id> --limit 1..100 --status draft|open|paid|uncollectible|void
199
+ invoices <customer-id> --limit 1..100 --status <status>, one of
200
+ draft, open, paid, uncollectible, void
198
201
  subscriptions <customer-id> Stripe subscriptions
199
202
  projects <customer-id> Linked projects (internal operators)
200
203
  Customer, target, report, and investigation IDs must be UUIDs. Investigations require internal Vigil access.