@vigilbase/cli 0.1.4 → 0.2.0

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 (142) hide show
  1. package/CLOUDFLARE.md +2 -0
  2. package/COMMANDS.md +43 -6
  3. package/LOCAL.md +731 -0
  4. package/README.md +58 -6
  5. package/dist/auth/device-auth.d.ts.map +1 -1
  6. package/dist/auth/device-auth.js +7 -3
  7. package/dist/auth/device-auth.js.map +1 -1
  8. package/dist/auth/index.d.ts +3 -0
  9. package/dist/auth/index.d.ts.map +1 -1
  10. package/dist/auth/index.js +2 -0
  11. package/dist/auth/index.js.map +1 -1
  12. package/dist/auth/provider.d.ts +18 -0
  13. package/dist/auth/provider.d.ts.map +1 -0
  14. package/dist/auth/provider.js +62 -0
  15. package/dist/auth/provider.js.map +1 -0
  16. package/dist/auth/refresh.d.ts +10 -0
  17. package/dist/auth/refresh.d.ts.map +1 -0
  18. package/dist/auth/refresh.js +143 -0
  19. package/dist/auth/refresh.js.map +1 -0
  20. package/dist/auth/store.d.ts +27 -2
  21. package/dist/auth/store.d.ts.map +1 -1
  22. package/dist/auth/store.js +23 -8
  23. package/dist/auth/store.js.map +1 -1
  24. package/dist/auth/token-metadata.d.ts +10 -0
  25. package/dist/auth/token-metadata.d.ts.map +1 -0
  26. package/dist/auth/token-metadata.js +22 -0
  27. package/dist/auth/token-metadata.js.map +1 -0
  28. package/dist/auth/types.d.ts +8 -1
  29. package/dist/auth/types.d.ts.map +1 -1
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +40 -9
  32. package/dist/cli.js.map +1 -1
  33. package/dist/commands/auth.d.ts.map +1 -1
  34. package/dist/commands/auth.js +20 -8
  35. package/dist/commands/auth.js.map +1 -1
  36. package/dist/commands/helpers.d.ts +15 -0
  37. package/dist/commands/helpers.d.ts.map +1 -1
  38. package/dist/commands/helpers.js +2 -2
  39. package/dist/commands/helpers.js.map +1 -1
  40. package/dist/commands/index.d.ts +3 -1
  41. package/dist/commands/index.d.ts.map +1 -1
  42. package/dist/commands/index.js +6 -1
  43. package/dist/commands/index.js.map +1 -1
  44. package/dist/commands/local.d.ts +30 -0
  45. package/dist/commands/local.d.ts.map +1 -0
  46. package/dist/commands/local.js +1520 -0
  47. package/dist/commands/local.js.map +1 -0
  48. package/dist/commands/public-tools.d.ts.map +1 -1
  49. package/dist/commands/public-tools.js +8 -4
  50. package/dist/commands/public-tools.js.map +1 -1
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/config.d.ts +16 -1
  56. package/dist/lib/config.d.ts.map +1 -1
  57. package/dist/lib/config.js +36 -4
  58. package/dist/lib/config.js.map +1 -1
  59. package/dist/lib/format.d.ts.map +1 -1
  60. package/dist/lib/format.js +9 -0
  61. package/dist/lib/format.js.map +1 -1
  62. package/dist/lib/help.d.ts +1 -1
  63. package/dist/lib/help.d.ts.map +1 -1
  64. package/dist/lib/help.js +9 -0
  65. package/dist/lib/help.js.map +1 -1
  66. package/dist/lib/network.d.ts.map +1 -1
  67. package/dist/lib/network.js +2 -1
  68. package/dist/lib/network.js.map +1 -1
  69. package/dist/lib/one-client.d.ts +3 -2
  70. package/dist/lib/one-client.d.ts.map +1 -1
  71. package/dist/lib/one-client.js +46 -2
  72. package/dist/lib/one-client.js.map +1 -1
  73. package/dist/lib/security-client.d.ts +1 -1
  74. package/dist/lib/tui.d.ts +309 -0
  75. package/dist/lib/tui.d.ts.map +1 -0
  76. package/dist/lib/tui.js +847 -0
  77. package/dist/lib/tui.js.map +1 -0
  78. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +95 -0
  79. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts.map +1 -0
  80. package/dist/local/cloudflare-checkup/cf/allowlist.js +184 -0
  81. package/dist/local/cloudflare-checkup/cf/allowlist.js.map +1 -0
  82. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +113 -0
  83. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts.map +1 -0
  84. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +313 -0
  85. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js.map +1 -0
  86. package/dist/local/cloudflare-checkup/cf/child-env.d.ts +32 -0
  87. package/dist/local/cloudflare-checkup/cf/child-env.d.ts.map +1 -0
  88. package/dist/local/cloudflare-checkup/cf/child-env.js +81 -0
  89. package/dist/local/cloudflare-checkup/cf/child-env.js.map +1 -0
  90. package/dist/local/cloudflare-checkup/cf/install.d.ts +106 -0
  91. package/dist/local/cloudflare-checkup/cf/install.d.ts.map +1 -0
  92. package/dist/local/cloudflare-checkup/cf/install.js +309 -0
  93. package/dist/local/cloudflare-checkup/cf/install.js.map +1 -0
  94. package/dist/local/cloudflare-checkup/cf/locate.d.ts +44 -0
  95. package/dist/local/cloudflare-checkup/cf/locate.d.ts.map +1 -0
  96. package/dist/local/cloudflare-checkup/cf/locate.js +228 -0
  97. package/dist/local/cloudflare-checkup/cf/locate.js.map +1 -0
  98. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +89 -0
  99. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts.map +1 -0
  100. package/dist/local/cloudflare-checkup/cf/run-cf.js +275 -0
  101. package/dist/local/cloudflare-checkup/cf/run-cf.js.map +1 -0
  102. package/dist/local/cloudflare-checkup/collector.d.ts +48 -0
  103. package/dist/local/cloudflare-checkup/collector.d.ts.map +1 -0
  104. package/dist/local/cloudflare-checkup/collector.js +296 -0
  105. package/dist/local/cloudflare-checkup/collector.js.map +1 -0
  106. package/dist/local/cloudflare-checkup/contract.d.ts +2793 -0
  107. package/dist/local/cloudflare-checkup/contract.d.ts.map +1 -0
  108. package/dist/local/cloudflare-checkup/contract.js +1614 -0
  109. package/dist/local/cloudflare-checkup/contract.js.map +1 -0
  110. package/dist/local/cloudflare-checkup/email-verification.d.ts +125 -0
  111. package/dist/local/cloudflare-checkup/email-verification.d.ts.map +1 -0
  112. package/dist/local/cloudflare-checkup/email-verification.js +212 -0
  113. package/dist/local/cloudflare-checkup/email-verification.js.map +1 -0
  114. package/dist/local/cloudflare-checkup/errors.d.ts +28 -0
  115. package/dist/local/cloudflare-checkup/errors.d.ts.map +1 -0
  116. package/dist/local/cloudflare-checkup/errors.js +99 -0
  117. package/dist/local/cloudflare-checkup/errors.js.map +1 -0
  118. package/dist/local/cloudflare-checkup/output.d.ts +24 -0
  119. package/dist/local/cloudflare-checkup/output.d.ts.map +1 -0
  120. package/dist/local/cloudflare-checkup/output.js +89 -0
  121. package/dist/local/cloudflare-checkup/output.js.map +1 -0
  122. package/dist/local/cloudflare-checkup/prompts.d.ts +41 -0
  123. package/dist/local/cloudflare-checkup/prompts.d.ts.map +1 -0
  124. package/dist/local/cloudflare-checkup/prompts.js +140 -0
  125. package/dist/local/cloudflare-checkup/prompts.js.map +1 -0
  126. package/dist/local/cloudflare-checkup/rate-budget.d.ts +42 -0
  127. package/dist/local/cloudflare-checkup/rate-budget.d.ts.map +1 -0
  128. package/dist/local/cloudflare-checkup/rate-budget.js +80 -0
  129. package/dist/local/cloudflare-checkup/rate-budget.js.map +1 -0
  130. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +53 -0
  131. package/dist/local/cloudflare-checkup/snapshot-file.d.ts.map +1 -0
  132. package/dist/local/cloudflare-checkup/snapshot-file.js +186 -0
  133. package/dist/local/cloudflare-checkup/snapshot-file.js.map +1 -0
  134. package/dist/local/cloudflare-checkup/submit-client.d.ts +98 -0
  135. package/dist/local/cloudflare-checkup/submit-client.d.ts.map +1 -0
  136. package/dist/local/cloudflare-checkup/submit-client.js +349 -0
  137. package/dist/local/cloudflare-checkup/submit-client.js.map +1 -0
  138. package/dist/local/cloudflare-checkup/terminal-ui.d.ts +171 -0
  139. package/dist/local/cloudflare-checkup/terminal-ui.d.ts.map +1 -0
  140. package/dist/local/cloudflare-checkup/terminal-ui.js +404 -0
  141. package/dist/local/cloudflare-checkup/terminal-ui.js.map +1 -0
  142. package/package.json +8 -4
@@ -0,0 +1,1520 @@
1
+ /**
2
+ * `vigilbase cloudflare-checkup [options]`
3
+ * ≡ `vigilbase local cloudflare-checkup [run] [options]`, plus the advanced
4
+ * `login|doctor|accounts|collect|inspect|submit|logout` subcommands.
5
+ *
6
+ * The one command verifies the user's work email (vigilbase.com emails a
7
+ * 6-digit code), finds (or, after asking, installs) Cloudflare's cf CLI, signs
8
+ * in to Cloudflare with a read-only cf login, lets the user choose the
9
+ * account, collects a minimised snapshot with a fixed allowlist of read-only
10
+ * cf commands, shows what it contains, uploads exactly that file to
11
+ * vigilbase.com after an explicit confirmation and opens the results page.
12
+ * cf owns the Cloudflare login: this process never reads, prints, stores or
13
+ * uploads a Cloudflare credential. See LOCAL.md.
14
+ */
15
+ import { access, constants, stat } from 'node:fs/promises';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
18
+ import { z } from 'zod';
19
+ import { CLI_VERSION } from '../version.js';
20
+ import { resolveConfigDir } from '../lib/config.js';
21
+ import { isHeadlessEnvironment, openUrlInBrowser } from '../lib/open-url.js';
22
+ import { shouldShowProgress } from '../lib/progress.js';
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';
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';
27
+ import { CfInstallError, MANAGED_CF, ensurePrivateDir, findManagedCf, installManagedCf, managedCfRoot, removeManagedCf } from '../local/cloudflare-checkup/cf/install.js';
28
+ import { withCfRunner } from '../local/cloudflare-checkup/cf/run-cf.js';
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';
30
+ import { CfScheduler, authSourceFor, doctorAuth, isReadOnlyScope } from '../local/cloudflare-checkup/cf/auth-lifecycle.js';
31
+ import { RequestBudget, estimateBudgetSeconds, parseRequestBudget, systemClock } from '../local/cloudflare-checkup/rate-budget.js';
32
+ import { buildSnapshot, collectAccount, estimateRequests, listAccounts } from '../local/cloudflare-checkup/collector.js';
33
+ import { accountName, countEntries, countEntriesByKind, dataCategories, loadSnapshotFile, writeSnapshotFile, zoneCount, } from '../local/cloudflare-checkup/snapshot-file.js';
34
+ import { confirmDefaultYes, confirmUpload, hasInteractiveInput, promptLine } from '../local/cloudflare-checkup/prompts.js';
35
+ import { SAME_ADDRESS_WAIT, emailLocale, parseEmailFlag, verifyEmail } from '../local/cloudflare-checkup/email-verification.js';
36
+ import { SubmitClient, VerificationClient, assertSessionAccepts, displayRequester, resolveSubmitOrigin, } from '../local/cloudflare-checkup/submit-client.js';
37
+ import { displayText, formatBytes, formatClock, formatDuration, maskId, readsSummary, shortSha, startCountsProgress } from '../local/cloudflare-checkup/output.js';
38
+ import { CheckupTui } from '../local/cloudflare-checkup/terminal-ui.js';
39
+ import { EXIT, assertCommandFlags, ok, outputJson } from './helpers.js';
40
+ /**
41
+ * How the page, the docs and the hints spell the command: the bare package,
42
+ * no version. vigilbase.com refuses CLIs older than it accepts, and that
43
+ * error's hint (CLI_UPGRADE_REQUIRED) names `@latest` to get past a cached copy.
44
+ */
45
+ export const NPX_COMMAND = 'npx @vigilbase/cli';
46
+ const BANNER = 'Vigilbase Cloudflare Checkup · collects on this machine with Cloudflare\'s cf CLI';
47
+ const SUBCOMMANDS = ['run', 'login', 'doctor', 'accounts', 'collect', 'inspect', 'submit', 'logout'];
48
+ const usage = `Usage: vigilbase cloudflare-checkup [${SUBCOMMANDS.join('|')}] [options]`;
49
+ export const LOCAL_HELP = `Usage: vigilbase cloudflare-checkup [options]
50
+ vigilbase local cloudflare-checkup [<command>] [options]
51
+
52
+ Runs the Vigilbase Cloudflare Checkup on this machine, in one command:
53
+
54
+ ${NPX_COMMAND} cloudflare-checkup
55
+
56
+ It asks for your work email and emails you a 6-digit code to enter, installs
57
+ Cloudflare's cf CLI if it is missing (asks first), signs in to Cloudflare with
58
+ a read-only cf login, lets you choose the account, collects a snapshot, shows
59
+ what it contains, uploads it only after you confirm and opens the results.
60
+ cf owns your Cloudflare login; this CLI never reads, prints, stores or
61
+ uploads it.
62
+
63
+ Options (the default command, also called run):
64
+ --email <address> Your work email (asked for when omitted)
65
+ --account-id <id> Skip the account picker
66
+ --out <file> Snapshot path (default: <config dir>/checkups/…);
67
+ --overwrite replaces an existing file
68
+ --yes Install cf and upload without asking
69
+ (--confirm answers only the upload question)
70
+ --no-open Print the results link; do not open a browser
71
+ --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
74
+ --profile <name> cf profile (default ${DEFAULT_PROFILE})
75
+ --cf-bin <path> Use this cf package; never install one
76
+ --max-requests-per-5min <n> Cloudflare request budget, 100..1100 (default 900)
77
+ --timeout <milliseconds> · --json
78
+
79
+ Without a terminal, add --yes: the email (unless --email is given) and the
80
+ code are read from stdin, one line each, after their prompts on stderr.
81
+
82
+ Advanced commands:
83
+ login Create or refresh the read-only cf profile
84
+ [--profile] [--no-open] [--dry-run] [--cf-bin]
85
+ doctor Check Node, cf, the offline command mapping and cf auth
86
+ accounts List the Cloudflare account IDs cf can read
87
+ collect --out <file> Collect only; --dry-run shows every cf command (offline)
88
+ 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]
93
+
94
+ Results go to the verified email address. Uploads go only to
95
+ ${PRODUCTION_ORIGIN} (or a loopback --base-url).
96
+ Exit codes: 0 success, 1 error, 2 rate limited. See LOCAL.md for details.`;
97
+ const CF_FLAGS = ['profile', 'cf-auth', 'cf-bin'];
98
+ const COLLECT_FLAGS = [...CF_FLAGS, 'account-id', 'out', 'isOverwrite', 'max-requests-per-5min'];
99
+ const SUBMIT_FLAGS = ['email', 'isNoOpen', 'isConfirm', 'isYes', 'base-url', 'timeout'];
100
+ const ALLOWED_FLAGS = {
101
+ run: [...COLLECT_FLAGS, ...SUBMIT_FLAGS, 'isJson'],
102
+ login: ['profile', 'cf-bin', 'isDryRun', 'isNoOpen', 'isJson'],
103
+ doctor: [...CF_FLAGS, 'isJson'],
104
+ accounts: [...CF_FLAGS, 'isJson'],
105
+ collect: [...COLLECT_FLAGS, 'isDryRun', 'isJson'],
106
+ inspect: ['isJson'],
107
+ submit: [...SUBMIT_FLAGS, 'isJson'],
108
+ logout: ['profile', 'cf-bin', 'isJson'],
109
+ };
110
+ /** Validate flags before any spawn, file or network access. */
111
+ function assertLocalFlags(ctx, subcommand) {
112
+ 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
+ }
119
+ }
120
+ }
121
+ const accountIdSchema = z.string()
122
+ .regex(/^[0-9a-fA-F]{32}$/, '--account-id must contain 32 hexadecimal characters.')
123
+ .transform((id) => id.toLowerCase());
124
+ const pathSchema = (flag) => z.string()
125
+ .min(1, `${flag} must not be empty.`)
126
+ .max(4096, `${flag} is too long.`)
127
+ .refine((value) => !value.includes('\0'), `${flag} must not contain NUL characters.`);
128
+ function parse(schema, value) {
129
+ const result = schema.safeParse(value);
130
+ if (!result.success)
131
+ throw new Error(result.error.issues.map((issue) => issue.message).join(' '));
132
+ return result.data;
133
+ }
134
+ function parseLocalOptions(ctx, subcommand, args) {
135
+ const takesFile = subcommand === 'inspect' || subcommand === 'submit';
136
+ if (takesFile ? args.length !== 1 : args.length !== 0) {
137
+ throw new Error(takesFile ? `Usage: vigilbase cloudflare-checkup ${subcommand} <file>` : usage);
138
+ }
139
+ const values = ctx.values;
140
+ const requested = parse(z.enum(['auto', 'profile', 'env', 'default'], { message: '--cf-auth must be auto, profile, env or default.' }), values['cf-auth'] ?? (subcommand === 'run' ? 'auto' : 'profile'));
141
+ if (values.profile !== undefined && requested !== 'profile' && requested !== 'auto')
142
+ throw new Error('--profile applies only to --cf-auth profile.');
143
+ // auto: an explicit --profile wins; otherwise env when the token variable is set (presence only).
144
+ const authMode = requested !== 'auto' ? requested : values.profile !== undefined ? 'profile' : hasEnvToken(ctx.env) ? 'env' : 'profile';
145
+ const profile = validateProfileName(values.profile ?? DEFAULT_PROFILE);
146
+ const cfBin = values['cf-bin'] === undefined ? undefined : parse(pathSchema('--cf-bin'), values['cf-bin']);
147
+ if (cfBin !== undefined && !isAbsolute(cfBin))
148
+ throw new Error('--cf-bin must be an absolute path.');
149
+ const out = values.out === undefined ? undefined : parse(pathSchema('--out'), values.out);
150
+ if (subcommand === 'collect' && values.isDryRun) {
151
+ if (out !== undefined || values['account-id'] !== undefined || values.isOverwrite) {
152
+ throw new Error('collect --dry-run makes no Cloudflare calls and writes no file; omit --out, --overwrite and --account-id.');
153
+ }
154
+ }
155
+ else if (subcommand === 'collect' && out === undefined) {
156
+ throw new Error('--out <file> is required for cloudflare-checkup collect.');
157
+ }
158
+ if (values.isOverwrite && out === undefined)
159
+ throw new Error('--overwrite applies only with --out.');
160
+ // The address is never echoed, even when it is malformed.
161
+ const email = values.email === undefined ? undefined : parseEmailFlag(values.email);
162
+ const options = {
163
+ subcommand,
164
+ ...(takesFile ? { file: parse(pathSchema('<file>'), args[0]) } : {}),
165
+ auth: { authMode, profile },
166
+ authAuto: requested === 'auto',
167
+ ...(cfBin !== undefined ? { cfBin } : {}),
168
+ ...(values['account-id'] !== undefined ? { accountId: parse(accountIdSchema, values['account-id']) } : {}),
169
+ ...(out !== undefined ? { out: resolve(out) } : {}),
170
+ maxRequests: parseRequestBudget(values['max-requests-per-5min']),
171
+ // Environment variables never choose the submit origin.
172
+ ...(subcommand === 'submit' || subcommand === 'run' ? { origin: resolveSubmitOrigin(values['base-url']) } : {}),
173
+ ...(email !== undefined ? { email } : {}),
174
+ configDir: localConfigDir(ctx, subcommand === 'run' || subcommand === 'logout'),
175
+ };
176
+ if (subcommand === 'submit' || subcommand === 'run')
177
+ assertConfirmationAvailable(ctx, subcommand);
178
+ return options;
179
+ }
180
+ /**
181
+ * The config directory (VIGILBASE_CONFIG_DIR, XDG_CONFIG_HOME/vigilbase or
182
+ * ~/.config/vigilbase). `run` and `logout` need it and fail on a malformed
183
+ * value; the advanced commands only look for a managed cf there, so for them
184
+ * a malformed value just means "no managed cf". `inspect` never reads it.
185
+ */
186
+ function localConfigDir(ctx, required) {
187
+ try {
188
+ return resolveConfigDir(undefined, ctx.env);
189
+ }
190
+ catch (error) {
191
+ if (required)
192
+ throw error;
193
+ return null;
194
+ }
195
+ }
196
+ function isInteractive(ctx) {
197
+ return hasInteractiveInput(ctx.stdin ?? process.stdin);
198
+ }
199
+ /**
200
+ * Without a terminal, `submit` needs `--yes` or `--confirm` for the upload
201
+ * question, and the one command needs `--yes`: `--confirm` cannot answer its
202
+ * question about installing cf. Checked before anything is sent, so no code is
203
+ * emailed for a run that could not install cf or confirm the upload. The email
204
+ * and the emailed code themselves may come from piped stdin. (A missing cf
205
+ * login, or several accounts without `--account-id`, still show only after the
206
+ * email is verified: finding them needs cf.)
207
+ */
208
+ function assertConfirmationAvailable(ctx, subcommand) {
209
+ if (isInteractive(ctx) || ctx.values.isYes)
210
+ return;
211
+ if (subcommand === 'run') {
212
+ throw new LocalCheckupError('CONFIRMATION_REQUIRED', ctx.values.isConfirm
213
+ ? 'No terminal is available, and --confirm answers only the upload question, not installing cf. Add --yes.'
214
+ : 'No terminal is available to confirm installing cf or the upload. Add --yes.');
215
+ }
216
+ if (!ctx.values.isConfirm)
217
+ throw new LocalCheckupError('CONFIRMATION_REQUIRED', 'No terminal is available to confirm the upload.');
218
+ }
219
+ /** `vigilbase local cloudflare-checkup [<command>] …` */
220
+ export const handleLocal = async (ctx) => {
221
+ const [group, subcommandRaw, ...args] = ctx.rest;
222
+ if (group !== 'cloudflare-checkup')
223
+ throw new Error(usage);
224
+ const subcommand = (subcommandRaw ?? 'run').toLowerCase();
225
+ if (!SUBCOMMANDS.includes(subcommand))
226
+ throw new Error(usage);
227
+ assertLocalFlags(ctx, subcommand);
228
+ const options = parseLocalOptions(ctx, subcommand, args);
229
+ switch (subcommand) {
230
+ case 'run': return runFull(ctx, options);
231
+ case 'login': return runLogin(ctx, options);
232
+ case 'doctor': return runDoctor(ctx, options);
233
+ case 'accounts': return runAccounts(ctx, options);
234
+ case 'collect': return ctx.values.isDryRun ? runCollectDryRun(ctx, options) : runCollect(ctx, options);
235
+ case 'inspect': return runInspect(ctx, options);
236
+ case 'submit': return runSubmit(ctx, options);
237
+ case 'logout': return runLogout(ctx, options);
238
+ }
239
+ };
240
+ /** `vigilbase cloudflare-checkup …`: the top-level alias. */
241
+ export const handleCloudflareCheckup = (ctx) => handleLocal({ ...ctx, rest: ['cloudflare-checkup', ...ctx.rest] });
242
+ function stderrStream(ctx) {
243
+ return ctx.stderrStream ?? process.stderr;
244
+ }
245
+ /**
246
+ * run and submit on an interactive terminal (stdin and stderr are TTYs, TERM
247
+ * is not dumb, CI is not set, no --json): the steps render as a timeline on
248
+ * stderr (terminal-ui.ts), every other line goes through it too, and an error
249
+ * that ends the command is shown there (■) instead of by runCli. Anywhere
250
+ * else the plain lines and prompts are unchanged.
251
+ */
252
+ async function withTerminalUi(ctx, work) {
253
+ const tui = CheckupTui.open({
254
+ mode: ctx.mode,
255
+ stdin: ctx.stdin ?? process.stdin,
256
+ stderr: stderrStream(ctx),
257
+ stdout: ctx.stdoutStream ?? process.stdout,
258
+ env: ctx.env,
259
+ writer: ctx.writer,
260
+ });
261
+ if (!tui)
262
+ return work(ctx);
263
+ try {
264
+ return await work({ ...ctx, writer: tui.lines, tui });
265
+ }
266
+ catch (error) {
267
+ tui.fail(error);
268
+ throw error;
269
+ }
270
+ finally {
271
+ tui.close();
272
+ }
273
+ }
274
+ /** `work` with a spinner (`label…`) in the terminal UI; plain output shows nothing for it. */
275
+ function busy(ctx, label, work) {
276
+ return ctx.tui ? ctx.tui.busy(label, work) : work();
277
+ }
278
+ function promptIO(ctx) {
279
+ return { stdin: ctx.stdin ?? process.stdin, stderr: stderrStream(ctx) };
280
+ }
281
+ /** Status lines of the advanced commands: stderr, text mode only. */
282
+ function info(ctx, line) {
283
+ if (ctx.mode !== 'json')
284
+ ctx.writer.stderr(line);
285
+ }
286
+ /** Status lines of the one command: stdout in text mode, stderr with --json (stdout stays one JSON object). */
287
+ function say(ctx, line) {
288
+ if (ctx.mode === 'json')
289
+ ctx.writer.stderr(line);
290
+ else
291
+ ctx.writer.stdout(line);
292
+ }
293
+ function warn(ctx, warnings, warning) {
294
+ warnings.push(warning);
295
+ if (ctx.mode !== 'json')
296
+ ctx.writer.stderr(`Warning: ${warning.message}`);
297
+ }
298
+ /** Env-mode presence check and the profile-mode notice; the value is never read. */
299
+ function checkAuthEnvironment(ctx, options, warnings) {
300
+ if (options.auth.authMode === 'env') {
301
+ if (!hasEnvToken(ctx.env))
302
+ throw new LocalCheckupError('CF_AUTH_REQUIRED', missingEnvTokenMessage(), { hint: ENV_TOKEN_AUTH_HINT });
303
+ }
304
+ else if (hasEnvToken(ctx.env)) {
305
+ warn(ctx, warnings, { code: 'ENV_TOKEN_NOT_USED', message: strippedTokenNotice(options.auth.authMode) });
306
+ }
307
+ }
308
+ async function managedDirFor(options) {
309
+ if (options.cfBin !== undefined || options.configDir === null)
310
+ return null;
311
+ return findManagedCf(options.configDir);
312
+ }
313
+ async function locateAndWarn(ctx, options, warnings, managedDir) {
314
+ const managed = managedDir === undefined ? await managedDirFor(options) : managedDir;
315
+ const location = await locateCf({
316
+ ...(options.cfBin !== undefined ? { cfBin: options.cfBin } : {}),
317
+ ...(managed ? { managedDir: managed } : {}),
318
+ env: ctx.env,
319
+ });
320
+ if (location.insideCwd) {
321
+ 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.' });
322
+ }
323
+ return location;
324
+ }
325
+ function offlineEnv(ctx, authMode) {
326
+ return buildChildEnv(ctx.env, { purpose: 'offline', authMode, accountId: PREFLIGHT_ACCOUNT_ID });
327
+ }
328
+ async function checkVersion(ctx, runner, location, authMode, warnings) {
329
+ const result = await runner.run(versionArgv(), { env: offlineEnv(ctx, authMode) });
330
+ if (result.exitCode !== 0) {
331
+ if (/cf requires Node\.js/.test(result.stderr))
332
+ throw new LocalCheckupError('NODE_UNSUPPORTED', 'cf refused to run under this Node.js version.');
333
+ throw new LocalCheckupError('CF_WRONG_BINARY', 'cf --version failed; the package may be damaged. Reinstall cf.');
334
+ }
335
+ const prepared = checkCfVersion(result.stdout, location.packageVersion);
336
+ if (!prepared.tested) {
337
+ warn(ctx, warnings, {
338
+ code: 'CF_UNTESTED_VERSION',
339
+ message: `cf ${prepared.version} has not been tested with this CLI (tested: ${CF_SUPPORTED.tested.join(', ')}); it is used only if every command template maps offline.`,
340
+ });
341
+ }
342
+ return prepared;
343
+ }
344
+ /** Run every template with `--dry-run`, eight at a time; no auth, no network. */
345
+ async function runPreflight(ctx, runner, auth, only) {
346
+ const checks = preflightChecks(auth, only);
347
+ const env = offlineEnv(ctx, auth.authMode);
348
+ const mismatched = [];
349
+ let verified = 0;
350
+ let next = 0;
351
+ const worker = async () => {
352
+ while (next < checks.length) {
353
+ const check = checks[next++];
354
+ const result = await runner.run(check.argv, { env });
355
+ if (result.exitCode === 0 && !result.timedOut && dryRunMatches(result.stdout, check))
356
+ verified++;
357
+ else
358
+ mismatched.push(check.settingId ? `zone_setting:${check.settingId}` : check.id);
359
+ }
360
+ };
361
+ await Promise.all(Array.from({ length: Math.min(8, checks.length) }, () => worker()));
362
+ mismatched.sort();
363
+ return { verified, total: checks.length, mismatched };
364
+ }
365
+ function assertCriticalTemplates(preflight, critical = CRITICAL_TEMPLATES) {
366
+ const broken = critical.filter((id) => preflight.mismatched.includes(id));
367
+ if (broken.length > 0) {
368
+ throw new LocalCheckupError('CF_ALLOWLIST_MISMATCH', `This cf release does not map the ${broken.join(', ')} command${broken.length === 1 ? '' : 's'} the checkup needs.`);
369
+ }
370
+ }
371
+ function createScheduler(ctx, runner, auth, maxRequests) {
372
+ return new CfScheduler({
373
+ invoke: (argv, callOptions) => runner.run(argv, {
374
+ env: buildChildEnv(ctx.env, { purpose: 'api', authMode: auth.authMode, ...(callOptions.accountId ? { accountId: callOptions.accountId } : {}) }),
375
+ ...(callOptions.stdoutCapBytes ? { stdoutCapBytes: callOptions.stdoutCapBytes } : {}),
376
+ }),
377
+ whoami: () => runner.run(whoamiArgv(auth), { env: buildChildEnv(ctx.env, { purpose: 'api', authMode: auth.authMode }) }),
378
+ budget: new RequestBudget(maxRequests, systemClock),
379
+ clock: systemClock,
380
+ authMode: auth.authMode,
381
+ terminateAll: () => runner.terminateAll(),
382
+ });
383
+ }
384
+ function profileLine(auth, whoami) {
385
+ const label = auth.authMode === 'profile' ? `cf profile "${auth.profile}"` : auth.authMode === 'env' ? 'cf with the API token from this shell' : 'cf default profile';
386
+ if (auth.authMode === 'env' || !whoami.scopes)
387
+ return label;
388
+ const writes = whoami.scopes.filter((scope) => !isReadOnlyScope(scope)).length;
389
+ const access = writes === 0 ? 'read-only' : `${writes} write scope${writes === 1 ? '' : 's'}`;
390
+ return `${label} · ${access}${whoami.expiresAt ? ` · expires ${formatClock(whoami.expiresAt)}` : ''}`;
391
+ }
392
+ /** The terminal UI's sign-in line: `read-only profile "vigilbase-checkup"`. */
393
+ function signInSummary(auth, whoami, tui) {
394
+ if (auth.authMode === 'env')
395
+ return 'API token from this shell';
396
+ const writes = whoami.scopes?.filter((scope) => !isReadOnlyScope(scope)).length;
397
+ const profile = auth.authMode === 'profile' ? `profile "${auth.profile}"` : 'default cf profile';
398
+ if (writes === undefined)
399
+ return profile;
400
+ return writes === 0 ? `read-only ${profile}` : tui.join([profile, `${writes} write scope${writes === 1 ? '' : 's'}`]);
401
+ }
402
+ const MAX_PICKER_ACCOUNTS = 20;
403
+ const MAX_PROMPT_ATTEMPTS = 3;
404
+ /**
405
+ * Account choice without `--account-id` or a bound account: one account is
406
+ * used as is; several get a numbered picker (name and masked ID, up to 20) on
407
+ * a terminal, or the arrow-key picker of the terminal UI; without a terminal,
408
+ * ACCOUNT_SELECTION_REQUIRED lists the full IDs.
409
+ */
410
+ async function chooseAccount(ctx, env, log) {
411
+ const accounts = await busy(ctx, 'Listing the Cloudflare accounts cf can read', () => listAccounts(env));
412
+ if (accounts.length === 0) {
413
+ throw new LocalCheckupError('ACCOUNT_NOT_VISIBLE', 'cf can read no Cloudflare accounts with this login.');
414
+ }
415
+ // The terminal UI's ellipsis is `...` on an ASCII-only console.
416
+ const ellipsis = ctx.tui?.ellipsis;
417
+ const label = (account) => displayText(account.name, 60, ellipsis) || '(no name)';
418
+ if (accounts.length === 1) {
419
+ if (ctx.tui)
420
+ ctx.tui.account(label(accounts[0]), maskId(accounts[0].id, ellipsis));
421
+ else
422
+ log(`cf can read 1 Cloudflare account: ${label(accounts[0])} ${maskId(accounts[0].id)}`);
423
+ return accounts[0].id;
424
+ }
425
+ // The terminal UI scrolls through every account.
426
+ if (ctx.tui) {
427
+ const id = await ctx.tui.chooseAccount(accounts.map((account) => ({ id: account.id, label: label(account), detail: maskId(account.id, ellipsis) })));
428
+ if (id === null)
429
+ throw new LocalCheckupError('ACCOUNT_SELECTION_REQUIRED', 'No account was chosen.');
430
+ return id;
431
+ }
432
+ const shown = accounts.slice(0, MAX_PICKER_ACCOUNTS);
433
+ const more = accounts.length > shown.length ? accounts.length - shown.length : 0;
434
+ if (!isInteractive(ctx)) {
435
+ const listing = shown.map((account, index) => ` ${index + 1}) ${account.id} ${displayText(account.name, 80)}`);
436
+ throw new LocalCheckupError('ACCOUNT_SELECTION_REQUIRED', `cf can read ${accounts.length} accounts; choose one with --account-id:\n${listing.join('\n')}${more ? `\n … and ${more} more` : ''}`);
437
+ }
438
+ const width = Math.max(...shown.map((account) => label(account).length));
439
+ ctx.writer.stderr(`cf can read ${accounts.length} Cloudflare accounts:`);
440
+ shown.forEach((account, index) => ctx.writer.stderr(` ${String(index + 1).padStart(String(shown.length).length)}) ${label(account).padEnd(width)} ${maskId(account.id)}`));
441
+ if (more)
442
+ ctx.writer.stderr(` … and ${more} more; pass --account-id <ACCOUNT_ID> for one of those.`);
443
+ for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt++) {
444
+ const answer = await promptLine(promptIO(ctx), `Account number (1-${shown.length}): `);
445
+ if (answer === null)
446
+ throw new LocalCheckupError('ACCOUNT_SELECTION_REQUIRED', 'No account was chosen, and the input ended.');
447
+ const choice = Number(answer);
448
+ if (Number.isInteger(choice) && choice >= 1 && choice <= shown.length)
449
+ return shown[choice - 1].id;
450
+ }
451
+ throw new LocalCheckupError('ACCOUNT_SELECTION_REQUIRED', 'No account was chosen.');
452
+ }
453
+ /**
454
+ * A path as one shell word, for hints the user may copy. POSIX shells get
455
+ * single quotes (nothing inside is expanded), with a leading `~/` left outside
456
+ * them so the shell still expands it.
457
+ */
458
+ export function shellArg(value, platform = process.platform) {
459
+ // Windows paths cannot contain `"`, and cmd and PowerShell keep backslashes inside double quotes.
460
+ if (platform === 'win32')
461
+ return /^[A-Za-z0-9_@+=:,.\\~-]+$/.test(value) ? value : `"${value}"`;
462
+ if (/^[A-Za-z0-9_@%+=:,./~-]+$/.test(value))
463
+ return value;
464
+ const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`;
465
+ return value.startsWith('~/') ? `~/${quote(value.slice(2))}` : quote(value);
466
+ }
467
+ /**
468
+ * `~/…` for paths under the home directory. Not on Windows: cmd and Windows
469
+ * PowerShell do not expand `~` for native commands, and the printed path is
470
+ * what users pass to `submit`.
471
+ */
472
+ export function displayPath(path, env = process.env, platform = process.platform) {
473
+ if (platform === 'win32')
474
+ return path;
475
+ const home = env.HOME || homedir();
476
+ return home && path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
477
+ }
478
+ /**
479
+ * `cloudflare-checkup-<first 8 of the account ID>-<UTC yyyymmddThhmmssZ>.json`;
480
+ * `-2`, `-3`, … only when a run in the same second already used the name.
481
+ */
482
+ export function defaultSnapshotName(accountId, at, attempt = 1) {
483
+ const stamp = at.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
484
+ return `cloudflare-checkup-${accountId.slice(0, 8)}-${stamp}${attempt > 1 ? `-${attempt}` : ''}.json`;
485
+ }
486
+ /** Write to `--out`, or to a fresh default name in the checkups directory (never replacing a file there). */
487
+ async function writeToTarget(target, accountId, startedAt, text, overwrite) {
488
+ if (target.kind === 'out')
489
+ return { path: target.path, ...(await writeSnapshotFile(target.path, text, { overwrite })) };
490
+ for (let attempt = 1;; attempt++) {
491
+ const path = join(target.dir, defaultSnapshotName(accountId, startedAt, attempt));
492
+ try {
493
+ return { path, ...(await writeSnapshotFile(path, text, { overwrite: false })) };
494
+ }
495
+ catch (error) {
496
+ if (!(error instanceof LocalCheckupError) || error.code !== 'OUTPUT_EXISTS' || attempt >= 20)
497
+ throw error;
498
+ }
499
+ }
500
+ }
501
+ // ============================================================================
502
+ // login
503
+ // ============================================================================
504
+ async function runLogin(ctx, options) {
505
+ const argv = loginArgv(options.auth.profile, RECOMMENDED_CF_SCOPES, { noBrowser: Boolean(ctx.values.isNoOpen) });
506
+ if (ctx.values.isDryRun) {
507
+ if (ctx.mode === 'json')
508
+ outputJson(ctx, { success: true, dryRun: true, argv: ['cf', ...argv] });
509
+ else
510
+ ctx.writer.stdout(`cf ${argv.join(' ')}`);
511
+ return ok();
512
+ }
513
+ assertLocalRuntimeSupported();
514
+ const warnings = [];
515
+ if (hasEnvToken(ctx.env))
516
+ warn(ctx, warnings, { code: 'ENV_TOKEN_NOT_USED', message: strippedTokenNotice('profile') });
517
+ const location = await locateAndWarn(ctx, options, warnings);
518
+ await withCfRunner(location.entry, async (runner) => {
519
+ await checkVersion(ctx, runner, location, 'profile', warnings);
520
+ info(ctx, `Running: cf auth create ${options.auth.profile} --scopes … (${RECOMMENDED_CF_SCOPES.length} read scopes)`);
521
+ // cf keeps the login in its own config directory.
522
+ const code = await runner.runInteractive(argv, buildChildEnv(ctx.env, { purpose: 'login', authMode: 'profile' }), { stdoutToStderr: ctx.mode === 'json' });
523
+ if (code !== 0)
524
+ throw new LocalCheckupError('CF_AUTH_REQUIRED', 'cf auth create did not complete; the profile was not created or refreshed.');
525
+ });
526
+ if (ctx.mode === 'json') {
527
+ outputJson(ctx, { success: true, profile: options.auth.profile, scopes: [...RECOMMENDED_CF_SCOPES], warnings });
528
+ }
529
+ else {
530
+ ctx.writer.stdout(`✓ cf profile "${options.auth.profile}" is ready with read-only scopes.`);
531
+ ctx.writer.stdout(`Next: run ${NPX_COMMAND} cloudflare-checkup`);
532
+ }
533
+ return ok();
534
+ }
535
+ // ============================================================================
536
+ // logout
537
+ // ============================================================================
538
+ /**
539
+ * `cf auth delete <profile>` through the located cf (interactive passthrough,
540
+ * so cf's own message is visible), then the managed cf install is removed.
541
+ * A profile cf declines to delete (usually: it does not exist) is reported,
542
+ * not an error, so logout can be repeated. A cf that cannot run at all is an
543
+ * error (exit 1, `success: false`): the login may still be on disk, so the
544
+ * managed cf is kept, since it is usually the only cf that can delete it.
545
+ */
546
+ async function runLogout(ctx, options) {
547
+ const configDir = options.configDir;
548
+ const profile = options.auth.profile;
549
+ const warnings = [];
550
+ let location = null;
551
+ try {
552
+ location = await locateAndWarn(ctx, options, warnings);
553
+ }
554
+ catch (error) {
555
+ // An explicit --cf-bin that is not cf is an error; with no cf at all, the profile cannot be checked.
556
+ if (options.cfBin !== undefined || !(error instanceof LocalCheckupError) || !['CF_NOT_FOUND', 'CF_WRONG_BINARY'].includes(error.code))
557
+ throw error;
558
+ }
559
+ let profileRemoved = false;
560
+ let cfProblem = null;
561
+ if (location) {
562
+ const found = location;
563
+ try {
564
+ assertLocalRuntimeSupported();
565
+ await withCfRunner(found.entry, async (runner) => {
566
+ await checkVersion(ctx, runner, found, 'profile', warnings);
567
+ say(ctx, `Running: cf auth delete ${profile}`);
568
+ const code = await runner.runInteractive(logoutArgv(profile), buildChildEnv(ctx.env, { purpose: 'login', authMode: 'profile' }), { stdoutToStderr: ctx.mode === 'json' });
569
+ profileRemoved = code === 0;
570
+ });
571
+ }
572
+ catch (error) {
573
+ // A cf that cannot run must not stop a broken managed install from being removed (unless --cf-bin named it).
574
+ if (options.cfBin !== undefined || !(error instanceof LocalCheckupError))
575
+ throw error;
576
+ cfProblem = error;
577
+ }
578
+ }
579
+ // The login could not be deleted: keep the cf Vigilbase installed (if any) for the next attempt.
580
+ const keepManaged = cfProblem !== null && await pathExists(managedCfRoot(configDir));
581
+ const managed = cfProblem ? { path: managedCfRoot(configDir), removed: false } : await removeManagedCf(configDir);
582
+ const hint = cfProblem ? logoutHint(cfProblem, profile, location?.source === 'managed') : undefined;
583
+ if (ctx.mode === 'json') {
584
+ outputJson(ctx, {
585
+ success: cfProblem === null,
586
+ profile: { name: profile, removed: profileRemoved, ...(cfProblem ? { problem: cfProblem.code } : {}) },
587
+ managedCf: { path: managed.path, removed: managed.removed, ...(keepManaged ? { kept: true } : {}) },
588
+ warnings,
589
+ ...(hint ? { hint } : {}),
590
+ });
591
+ return cfProblem ? EXIT.ERROR : ok();
592
+ }
593
+ if (profileRemoved)
594
+ ctx.writer.stdout(`✓ Removed the read-only Cloudflare login (cf profile "${profile}").`);
595
+ else if (location && !cfProblem)
596
+ ctx.writer.stdout(`cf did not delete the profile "${profile}" (see cf's message above; usually it does not exist).`);
597
+ else if (!location)
598
+ ctx.writer.stdout(`cf is not installed, so the cf profile "${profile}" could not be checked. If you signed in with it, install cf (npm install --global cf@${MANAGED_CF.version}) and run cf auth delete ${profile}.`);
599
+ if (keepManaged)
600
+ ctx.writer.stdout(`Kept the cf that Vigilbase installed (${displayPath(managed.path, ctx.env)}) to remove the login later.`);
601
+ else
602
+ ctx.writer.stdout(managed.removed ? `✓ Removed the cf that Vigilbase installed (${displayPath(managed.path, ctx.env)}).` : 'No Vigilbase-installed cf to remove.');
603
+ if (cfProblem) {
604
+ ctx.writer.stderr(`Error: cf could not run (${cfProblem.code}: ${cfProblem.message}), so the read-only Cloudflare login (cf profile "${profile}") was not removed.`);
605
+ ctx.writer.stderr(`Hint: ${hint}`);
606
+ return EXIT.ERROR;
607
+ }
608
+ return ok();
609
+ }
610
+ /** What to do when logout could not run cf to delete the profile. */
611
+ function logoutHint(problem, profile, managedFailed) {
612
+ const logout = `${NPX_COMMAND} cloudflare-checkup logout`;
613
+ if (problem.code === 'NODE_UNSUPPORTED')
614
+ return `Run ${logout} again under Node.js 22.18 or newer (https://nodejs.org).`;
615
+ const install = `Install cf with \`npm install --global cf@${MANAGED_CF.version}\``;
616
+ // A damaged managed cf would be found first again; --cf-bin names the new one, and a successful logout then removes the old.
617
+ if (managedFailed)
618
+ return `${install}, then run ${logout} --cf-bin "$(npm root -g)/cf".`;
619
+ return `${install}, then run ${logout} again (or \`cf auth delete ${profile}\`).`;
620
+ }
621
+ async function pathExists(target) {
622
+ return (await stat(target).catch(() => null)) !== null;
623
+ }
624
+ // ============================================================================
625
+ // doctor
626
+ // ============================================================================
627
+ /** doctor makes one whoami call (two when that call refreshed an expired token). */
628
+ const DOCTOR_BUDGET = 100;
629
+ async function runDoctor(ctx, options) {
630
+ const warnings = [];
631
+ const problems = [];
632
+ const record = (error) => {
633
+ if (error instanceof LocalCheckupError)
634
+ problems.push({ code: error.code, message: error.message, hint: error.hint });
635
+ else
636
+ problems.push({ code: 'CF_ERROR', message: error instanceof Error ? error.message : 'Unexpected error.' });
637
+ };
638
+ const nodeSupported = isNodeSupported();
639
+ const cf = { found: false, path: null, version: null, supported: false, tested: false, identityVerified: false };
640
+ const commands = { verified: 0, mismatched: [] };
641
+ let auth = {
642
+ source: authSourceFor(options.auth.authMode),
643
+ profile: options.auth.authMode === 'profile' ? options.auth.profile : null,
644
+ authenticated: false,
645
+ scopes: 'unknown',
646
+ };
647
+ let runtimeOk = true;
648
+ try {
649
+ assertLocalRuntimeSupported();
650
+ }
651
+ catch (error) {
652
+ runtimeOk = false;
653
+ record(error);
654
+ }
655
+ let location = null;
656
+ try {
657
+ location = await locateAndWarn(ctx, options, warnings);
658
+ Object.assign(cf, { found: true, path: location.packageDir, identityVerified: true });
659
+ }
660
+ catch (error) {
661
+ record(error);
662
+ }
663
+ if (location && runtimeOk) {
664
+ const found = location;
665
+ await withCfRunner(found.entry, async (runner) => {
666
+ try {
667
+ const prepared = await checkVersion(ctx, runner, found, options.auth.authMode, warnings);
668
+ Object.assign(cf, { version: prepared.version, supported: true, tested: prepared.tested });
669
+ }
670
+ catch (error) {
671
+ record(error);
672
+ return;
673
+ }
674
+ const preflight = await runPreflight(ctx, runner, options.auth);
675
+ commands.verified = preflight.verified;
676
+ commands.mismatched = preflight.mismatched;
677
+ if (preflight.mismatched.length > 0) {
678
+ problems.push({ code: 'CF_ALLOWLIST_MISMATCH', message: `${preflight.mismatched.length} of ${preflight.total} command templates do not map in this cf release.` });
679
+ }
680
+ try {
681
+ checkAuthEnvironment(ctx, options, warnings);
682
+ auth = doctorAuth(await createScheduler(ctx, runner, options.auth, DOCTOR_BUDGET).start(), options.auth.authMode, options.auth.profile);
683
+ }
684
+ catch (error) {
685
+ record(error);
686
+ }
687
+ });
688
+ }
689
+ if (auth.authenticated && options.auth.authMode === 'default' && (auth.writeScopeCount ?? 0) > 0) {
690
+ warn(ctx, warnings, { code: 'DEFAULT_PROFILE_WRITE_SCOPES', message: `The default cf profile has ${auth.writeScopeCount} write scopes; prefer the read-only profile the checkup creates (--cf-auth profile).` });
691
+ }
692
+ const success = nodeSupported && cf.supported && commands.mismatched.length === 0 && auth.authenticated && problems.length === 0;
693
+ if (ctx.mode === 'json') {
694
+ outputJson(ctx, { success, node: { version: process.versions.node, supported: nodeSupported }, cf, auth, commands, warnings, problems });
695
+ return success ? EXIT.OK : EXIT.ERROR;
696
+ }
697
+ const mark = (isOk) => (isOk ? '✓' : '✗');
698
+ ctx.writer.stdout(`${mark(nodeSupported)} Node.js ${process.versions.node}${nodeSupported ? '' : ' (22.18 or newer required)'}`);
699
+ 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)' : ''}`);
700
+ 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(', ')}` : ''}`);
701
+ const authLabel = options.auth.authMode === 'profile' ? `profile "${options.auth.profile}"` : options.auth.authMode === 'env' ? 'API token from this shell' : 'default profile';
702
+ ctx.writer.stdout(`${mark(auth.authenticated)} cf auth (${authLabel}): ${auth.authenticated ? 'signed in' : 'not signed in'}${auth.expiresAt ? ` · expires ${formatClock(auth.expiresAt)}` : ''}`);
703
+ if (auth.authenticated && auth.scopes === 'unknown')
704
+ ctx.writer.stdout(' scopes: unknown (API token permissions cannot be introspected)');
705
+ if (auth.missingRecommendedScopes?.length)
706
+ ctx.writer.stdout(` missing recommended scopes: ${auth.missingRecommendedScopes.join(' ')}`);
707
+ if (auth.writeScopeCount)
708
+ ctx.writer.stdout(` write scopes: ${auth.writeScopeCount}`);
709
+ for (const problem of problems) {
710
+ ctx.writer.stderr(`${problem.code}: ${problem.message}`);
711
+ if (problem.hint)
712
+ ctx.writer.stderr(` Hint: ${problem.hint}`);
713
+ }
714
+ return success ? EXIT.OK : EXIT.ERROR;
715
+ }
716
+ // ============================================================================
717
+ // accounts
718
+ // ============================================================================
719
+ async function runAccounts(ctx, options) {
720
+ assertLocalRuntimeSupported();
721
+ const warnings = [];
722
+ checkAuthEnvironment(ctx, options, warnings);
723
+ const location = await locateAndWarn(ctx, options, warnings);
724
+ const accounts = await withCfRunner(location.entry, async (runner) => {
725
+ await checkVersion(ctx, runner, location, options.auth.authMode, warnings);
726
+ assertCriticalTemplates(await runPreflight(ctx, runner, options.auth, ['account_selection']), ['account_selection']);
727
+ const scheduler = createScheduler(ctx, runner, options.auth, options.maxRequests);
728
+ await scheduler.start();
729
+ return listAccounts({ scheduler, auth: options.auth, unavailable: new Set() });
730
+ });
731
+ if (ctx.mode === 'json') {
732
+ outputJson(ctx, { success: true, accounts });
733
+ }
734
+ else if (accounts.length === 0) {
735
+ ctx.writer.stdout('cf can read no Cloudflare accounts with this login.');
736
+ }
737
+ else {
738
+ for (const account of accounts)
739
+ ctx.writer.stdout(`${account.id} ${displayText(account.name, 120)}`);
740
+ }
741
+ return ok();
742
+ }
743
+ // ============================================================================
744
+ // collect --dry-run
745
+ // ============================================================================
746
+ const REQUEST_FORMULA = 'about 19 + extra pages + zones × (33 + rulesets per zone) cf requests';
747
+ async function runCollectDryRun(ctx, options) {
748
+ assertLocalRuntimeSupported();
749
+ const warnings = [];
750
+ const location = await locateAndWarn(ctx, options, warnings);
751
+ const { prepared, preflight } = await withCfRunner(location.entry, async (runner) => ({
752
+ prepared: await checkVersion(ctx, runner, location, options.auth.authMode, warnings),
753
+ preflight: await runPreflight(ctx, runner, options.auth),
754
+ }));
755
+ const display = (id) => ['cf', ...templateDisplay(id, options.auth)];
756
+ const plan = {
757
+ accountSelection: [display('account_selection')],
758
+ account: TEMPLATE_IDS.filter((id) => id !== 'account_selection' && TEMPLATES[id].scope === 'account').map(display),
759
+ perZone: TEMPLATE_IDS.filter((id) => TEMPLATES[id].scope === 'zone' || TEMPLATES[id].scope === 'setting').map(display),
760
+ perRuleset: [display('zone_ruleset')],
761
+ notCollected: [...NOT_COLLECTABLE_ZONE_KINDS],
762
+ };
763
+ const childEnv = childEnvVariableNames(ctx.env, options.auth.authMode);
764
+ if (ctx.mode === 'json') {
765
+ outputJson(ctx, {
766
+ success: true,
767
+ dryRun: true,
768
+ cf: { version: prepared.version, path: location.packageDir, tested: prepared.tested },
769
+ plan,
770
+ verified: { commands: preflight.verified, mismatched: preflight.mismatched },
771
+ childEnv,
772
+ requestFormula: REQUEST_FORMULA,
773
+ warnings,
774
+ });
775
+ return ok();
776
+ }
777
+ const lines = [
778
+ `Using Cloudflare cf ${prepared.version} (${location.packageDir}) · ${preflight.verified}/${preflight.total} command templates mapped offline`,
779
+ ...(preflight.mismatched.length ? [`Not mapped (recorded as command_unavailable): ${preflight.mismatched.join(', ')}`] : []),
780
+ '',
781
+ 'Account selection (only without --account-id):',
782
+ ...plan.accountSelection.map((argv) => ` ${argv.join(' ')}`),
783
+ 'Account (CLOUDFLARE_ACCOUNT_ID=<ACCOUNT_ID> for every call):',
784
+ ...plan.account.map((argv) => ` ${argv.join(' ')}`),
785
+ 'Per zone (<SETTING_ID> is each of the 19 fixed zone settings):',
786
+ ...plan.perZone.map((argv) => ` ${argv.join(' ')}`),
787
+ 'Per listed ruleset:',
788
+ ...plan.perRuleset.map((argv) => ` ${argv.join(' ')}`),
789
+ `Not collectable with cf (recorded as not_collected for every zone): ${plan.notCollected.join(', ')}`,
790
+ `cf child environment (names only): ${childEnv.join(' ')}`,
791
+ `Requests: ${REQUEST_FORMULA}, at most ${options.maxRequests} per 5 minutes.`,
792
+ 'No Cloudflare or Vigilbase requests were made.',
793
+ ];
794
+ for (const line of lines)
795
+ ctx.writer.stdout(line);
796
+ return ok();
797
+ }
798
+ /**
799
+ * Advisory pre-flight for --out, before any cf spawn, so a typo cannot throw
800
+ * away a whole collection. writeSnapshotFile stays the race-safe final guard.
801
+ */
802
+ async function assertOutWritable(out, overwrite) {
803
+ const existing = await stat(out).catch(() => null);
804
+ if (existing && !overwrite)
805
+ throw new LocalCheckupError('OUTPUT_EXISTS', 'The --out file already exists; nothing was collected.');
806
+ if (existing && !existing.isFile())
807
+ throw new Error('--out must name a regular file; nothing was collected.');
808
+ const dir = dirname(out);
809
+ const parent = await stat(dir).catch(() => null);
810
+ if (!parent || !parent.isDirectory())
811
+ throw new Error('The --out directory does not exist; nothing was collected.');
812
+ try {
813
+ await access(dir, constants.W_OK);
814
+ }
815
+ catch {
816
+ throw new Error('The --out directory is not writable; nothing was collected.');
817
+ }
818
+ }
819
+ /** Before any cf spawn: check --out, or create the default checkups directory (0700, never through a symlink). */
820
+ async function prepareOutput(ctx, options, overwrite) {
821
+ if (options.out !== undefined) {
822
+ await assertOutWritable(options.out, overwrite);
823
+ return { kind: 'out', path: options.out };
824
+ }
825
+ const dir = join(options.configDir, 'checkups');
826
+ try {
827
+ await ensurePrivateDir(dir);
828
+ }
829
+ catch {
830
+ throw new Error(`Could not create ${displayPath(dir, ctx.env)} for the snapshot; pass --out <file> instead.`);
831
+ }
832
+ return { kind: 'default', dir };
833
+ }
834
+ /**
835
+ * Why a located cf cannot be used, found before any authenticated call: none
836
+ * at all, a different program, an unsupported version, or (after `--version`
837
+ * and the offline `--dry-run` mapping) a release that renamed commands the
838
+ * checkup needs, for example a newer global cf.
839
+ */
840
+ const INSTALLABLE_CODES = new Set(['CF_NOT_FOUND', 'CF_WRONG_BINARY', 'CF_UNSUPPORTED_VERSION', 'CF_ALLOWLIST_MISMATCH']);
841
+ /** Thrown inside the runner when the located cf is unusable before anything but `--version` and `--dry-run` ran. */
842
+ class UnusableCf extends Error {
843
+ cause;
844
+ constructor(cause) {
845
+ super(cause.message);
846
+ this.cause = cause;
847
+ }
848
+ }
849
+ /** `cf --version` and the offline mapping of every template; no auth, no network. */
850
+ async function prepareCf(ctx, runner, location, auth, warnings) {
851
+ const prepared = await checkVersion(ctx, runner, location, auth.authMode, warnings);
852
+ const preflight = await runPreflight(ctx, runner, auth);
853
+ assertCriticalTemplates(preflight);
854
+ // An untested cf release is used only when every template maps (spec §A3).
855
+ if (!prepared.tested && preflight.mismatched.length > 0) {
856
+ throw new LocalCheckupError('CF_ALLOWLIST_MISMATCH', `cf ${prepared.version} is untested and ${preflight.mismatched.length} command template${preflight.mismatched.length === 1 ? ' does' : 's do'} not map: ${preflight.mismatched.join(', ')}.`);
857
+ }
858
+ return { ...prepared, preflight };
859
+ }
860
+ /**
861
+ * Locate cf (`--cf-bin`, the managed install, PATH, pnpm, Volta), check its
862
+ * version and offline mapping, and run `work`. In the one command, an
863
+ * unusable or missing cf (without `--cf-bin`) leads to the managed-install
864
+ * offer, then `work` runs with the managed cf; the user's own cf is left alone.
865
+ */
866
+ async function withUsableCf(ctx, options, warnings, work) {
867
+ const canInstall = options.subcommand === 'run' && options.cfBin === undefined && options.configDir !== null;
868
+ const attempt = (location) => withCfRunner(location.entry, async (runner) => {
869
+ const warned = warnings.length;
870
+ let ready;
871
+ try {
872
+ ready = await busy(ctx, 'Checking Cloudflare\'s cf CLI', () => prepareCf(ctx, runner, location, options.auth, warnings));
873
+ }
874
+ catch (error) {
875
+ if (canInstall && error instanceof LocalCheckupError && INSTALLABLE_CODES.has(error.code)) {
876
+ // Warnings about a cf that is replaced (CF_UNTESTED_VERSION) do not describe the run.
877
+ warnings.splice(warned);
878
+ throw new UnusableCf(error);
879
+ }
880
+ throw error;
881
+ }
882
+ return work(runner, location, ready);
883
+ });
884
+ let unusable = null;
885
+ let replace = false;
886
+ let location = null;
887
+ try {
888
+ location = await busy(ctx, 'Looking for Cloudflare\'s cf CLI', () => locateAndWarn(ctx, options, warnings));
889
+ }
890
+ catch (error) {
891
+ if (!canInstall || !(error instanceof LocalCheckupError) || !INSTALLABLE_CODES.has(error.code))
892
+ throw error;
893
+ unusable = error;
894
+ }
895
+ if (location) {
896
+ try {
897
+ return { result: await attempt(location), managed: location.source === 'managed' };
898
+ }
899
+ catch (error) {
900
+ if (!(error instanceof UnusableCf))
901
+ throw error;
902
+ unusable = error.cause;
903
+ // A managed install whose cf cannot run is reinstalled, not reused.
904
+ replace = location.source === 'managed';
905
+ }
906
+ }
907
+ const installed = await offerManagedInstall(ctx, options, unusable, replace);
908
+ const managed = await locateAndWarn(ctx, options, warnings, installed);
909
+ try {
910
+ return { result: await attempt(managed), managed: managed.source === 'managed' };
911
+ }
912
+ catch (error) {
913
+ throw error instanceof UnusableCf ? error.cause : error;
914
+ }
915
+ }
916
+ /** The `[Y/n]` offer to install the pinned cf into the config directory; returns the package directory. */
917
+ async function offerManagedInstall(ctx, options, reason, replace) {
918
+ const { tui } = ctx;
919
+ const what = `Cloudflare's cf CLI (cf@${MANAGED_CF.version}, ${MANAGED_CF.approxSize})`;
920
+ if (reason.code !== 'CF_NOT_FOUND') {
921
+ if (tui)
922
+ tui.warning(`The cf found on this machine cannot be used: ${reason.message}`);
923
+ else
924
+ say(ctx, `The cf found on this machine cannot be used: ${reason.message}`);
925
+ }
926
+ const question = reason.code === 'CF_NOT_FOUND'
927
+ ? `${what} is not installed. Install it for Vigilbase now?`
928
+ : `Install ${what} for Vigilbase now?`;
929
+ // The terminal UI's question fits one line; the size goes under it.
930
+ const short = reason.code === 'CF_NOT_FOUND' ? 'cf is not installed. Install it for Vigilbase?' : 'Install a cf for Vigilbase?';
931
+ const detail = `Cloudflare's cf CLI ${MANAGED_CF.version}, ${MANAGED_CF.approxSize}; your own cf is left alone`;
932
+ if (ctx.values.isYes) {
933
+ if (tui)
934
+ tui.answeredByFlag(short, '--yes');
935
+ else
936
+ ctx.writer.stderr(`${question} [Y/n] y (--yes)`);
937
+ }
938
+ else if (!isInteractive(ctx)) {
939
+ throw new LocalCheckupError('CF_NOT_FOUND', `${reason.code === 'CF_NOT_FOUND' ? 'Cloudflare\'s cf CLI was not found' : 'No usable cf was found'}, and no terminal is available to confirm installing it.`);
940
+ }
941
+ else if (!(await (tui ? tui.confirm(short, { initial: true, detail }) : confirmDefaultYes(promptIO(ctx), question)))) {
942
+ throw new LocalCheckupError('CF_NOT_FOUND', 'cf was not installed, so the checkup cannot collect.');
943
+ }
944
+ const label = `Installing cf ${MANAGED_CF.version} (one time)`;
945
+ const progress = tui ? tui.spinner(label) : startCountsProgress(shouldShowProgress(ctx.mode), stderrStream(ctx));
946
+ if (!tui) {
947
+ if (shouldShowProgress(ctx.mode))
948
+ progress.update(`${label}…`);
949
+ else
950
+ say(ctx, `${label}…`);
951
+ }
952
+ try {
953
+ const packageDir = await installManagedCf({ configDir: options.configDir, env: ctx.env, replace, onInterrupt: () => progress.stop() });
954
+ progress.stop();
955
+ if (tui)
956
+ tui.step(`Installed cf ${MANAGED_CF.version}`);
957
+ else
958
+ say(ctx, `✓ Installed cf ${MANAGED_CF.version}`);
959
+ return packageDir;
960
+ }
961
+ catch (error) {
962
+ progress.stop();
963
+ if (error instanceof CfInstallError && error.npmOutput.length > 0) {
964
+ ctx.writer.stderr(`npm output (last ${error.npmOutput.length} lines):`);
965
+ for (const line of error.npmOutput)
966
+ ctx.writer.stderr(` ${line}`);
967
+ }
968
+ throw error;
969
+ }
970
+ }
971
+ /**
972
+ * whoami before any fan-out. In the one command with a cf profile, a missing
973
+ * or expired login on a terminal starts the read-only `cf auth create`
974
+ * sign-in, then whoami is retried once (the terminal UI offers the sign-in
975
+ * up to twice more when it fails).
976
+ */
977
+ async function startAuth(ctx, runner, options, scheduler) {
978
+ const whoami = () => busy(ctx, 'Checking the Cloudflare sign-in', () => scheduler.start());
979
+ try {
980
+ return await whoami();
981
+ }
982
+ catch (error) {
983
+ const canSignIn = options.subcommand === 'run' && options.auth.authMode === 'profile' &&
984
+ error instanceof LocalCheckupError && error.code === 'CF_AUTH_REQUIRED' && isInteractive(ctx);
985
+ if (!canSignIn)
986
+ throw error;
987
+ }
988
+ const argv = loginArgv(options.auth.profile, RECOMMENDED_CF_SCOPES, { noBrowser: false });
989
+ const loginEnv = buildChildEnv(ctx.env, { purpose: 'login', authMode: 'profile' });
990
+ // cf prints and reads on the terminal itself; the terminal UI hands it over and offers retries.
991
+ if (ctx.tui)
992
+ return ctx.tui.signIn(() => runner.runInteractive(argv, loginEnv), whoami);
993
+ say(ctx, 'Signing in to Cloudflare with a read-only login. Your browser will open…');
994
+ const code = await runner.runInteractive(argv, loginEnv, { stdoutToStderr: ctx.mode === 'json' });
995
+ if (code !== 0)
996
+ throw new LocalCheckupError('CF_AUTH_REQUIRED', 'The Cloudflare sign-in (cf auth create) did not complete.');
997
+ return scheduler.start();
998
+ }
999
+ async function collectSnapshot(ctx, options, session) {
1000
+ const full = options.subcommand === 'run';
1001
+ const log = (line) => (full ? say(ctx, line) : info(ctx, line));
1002
+ const overwrite = Boolean(ctx.values.isOverwrite);
1003
+ const target = await prepareOutput(ctx, options, overwrite);
1004
+ const warnings = [];
1005
+ checkAuthEnvironment(ctx, options, warnings);
1006
+ const started = Date.now();
1007
+ const { result, managed } = await withUsableCf(ctx, options, warnings, async (runner, location, prepared) => {
1008
+ const { preflight } = prepared;
1009
+ if (!full)
1010
+ info(ctx, `Using Cloudflare cf ${prepared.version} (${location.packageDir}) · ${preflight.verified}/${preflight.total} command templates mapped offline`);
1011
+ if (preflight.mismatched.length) {
1012
+ warn(ctx, warnings, { code: 'CF_COMMANDS_UNAVAILABLE', message: `Not mapped in this cf release (recorded as command_unavailable): ${preflight.mismatched.join(', ')}` });
1013
+ }
1014
+ const scheduler = createScheduler(ctx, runner, options.auth, options.maxRequests);
1015
+ const whoami = await startAuth(ctx, runner, options, scheduler);
1016
+ if (ctx.tui)
1017
+ ctx.tui.signedIn(signInSummary(options.auth, whoami, ctx.tui));
1018
+ else
1019
+ log(full ? `✓ Signed in · ${profileLine(options.auth, whoami)}` : profileLine(options.auth, whoami));
1020
+ if (options.auth.authMode === 'default' && whoami.scopes?.some((scope) => !isReadOnlyScope(scope))) {
1021
+ warn(ctx, warnings, { code: 'DEFAULT_PROFILE_WRITE_SCOPES', message: 'The default cf profile has write scopes; the checkup still runs only read commands. Prefer the read-only profile (--cf-auth profile).' });
1022
+ }
1023
+ let progress = startCountsProgress(false);
1024
+ // The terminal UI's progress bar, once the zones are known.
1025
+ let live = null;
1026
+ const env = {
1027
+ scheduler,
1028
+ auth: options.auth,
1029
+ unavailable: new Set(preflight.mismatched),
1030
+ onProgress: (state) => (live ? live.update(state) : progress.update(`zones ${state.zonesDone}/${state.zonesTotal} · ${state.requests} requests`)),
1031
+ };
1032
+ const bound = options.accountId ?? session?.accountId ?? null;
1033
+ const accountId = bound ?? await chooseAccount(ctx, env, log);
1034
+ if (ctx.tui && bound !== null)
1035
+ ctx.tui.account(maskId(accountId, ctx.tui.ellipsis), options.accountId !== undefined ? 'from --account-id' : 'bound to this checkup');
1036
+ try {
1037
+ const startedAt = new Date();
1038
+ const entries = await collectAccount(env, accountId, {
1039
+ onZones: (zones) => {
1040
+ const requests = estimateRequests(zones.length);
1041
+ if (ctx.tui) {
1042
+ live = ctx.tui.collecting({ zones: zones.length, plannedReads: requests, maxRequests: options.maxRequests });
1043
+ return;
1044
+ }
1045
+ const seconds = estimateBudgetSeconds(options.maxRequests, requests);
1046
+ log(`Account ${maskId(accountId)} · ${zones.length} zone${zones.length === 1 ? '' : 's'} · ~${requests} cf requests at ≤${options.maxRequests} per 5 min (${formatDuration(seconds)})`);
1047
+ // The spinner starts once zones are known (after any account prompt and the estimate line).
1048
+ progress = startCountsProgress(shouldShowProgress(ctx.mode), stderrStream(ctx));
1049
+ progress.update(`zones 0/${zones.length} · ${scheduler.invocations} requests`);
1050
+ },
1051
+ });
1052
+ progress.stop();
1053
+ live?.stop();
1054
+ const collector = {
1055
+ name: SNAPSHOT_COLLECTOR_NAME,
1056
+ version: CLI_VERSION,
1057
+ cf: { version: prepared.version },
1058
+ authSource: authSourceFor(options.auth.authMode),
1059
+ platform: process.platform,
1060
+ startedAt: startedAt.toISOString(),
1061
+ finishedAt: new Date().toISOString(),
1062
+ cfInvocations: scheduler.invocations,
1063
+ };
1064
+ const { snapshot, text } = buildSnapshot(accountId, collector, entries);
1065
+ if (Buffer.byteLength(text) > SNAPSHOT_LIMITS.defaultServerMaxBytes) {
1066
+ warn(ctx, warnings, {
1067
+ code: 'SNAPSHOT_OVER_DEFAULT_UPLOAD_LIMIT',
1068
+ message: `The snapshot is larger than ${SNAPSHOT_LIMITS.defaultServerMaxBytes / 1024 / 1024} MiB, the default upload limit.`,
1069
+ });
1070
+ }
1071
+ const written = await writeToTarget(target, accountId, startedAt, text, overwrite);
1072
+ return { ...written, snapshot, version: prepared.version, cfInvocations: scheduler.invocations };
1073
+ }
1074
+ finally {
1075
+ progress.stop();
1076
+ live?.stop();
1077
+ }
1078
+ });
1079
+ return { ...result, durationMs: Date.now() - started, warnings, usedProfile: options.auth.authMode === 'profile', managedCf: managed };
1080
+ }
1081
+ function snapshotPayload(outcome) {
1082
+ return {
1083
+ path: outcome.path,
1084
+ bytes: outcome.bytes,
1085
+ sha256: outcome.sha256,
1086
+ schemaVersion: outcome.snapshot.schemaVersion,
1087
+ accountId: outcome.snapshot.accountId,
1088
+ zoneCount: zoneCount(outcome.snapshot),
1089
+ entries: countEntries(outcome.snapshot.entries),
1090
+ };
1091
+ }
1092
+ function deniedReads(snapshot) {
1093
+ return snapshot.entries.filter((entry) => {
1094
+ const result = entry.result;
1095
+ return !result.ok && result.reason === 'http_error' && result.status === 403;
1096
+ }).length;
1097
+ }
1098
+ async function runCollect(ctx, options) {
1099
+ assertLocalRuntimeSupported();
1100
+ const outcome = await collectSnapshot(ctx, options, null);
1101
+ if (ctx.mode === 'json') {
1102
+ outputJson(ctx, {
1103
+ success: true,
1104
+ snapshot: snapshotPayload(outcome),
1105
+ cf: { version: outcome.version, authSource: authSourceFor(options.auth.authMode) },
1106
+ cfInvocations: outcome.cfInvocations,
1107
+ durationMs: outcome.durationMs,
1108
+ warnings: outcome.warnings,
1109
+ });
1110
+ return ok();
1111
+ }
1112
+ const summary = readsSummary(countEntries(outcome.snapshot.entries), deniedReads(outcome.snapshot));
1113
+ ctx.writer.stdout(`✓ Wrote ${outcome.path} (${formatBytes(outcome.bytes)}, sha256 ${shortSha(outcome.sha256)})${summary ? ` · ${summary}` : ''}`);
1114
+ ctx.writer.stdout(`Next: inspect it, then upload it with ${NPX_COMMAND} cloudflare-checkup submit ${shellArg(outcome.path)}`);
1115
+ return ok();
1116
+ }
1117
+ /** The one command's summary of the written file: counts and data categories, no values. */
1118
+ function printCollectSummary(ctx, outcome) {
1119
+ const { snapshot } = outcome;
1120
+ const counts = countEntries(snapshot.entries);
1121
+ const denied = deniedReads(snapshot);
1122
+ const zones = zoneCount(snapshot);
1123
+ const notOk = [
1124
+ counts.notCollected ? `${counts.notCollected} not collectable with cf` : null,
1125
+ denied ? `${denied} denied` : null,
1126
+ counts.httpError - denied ? `${counts.httpError - denied} failed with another HTTP error` : null,
1127
+ counts.collectionFailed ? `${counts.collectionFailed} could not be collected` : null,
1128
+ ].filter((part) => part !== null);
1129
+ const categories = dataCategories(snapshot);
1130
+ const data = [
1131
+ [categories.memberEmails, 'member email', 'member emails'],
1132
+ [categories.accessRuleValues, 'access-rule value', 'access-rule values'],
1133
+ [categories.dnsHostnames, 'DNS name', 'DNS names'],
1134
+ [categories.pseudonymizedIps, 'pseudonymised IP', 'pseudonymised IPs'],
1135
+ [categories.ruleDescriptions, 'rule description', 'rule descriptions'],
1136
+ ];
1137
+ const dataParts = data.filter(([count]) => count > 0).map(([count, one, many]) => `${count} ${count === 1 ? one : many}`);
1138
+ if (ctx.tui) {
1139
+ ctx.tui.collected({
1140
+ zones, total: counts.total, ok: counts.ok, notCollectable: counts.notCollected, denied,
1141
+ failed: counts.httpError - denied + counts.collectionFailed,
1142
+ path: displayPath(outcome.path, ctx.env), bytes: outcome.bytes, contents: dataParts,
1143
+ inspect: `${NPX_COMMAND} cloudflare-checkup inspect ${shellArg(displayPath(outcome.path, ctx.env))}`,
1144
+ });
1145
+ return;
1146
+ }
1147
+ say(ctx, `✓ Saved ${displayPath(outcome.path, ctx.env)} (${formatBytes(outcome.bytes)})`);
1148
+ say(ctx, ` ${zones} zone${zones === 1 ? '' : 's'} · ${counts.total} reads (${[`${counts.ok} ok`, ...notOk].join(', ')})${dataParts.length ? ` · ${dataParts.join(' · ')}` : ''}`);
1149
+ say(ctx, ` No credentials. Inspect it: ${NPX_COMMAND} cloudflare-checkup inspect ${shellArg(outcome.path)}`);
1150
+ }
1151
+ // ============================================================================
1152
+ // The one command
1153
+ // ============================================================================
1154
+ function clientOptions(ctx, origin) {
1155
+ return {
1156
+ origin,
1157
+ cliVersion: CLI_VERSION,
1158
+ userAgent: ctx.config.userAgent,
1159
+ ...(ctx.values.timeout !== undefined ? { timeoutMs: ctx.config.timeoutMs } : {}),
1160
+ };
1161
+ }
1162
+ /**
1163
+ * The email verification (prompts on stderr, answers from piped stdin
1164
+ * without a terminal), then `✓ Verified j***@acme.com`. Nothing is sent
1165
+ * before an address is entered, and cf is not touched before it is verified.
1166
+ */
1167
+ async function verifyRequester(ctx, options, origin) {
1168
+ const verified = await verifyEmail({
1169
+ client: new VerificationClient(clientOptions(ctx, origin)),
1170
+ io: promptIO(ctx),
1171
+ locale: emailLocale(ctx.env),
1172
+ ...(options.email !== undefined ? { email: options.email } : {}),
1173
+ say: (line) => say(ctx, line),
1174
+ notice: (line) => ctx.writer.stderr(line),
1175
+ // The terminal UI shows `✓ verified` on the code's line.
1176
+ ...(ctx.tui ? { view: ctx.tui.emailView() } : {}),
1177
+ });
1178
+ if (!ctx.tui)
1179
+ say(ctx, `✓ Verified ${verified.requester}`);
1180
+ return verified;
1181
+ }
1182
+ /**
1183
+ * The checkup the verified email started. A session that expired (a long
1184
+ * collection, or a slow answer to the upload question) is renewed once with
1185
+ * the token and code still in memory; vigilbase.com mints the next session
1186
+ * code for the same checkup. The snapshot file is kept either way.
1187
+ */
1188
+ class CheckupSession {
1189
+ ctx;
1190
+ origin;
1191
+ verified;
1192
+ constructor(ctx, origin, verified) {
1193
+ this.ctx = ctx;
1194
+ this.origin = origin;
1195
+ this.verified = verified;
1196
+ }
1197
+ client() {
1198
+ return new SubmitClient({ ...clientOptions(this.ctx, this.origin), code: this.verified.sessionCode });
1199
+ }
1200
+ async withRenewal(call) {
1201
+ try {
1202
+ return await call(this.client());
1203
+ }
1204
+ catch (error) {
1205
+ if (!(error instanceof LocalCheckupError) || error.code !== 'SESSION_EXPIRED' || !this.verified.renewable)
1206
+ throw error;
1207
+ }
1208
+ try {
1209
+ await this.verified.renew();
1210
+ }
1211
+ catch (error) {
1212
+ if (error instanceof LocalCheckupError && (error.code === 'VERIFICATION_EXPIRED' || error.code === 'VERIFICATION_INVALID')) {
1213
+ throw new LocalCheckupError('SESSION_EXPIRED', 'The checkup session expired, and the code from the email can no longer renew it.', error.status !== undefined ? { status: error.status } : {});
1214
+ }
1215
+ throw error;
1216
+ }
1217
+ if (this.ctx.tui)
1218
+ this.ctx.tui.step('Renewed the checkup session, which had expired');
1219
+ else
1220
+ say(this.ctx, '✓ Renewed the checkup session, which had expired');
1221
+ return call(this.client());
1222
+ }
1223
+ getSession() {
1224
+ return this.withRenewal((client) => client.getSession());
1225
+ }
1226
+ postSnapshot(bytes, sha256) {
1227
+ return this.withRenewal((client) => client.postSnapshot(bytes, sha256));
1228
+ }
1229
+ }
1230
+ /** Failures after the snapshot is saved that a later `submit <file>` can fix without collecting again. */
1231
+ const UPLOAD_LATER_CODES = new Set(['SESSION_INVALID', 'SESSION_EXPIRED', 'SESSION_CLOSED', 'VERIFICATION_EXPIRED', 'VERIFICATION_INVALID',
1232
+ 'SUBMIT_FAILED', 'VIGILBASE_RATE_LIMITED', 'VIGILBASE_CHALLENGED', 'LOCAL_MODE_DISABLED']);
1233
+ /**
1234
+ * Right before such an error is reported (stderr, both modes): the `submit`
1235
+ * command for the saved file. A line of its own, because hints are capped at
1236
+ * 300 characters and a path has no fixed length; in the terminal UI, the
1237
+ * command has a line of its own too, never wrapped, so it can be copied.
1238
+ */
1239
+ function noteSavedSnapshot(ctx, error, path) {
1240
+ if (!(error instanceof LocalCheckupError) || !UPLOAD_LATER_CODES.has(error.code))
1241
+ return;
1242
+ const submit = `${NPX_COMMAND} cloudflare-checkup submit ${shellArg(path)}`;
1243
+ if (ctx.tui)
1244
+ ctx.tui.uploadLater(submit);
1245
+ else
1246
+ ctx.writer.stderr(`The snapshot stays saved. To upload it later without collecting again, run ${submit} (it verifies your email again).`);
1247
+ }
1248
+ /** Session checks that need no upload: CLI version, single use, and the bound account. */
1249
+ function assertSessionUsable(session, accountId) {
1250
+ assertSessionAccepts(session, CLI_VERSION, SNAPSHOT_SCHEMA_VERSION);
1251
+ if (session.state === 'completed')
1252
+ throw new LocalCheckupError('SESSION_USED', 'This checkup already has results, so it cannot accept a new snapshot.');
1253
+ // A checkup the email verification just started is never processing; this guards any other state.
1254
+ if (session.state === 'processing')
1255
+ throw new LocalCheckupError('SESSION_BUSY', 'A snapshot is already being processed for this checkup.');
1256
+ if (accountId !== undefined && session.accountId !== null && session.accountId !== accountId) {
1257
+ throw new LocalCheckupError('ACCOUNT_MISMATCH', `This checkup is bound to Cloudflare account ${maskId(session.accountId)}, not ${maskId(accountId)}. Nothing was collected.`);
1258
+ }
1259
+ }
1260
+ /**
1261
+ * Without a terminal, a missing cf login and a choice between accounts show
1262
+ * only once cf runs, after the email was verified (`assertConfirmationAvailable`
1263
+ * cannot tell beforehand). Their hint then adds that running the command again
1264
+ * with the same address waits for vigilbase.com's 8 minutes per address. A hint
1265
+ * that is not the code's default (the API token one) is left as it is.
1266
+ */
1267
+ function withSameAddressWait(error) {
1268
+ if (!(error instanceof LocalCheckupError) || (error.code !== 'CF_AUTH_REQUIRED' && error.code !== 'ACCOUNT_SELECTION_REQUIRED'))
1269
+ return error;
1270
+ if (error.hint !== LOCAL_ERROR_HINTS[error.code])
1271
+ return error;
1272
+ return new LocalCheckupError(error.code, error.message, {
1273
+ hint: `${error.hint} ${SAME_ADDRESS_WAIT}.`,
1274
+ ...(error.status !== undefined ? { status: error.status } : {}),
1275
+ ...(error.retryAfterSec !== undefined ? { retryAfterSec: error.retryAfterSec } : {}),
1276
+ });
1277
+ }
1278
+ function runFull(ctx, options) {
1279
+ return withTerminalUi(ctx, (local) => runOneCommand(local, options));
1280
+ }
1281
+ async function runOneCommand(ctx, options) {
1282
+ const origin = options.origin ?? PRODUCTION_ORIGIN;
1283
+ if (ctx.tui)
1284
+ ctx.tui.intro();
1285
+ else
1286
+ say(ctx, BANNER);
1287
+ assertLocalRuntimeSupported();
1288
+ // A bad --out never costs an emailed code (collectSnapshot checks it again).
1289
+ if (options.out !== undefined)
1290
+ await assertOutWritable(options.out, Boolean(ctx.values.isOverwrite));
1291
+ // 1-3. The email is verified, and the checkup it started is checked, before cf is touched.
1292
+ const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
1293
+ const early = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
1294
+ assertSessionUsable(early, options.accountId);
1295
+ // 4-9. cf, sign-in, account, collection.
1296
+ let outcome;
1297
+ try {
1298
+ outcome = await collectSnapshot(ctx, options, early);
1299
+ }
1300
+ catch (error) {
1301
+ throw isInteractive(ctx) ? error : withSameAddressWait(error);
1302
+ }
1303
+ printCollectSummary(ctx, outcome);
1304
+ if (ctx.tui)
1305
+ ctx.tui.cancelMessage = 'Cancelled. Nothing was uploaded; the snapshot stays saved.';
1306
+ // 10. The session, re-checked (and renewed once if it expired) right before uploading.
1307
+ let result;
1308
+ try {
1309
+ const session = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
1310
+ // Upload exactly the bytes on disk, re-validated.
1311
+ const loaded = await loadSnapshotFile(outcome.path);
1312
+ result = await uploadLoaded(ctx, { checkup, session, origin, loaded });
1313
+ }
1314
+ catch (error) {
1315
+ noteSavedSnapshot(ctx, error, outcome.path);
1316
+ throw error;
1317
+ }
1318
+ if (ctx.mode === 'json') {
1319
+ outputJson(ctx, { ...result, snapshot: snapshotPayload(outcome), warnings: outcome.warnings });
1320
+ return ok();
1321
+ }
1322
+ sayLogoutHint(ctx, outcome.usedProfile, outcome.managedCf);
1323
+ return ok();
1324
+ }
1325
+ /** logout removes the profile, and only the cf that Vigilbase installed (never the user's own). */
1326
+ function sayLogoutHint(ctx, usedProfile, managedCf) {
1327
+ const logout = `${NPX_COMMAND} cloudflare-checkup logout`;
1328
+ const lead = usedProfile ? `Remove the read-only Cloudflare login${managedCf ? ' and the cf Vigilbase installed' : ''} later with:`
1329
+ : managedCf ? 'Remove the cf that Vigilbase installed later with:' : null;
1330
+ if (lead === null)
1331
+ return;
1332
+ if (ctx.tui)
1333
+ ctx.tui.footnote(lead, logout);
1334
+ else
1335
+ say(ctx, `${lead} ${logout}`);
1336
+ }
1337
+ // ============================================================================
1338
+ // inspect
1339
+ // ============================================================================
1340
+ async function runInspect(ctx, options) {
1341
+ const loaded = await loadSnapshotFile(options.file);
1342
+ const { snapshot } = loaded;
1343
+ const name = accountName(snapshot);
1344
+ const categories = dataCategories(snapshot);
1345
+ const byKind = countEntriesByKind(snapshot.entries);
1346
+ if (ctx.mode === 'json') {
1347
+ outputJson(ctx, {
1348
+ success: true,
1349
+ snapshot: {
1350
+ path: resolve(loaded.path),
1351
+ bytes: loaded.bytes.byteLength,
1352
+ sha256: loaded.sha256,
1353
+ schemaVersion: snapshot.schemaVersion,
1354
+ accountId: snapshot.accountId,
1355
+ accountName: name,
1356
+ zoneCount: zoneCount(snapshot),
1357
+ collectedAt: snapshot.collector.finishedAt,
1358
+ collector: snapshot.collector,
1359
+ },
1360
+ entries: { byKind },
1361
+ dataCategories: categories,
1362
+ });
1363
+ return ok();
1364
+ }
1365
+ const counts = countEntries(snapshot.entries);
1366
+ const { collector } = snapshot;
1367
+ const lines = [
1368
+ `Snapshot ${options.file} ${formatBytes(loaded.bytes.byteLength)} sha256 ${loaded.sha256}`,
1369
+ `Schema ${snapshot.schemaVersion} (valid)`,
1370
+ `Account ${snapshot.accountId}${name !== null ? ` ${name} (as reported)` : ''}`,
1371
+ `Zones ${zoneCount(snapshot)}`,
1372
+ `Collected ${collector.finishedAt} by ${collector.name} ${collector.version} with cf ${collector.cf.version} (${collector.authSource}, ${collector.platform}, ${collector.cfInvocations} cf calls)`,
1373
+ `Reads ${counts.total} · ${counts.ok} ok · ${counts.httpError} HTTP error · ${counts.notCollected} not collectable with cf · ${counts.collectionFailed} collection failed`,
1374
+ ...Object.entries(byKind).map(([kind, kindCounts]) => {
1375
+ const notOk = kindCounts.total - kindCounts.ok;
1376
+ return ` ${kind.padEnd(34)} ${String(kindCounts.total).padStart(5)}${notOk ? ` (${notOk} not ok)` : ''}`;
1377
+ }),
1378
+ 'Free text and identifiers in this file:',
1379
+ ` member emails ${categories.memberEmails} · access-rule values ${categories.accessRuleValues} · non-proxied DNS names ${categories.dnsHostnames} · pseudonymised IPs ${categories.pseudonymizedIps} · rule descriptions ${categories.ruleDescriptions}`,
1380
+ 'No credentials are included, and origin IPs are pseudonymised.',
1381
+ ];
1382
+ for (const line of lines)
1383
+ ctx.writer.stdout(line);
1384
+ return ok();
1385
+ }
1386
+ // ============================================================================
1387
+ // submit (and the upload step of the one command)
1388
+ // ============================================================================
1389
+ function runSubmit(ctx, options) {
1390
+ return withTerminalUi(ctx, (local) => submitFile(local, options));
1391
+ }
1392
+ async function submitFile(ctx, options) {
1393
+ const origin = options.origin ?? PRODUCTION_ORIGIN;
1394
+ const loaded = await loadSnapshotFile(options.file);
1395
+ assertFresh(loaded.snapshot);
1396
+ const zones = zoneCount(loaded.snapshot);
1397
+ 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)}`;
1398
+ if (ctx.tui) {
1399
+ ctx.tui.intro();
1400
+ const { ellipsis } = ctx.tui;
1401
+ ctx.tui.step('Snapshot', ctx.tui.join([
1402
+ displayText(displayPath(resolve(options.file), ctx.env), 120, ellipsis),
1403
+ formatBytes(loaded.bytes.byteLength),
1404
+ `account ${maskId(loaded.snapshot.accountId, ellipsis)}`,
1405
+ `${zones} zone${zones === 1 ? '' : 's'}`,
1406
+ `collected ${formatClock(loaded.snapshot.collector.finishedAt)}`,
1407
+ ]));
1408
+ }
1409
+ else if (ctx.mode === 'json')
1410
+ ctx.writer.stderr(head);
1411
+ else
1412
+ ctx.writer.stdout(head);
1413
+ // The file is checked before the email is verified, so a bad file never sends a code.
1414
+ const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
1415
+ const session = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
1416
+ const result = await uploadLoaded(ctx, { checkup, session, origin, loaded });
1417
+ if (ctx.mode === 'json')
1418
+ outputJson(ctx, result);
1419
+ return ok();
1420
+ }
1421
+ /** Refused locally: vigilbase.com accepts snapshots finished in the last 24 h (5 min clock skew). */
1422
+ function assertFresh(snapshot, now = Date.now()) {
1423
+ const finished = Date.parse(snapshot.collector.finishedAt);
1424
+ if (!(finished >= now - SNAPSHOT_LIMITS.maxAgeHours * 3_600_000) || finished > now + SNAPSHOT_LIMITS.maxFutureSkewSeconds * 1_000) {
1425
+ throw new LocalCheckupError('SNAPSHOT_STALE', 'The snapshot was collected more than 24 hours ago (or is dated in the future).');
1426
+ }
1427
+ }
1428
+ function numberAt(value, ...path) {
1429
+ let current = value;
1430
+ for (const key of path) {
1431
+ if (!current || typeof current !== 'object')
1432
+ return undefined;
1433
+ current = current[key];
1434
+ }
1435
+ return typeof current === 'number' && Number.isFinite(current) ? current : undefined;
1436
+ }
1437
+ /**
1438
+ * Opens the results page in the browser, unless `--no-open`, `--json` or no
1439
+ * browser is available, and only when it is on the upload origin; true when
1440
+ * a browser was started. The link is printed either way.
1441
+ */
1442
+ function openResults(ctx, url, origin) {
1443
+ if (ctx.mode === 'json' || ctx.values.isNoOpen || isHeadlessEnvironment(ctx.env))
1444
+ return false;
1445
+ if (new URL(url).origin !== origin)
1446
+ return false;
1447
+ return openUrlInBrowser(url, ctx.env).isOpened;
1448
+ }
1449
+ /**
1450
+ * Checks against the session (CLI version, bound account, limits), the
1451
+ * confirmation naming the masked verified address, then `POST` of exactly the
1452
+ * loaded bytes, and the results link.
1453
+ */
1454
+ async function uploadLoaded(ctx, input) {
1455
+ const { checkup, session, origin, loaded } = input;
1456
+ const { snapshot } = loaded;
1457
+ assertFresh(snapshot);
1458
+ assertSessionAccepts(session, CLI_VERSION, snapshot.schemaVersion);
1459
+ if (session.accountId !== null && session.accountId !== snapshot.accountId) {
1460
+ 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.`);
1461
+ }
1462
+ const zones = zoneCount(snapshot);
1463
+ if (loaded.bytes.byteLength > session.accepts.maxBytes || zones > session.accepts.maxZones) {
1464
+ 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.`);
1465
+ }
1466
+ const requester = displayRequester(session.requester);
1467
+ const question = `Results go to ${requester}. Upload ${formatBytes(loaded.bytes.byteLength)} to ${origin}?`;
1468
+ const flag = ctx.values.isYes ? '--yes' : '--confirm';
1469
+ const isConfirmed = Boolean(ctx.values.isYes || ctx.values.isConfirm);
1470
+ const bytes = loaded.bytes.byteLength;
1471
+ const confirmed = ctx.tui
1472
+ ? await ctx.tui.confirmUpload({ bytes, where: origin === PRODUCTION_ORIGIN ? new URL(origin).host : origin, requester, flag: isConfirmed ? flag : null })
1473
+ : await confirmUpload(promptIO(ctx), question, { isConfirmed, flag });
1474
+ if (!confirmed)
1475
+ throw new Error('Upload cancelled; nothing was sent. The snapshot file is kept.');
1476
+ const post = () => checkup.postSnapshot(loaded.bytes, loaded.sha256);
1477
+ const result = ctx.tui ? await ctx.tui.uploading(bytes, post) : await post();
1478
+ if (ctx.mode !== 'json') {
1479
+ const passed = numberAt(result.report, 'summary', 'passed');
1480
+ const warnings = numberAt(result.report, 'summary', 'warnings');
1481
+ const failedChecks = numberAt(result.report, 'summary', 'failed');
1482
+ const notCollected = (numberAt(result.provenance, 'coverage', 'reads', 'notCollected') ?? 0) +
1483
+ (numberAt(result.provenance, 'coverage', 'reads', 'collectionFailed') ?? 0);
1484
+ const parts = [
1485
+ passed !== undefined ? `${passed} passed` : null,
1486
+ warnings !== undefined ? `${warnings} warning${warnings === 1 ? '' : 's'}` : null,
1487
+ failedChecks !== undefined ? `${failedChecks} failed` : null,
1488
+ `${notCollected} reads not collected locally`,
1489
+ ].filter((part) => part !== null);
1490
+ const summary = `${result.replayed ? '✓ Already uploaded (same file); nothing changed.' : '✓ Uploaded.'} Free summary: ${parts.join(' · ')}`;
1491
+ const emailed = result.resultsUrl !== undefined ? `A link has also been emailed to ${requester}.` : `A link to the results has been emailed to ${requester}.`;
1492
+ if (ctx.tui) {
1493
+ const opening = result.resultsUrl !== undefined && openResults(ctx, result.resultsUrl, origin);
1494
+ ctx.tui.uploaded({
1495
+ replayed: result.replayed, notCollected, requester, opening,
1496
+ ...(passed !== undefined ? { passed } : {}),
1497
+ ...(warnings !== undefined ? { warnings } : {}),
1498
+ ...(failedChecks !== undefined ? { failed: failedChecks } : {}),
1499
+ ...(result.resultsUrl !== undefined ? { resultsUrl: result.resultsUrl } : {}),
1500
+ }, [summary, ...(result.resultsUrl !== undefined ? [`Results: ${result.resultsUrl}`] : []), emailed]);
1501
+ }
1502
+ else {
1503
+ ctx.writer.stdout(summary);
1504
+ if (result.resultsUrl !== undefined) {
1505
+ ctx.writer.stdout(`Results: ${result.resultsUrl}`);
1506
+ openResults(ctx, result.resultsUrl, origin);
1507
+ }
1508
+ ctx.writer.stdout(emailed);
1509
+ }
1510
+ }
1511
+ return {
1512
+ success: true,
1513
+ replayed: result.replayed,
1514
+ snapshotSha256: result.snapshotSha256,
1515
+ provenance: result.provenance,
1516
+ report: result.report,
1517
+ ...(result.resultsUrl !== undefined ? { resultsUrl: result.resultsUrl } : {}),
1518
+ };
1519
+ }
1520
+ //# sourceMappingURL=local.js.map