@vigilbase/cli 0.1.4 → 0.2.1
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.
- package/CLOUDFLARE.md +3 -4
- package/COMMANDS.md +158 -83
- package/LOCAL.md +721 -0
- package/ONE.md +2 -4
- package/README.md +341 -123
- package/SECURITY.md +2 -2
- package/dist/auth/device-auth.d.ts +0 -1
- package/dist/auth/device-auth.js +13 -8
- package/dist/auth/index.d.ts +4 -2
- package/dist/auth/index.js +3 -2
- package/dist/auth/provider.d.ts +18 -0
- package/dist/auth/provider.js +71 -0
- package/dist/auth/refresh.d.ts +29 -0
- package/dist/auth/refresh.js +199 -0
- package/dist/auth/store.d.ts +103 -4
- package/dist/auth/store.js +400 -74
- package/dist/auth/token-metadata.d.ts +9 -0
- package/dist/auth/token-metadata.js +21 -0
- package/dist/auth/types.d.ts +8 -2
- package/dist/auth/types.js +0 -1
- package/dist/bin.d.ts +0 -1
- package/dist/bin.js +0 -1
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +45 -15
- package/dist/commands/account.d.ts +0 -1
- package/dist/commands/account.js +0 -1
- package/dist/commands/auth.d.ts +0 -1
- package/dist/commands/auth.js +82 -22
- package/dist/commands/billing.d.ts +0 -1
- package/dist/commands/billing.js +0 -1
- package/dist/commands/cloudflare.d.ts +1 -2
- package/dist/commands/cloudflare.js +6 -6
- package/dist/commands/helpers.d.ts +15 -1
- package/dist/commands/helpers.js +2 -3
- package/dist/commands/index.d.ts +3 -2
- package/dist/commands/index.js +6 -2
- package/dist/commands/legacy-validation.d.ts +0 -1
- package/dist/commands/legacy-validation.js +0 -1
- package/dist/commands/local.d.ts +35 -0
- package/dist/commands/local.js +1543 -0
- package/dist/commands/one.d.ts +0 -1
- package/dist/commands/one.js +6 -7
- package/dist/commands/public-tools.d.ts +0 -1
- package/dist/commands/public-tools.js +55 -17
- package/dist/commands/runs.d.ts +0 -1
- package/dist/commands/runs.js +0 -1
- package/dist/commands/security.d.ts +1 -2
- package/dist/commands/security.js +1 -2
- package/dist/commands/watchdog.d.ts +0 -1
- package/dist/commands/watchdog.js +0 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -2
- package/dist/lib/banner.d.ts +0 -1
- package/dist/lib/banner.js +0 -1
- package/dist/lib/client.d.ts +4 -2
- package/dist/lib/client.js +42 -18
- package/dist/lib/cloudflare-client.d.ts +0 -1
- package/dist/lib/cloudflare-client.js +0 -1
- package/dist/lib/config.d.ts +31 -3
- package/dist/lib/config.js +72 -13
- package/dist/lib/format.d.ts +2 -1
- package/dist/lib/format.js +27 -7
- package/dist/lib/help.d.ts +1 -2
- package/dist/lib/help.js +37 -17
- package/dist/lib/network.d.ts +10 -1
- package/dist/lib/network.js +33 -5
- package/dist/lib/one-client.d.ts +5 -3
- package/dist/lib/one-client.js +49 -3
- package/dist/lib/one-operations.d.ts +0 -1
- package/dist/lib/one-operations.js +0 -1
- package/dist/lib/open-url.d.ts +0 -1
- package/dist/lib/open-url.js +0 -1
- package/dist/lib/platform.d.ts +2 -0
- package/dist/lib/platform.js +4 -0
- package/dist/lib/progress.d.ts +0 -1
- package/dist/lib/progress.js +0 -1
- package/dist/lib/security-client.d.ts +1 -2
- package/dist/lib/security-client.js +0 -1
- package/dist/lib/skill-fetch.d.ts +0 -1
- package/dist/lib/skill-fetch.js +0 -1
- package/dist/lib/system-trust.d.ts +0 -1
- package/dist/lib/system-trust.js +0 -1
- package/dist/lib/tui.d.ts +308 -0
- package/dist/lib/tui.js +846 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +94 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.js +183 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +112 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +312 -0
- package/dist/local/cloudflare-checkup/cf/child-env.d.ts +31 -0
- package/dist/local/cloudflare-checkup/cf/child-env.js +80 -0
- package/dist/local/cloudflare-checkup/cf/install.d.ts +105 -0
- package/dist/local/cloudflare-checkup/cf/install.js +308 -0
- package/dist/local/cloudflare-checkup/cf/locate.d.ts +43 -0
- package/dist/local/cloudflare-checkup/cf/locate.js +227 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +88 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.js +274 -0
- package/dist/local/cloudflare-checkup/collector.d.ts +47 -0
- package/dist/local/cloudflare-checkup/collector.js +295 -0
- package/dist/local/cloudflare-checkup/contract.d.ts +2804 -0
- package/dist/local/cloudflare-checkup/contract.js +1620 -0
- package/dist/local/cloudflare-checkup/email-verification.d.ts +124 -0
- package/dist/local/cloudflare-checkup/email-verification.js +211 -0
- package/dist/local/cloudflare-checkup/errors.d.ts +29 -0
- package/dist/local/cloudflare-checkup/errors.js +103 -0
- package/dist/local/cloudflare-checkup/output.d.ts +23 -0
- package/dist/local/cloudflare-checkup/output.js +88 -0
- package/dist/local/cloudflare-checkup/prompts.d.ts +40 -0
- package/dist/local/cloudflare-checkup/prompts.js +139 -0
- package/dist/local/cloudflare-checkup/rate-budget.d.ts +41 -0
- package/dist/local/cloudflare-checkup/rate-budget.js +79 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts +52 -0
- package/dist/local/cloudflare-checkup/snapshot-file.js +185 -0
- package/dist/local/cloudflare-checkup/submit-client.d.ts +112 -0
- package/dist/local/cloudflare-checkup/submit-client.js +395 -0
- package/dist/local/cloudflare-checkup/terminal-ui.d.ts +170 -0
- package/dist/local/cloudflare-checkup/terminal-ui.js +403 -0
- package/dist/types.d.ts +25 -3
- package/dist/types.js +16 -1
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -1
- package/package.json +12 -12
- package/dist/auth/device-auth.d.ts.map +0 -1
- package/dist/auth/device-auth.js.map +0 -1
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/store.d.ts.map +0 -1
- package/dist/auth/store.js.map +0 -1
- package/dist/auth/types.d.ts.map +0 -1
- package/dist/auth/types.js.map +0 -1
- package/dist/bin.d.ts.map +0 -1
- package/dist/bin.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/commands/account.d.ts.map +0 -1
- package/dist/commands/account.js.map +0 -1
- package/dist/commands/auth.d.ts.map +0 -1
- package/dist/commands/auth.js.map +0 -1
- package/dist/commands/billing.d.ts.map +0 -1
- package/dist/commands/billing.js.map +0 -1
- package/dist/commands/cloudflare.d.ts.map +0 -1
- package/dist/commands/cloudflare.js.map +0 -1
- package/dist/commands/helpers.d.ts.map +0 -1
- package/dist/commands/helpers.js.map +0 -1
- package/dist/commands/index.d.ts.map +0 -1
- package/dist/commands/index.js.map +0 -1
- package/dist/commands/legacy-validation.d.ts.map +0 -1
- package/dist/commands/legacy-validation.js.map +0 -1
- package/dist/commands/one.d.ts.map +0 -1
- package/dist/commands/one.js.map +0 -1
- package/dist/commands/public-tools.d.ts.map +0 -1
- package/dist/commands/public-tools.js.map +0 -1
- package/dist/commands/runs.d.ts.map +0 -1
- package/dist/commands/runs.js.map +0 -1
- package/dist/commands/security.d.ts.map +0 -1
- package/dist/commands/security.js.map +0 -1
- package/dist/commands/watchdog.d.ts.map +0 -1
- package/dist/commands/watchdog.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lib/banner.d.ts.map +0 -1
- package/dist/lib/banner.js.map +0 -1
- package/dist/lib/client.d.ts.map +0 -1
- package/dist/lib/client.js.map +0 -1
- package/dist/lib/cloudflare-client.d.ts.map +0 -1
- package/dist/lib/cloudflare-client.js.map +0 -1
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/format.d.ts.map +0 -1
- package/dist/lib/format.js.map +0 -1
- package/dist/lib/help.d.ts.map +0 -1
- package/dist/lib/help.js.map +0 -1
- package/dist/lib/network.d.ts.map +0 -1
- package/dist/lib/network.js.map +0 -1
- package/dist/lib/one-client.d.ts.map +0 -1
- package/dist/lib/one-client.js.map +0 -1
- package/dist/lib/one-operations.d.ts.map +0 -1
- package/dist/lib/one-operations.js.map +0 -1
- package/dist/lib/open-url.d.ts.map +0 -1
- package/dist/lib/open-url.js.map +0 -1
- package/dist/lib/progress.d.ts.map +0 -1
- package/dist/lib/progress.js.map +0 -1
- package/dist/lib/security-client.d.ts.map +0 -1
- package/dist/lib/security-client.js.map +0 -1
- package/dist/lib/skill-fetch.d.ts.map +0 -1
- package/dist/lib/skill-fetch.js.map +0 -1
- package/dist/lib/system-trust.d.ts.map +0 -1
- package/dist/lib/system-trust.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,1543 @@
|
|
|
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, 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';
|
|
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
|
+
* Before the email is asked for (or `--email` is used): vigilbase.com says
|
|
1164
|
+
* whether local checkups are on and which CLI and snapshot schema it accepts,
|
|
1165
|
+
* so a switched-off local mode (LOCAL_MODE_DISABLED) or an old CLI
|
|
1166
|
+
* (CLI_UPGRADE_REQUIRED) never costs an emailed code. Without a usable answer
|
|
1167
|
+
* the run goes on, and the session check after the email decides as before.
|
|
1168
|
+
*/
|
|
1169
|
+
async function checkAvailability(ctx, origin, schemaVersion) {
|
|
1170
|
+
const client = new VerificationClient(clientOptions(ctx, origin));
|
|
1171
|
+
const availability = await busy(ctx, 'Checking vigilbase.com', () => client.availability());
|
|
1172
|
+
if (availability)
|
|
1173
|
+
assertServerAccepts(availability, CLI_VERSION, schemaVersion);
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* The email verification (prompts on stderr, answers from piped stdin
|
|
1177
|
+
* without a terminal), then `✓ Verified j***@acme.com`. No address is sent
|
|
1178
|
+
* before one is entered, and cf is not touched before it is verified.
|
|
1179
|
+
*/
|
|
1180
|
+
async function verifyRequester(ctx, options, origin) {
|
|
1181
|
+
const verified = await verifyEmail({
|
|
1182
|
+
client: new VerificationClient(clientOptions(ctx, origin)),
|
|
1183
|
+
io: promptIO(ctx),
|
|
1184
|
+
locale: emailLocale(ctx.env),
|
|
1185
|
+
...(options.email !== undefined ? { email: options.email } : {}),
|
|
1186
|
+
say: (line) => say(ctx, line),
|
|
1187
|
+
notice: (line) => ctx.writer.stderr(line),
|
|
1188
|
+
// The terminal UI shows `✓ verified` on the code's line.
|
|
1189
|
+
...(ctx.tui ? { view: ctx.tui.emailView() } : {}),
|
|
1190
|
+
});
|
|
1191
|
+
if (!ctx.tui)
|
|
1192
|
+
say(ctx, `✓ Verified ${verified.requester}`);
|
|
1193
|
+
return verified;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* The checkup the verified email started. A session that expired (a long
|
|
1197
|
+
* collection, or a slow answer to the upload question) is renewed once with
|
|
1198
|
+
* the token and code still in memory; vigilbase.com mints the next session
|
|
1199
|
+
* code for the same checkup. The snapshot file is kept either way.
|
|
1200
|
+
*/
|
|
1201
|
+
class CheckupSession {
|
|
1202
|
+
ctx;
|
|
1203
|
+
origin;
|
|
1204
|
+
verified;
|
|
1205
|
+
constructor(ctx, origin, verified) {
|
|
1206
|
+
this.ctx = ctx;
|
|
1207
|
+
this.origin = origin;
|
|
1208
|
+
this.verified = verified;
|
|
1209
|
+
}
|
|
1210
|
+
client() {
|
|
1211
|
+
return new SubmitClient({ ...clientOptions(this.ctx, this.origin), code: this.verified.sessionCode });
|
|
1212
|
+
}
|
|
1213
|
+
async withRenewal(call) {
|
|
1214
|
+
try {
|
|
1215
|
+
return await call(this.client());
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
if (!(error instanceof LocalCheckupError) || error.code !== 'SESSION_EXPIRED' || !this.verified.renewable)
|
|
1219
|
+
throw error;
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
await this.verified.renew();
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
if (error instanceof LocalCheckupError && (error.code === 'VERIFICATION_EXPIRED' || error.code === 'VERIFICATION_INVALID')) {
|
|
1226
|
+
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 } : {});
|
|
1227
|
+
}
|
|
1228
|
+
throw error;
|
|
1229
|
+
}
|
|
1230
|
+
if (this.ctx.tui)
|
|
1231
|
+
this.ctx.tui.step('Renewed the checkup session, which had expired');
|
|
1232
|
+
else
|
|
1233
|
+
say(this.ctx, '✓ Renewed the checkup session, which had expired');
|
|
1234
|
+
return call(this.client());
|
|
1235
|
+
}
|
|
1236
|
+
getSession() {
|
|
1237
|
+
return this.withRenewal((client) => client.getSession());
|
|
1238
|
+
}
|
|
1239
|
+
postSnapshot(bytes, sha256) {
|
|
1240
|
+
return this.withRenewal((client) => client.postSnapshot(bytes, sha256));
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
/** Failures after the snapshot is saved that a later `submit <file>` can fix without collecting again. */
|
|
1244
|
+
const UPLOAD_LATER_CODES = new Set(['SESSION_INVALID', 'SESSION_EXPIRED', 'SESSION_CLOSED', 'VERIFICATION_EXPIRED', 'VERIFICATION_INVALID',
|
|
1245
|
+
'SUBMIT_FAILED', 'VIGILBASE_RATE_LIMITED', 'VIGILBASE_CHALLENGED', 'LOCAL_MODE_DISABLED']);
|
|
1246
|
+
/**
|
|
1247
|
+
* `submit` for a saved file. A loopback origin is named with `--base-url`, so a
|
|
1248
|
+
* development run never suggests an upload to production; production is the
|
|
1249
|
+
* CLI's default and is not named.
|
|
1250
|
+
*/
|
|
1251
|
+
export function submitCommand(path, origin) {
|
|
1252
|
+
const baseUrl = isLoopbackOrigin(origin) ? ` --base-url ${shellArg(origin)}` : '';
|
|
1253
|
+
return `${NPX_COMMAND} cloudflare-checkup submit ${shellArg(path)}${baseUrl}`;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Right before such an error is reported (stderr, both modes): the `submit`
|
|
1257
|
+
* command for the saved file. A line of its own, because hints are capped at
|
|
1258
|
+
* 300 characters and a path has no fixed length; in the terminal UI, the
|
|
1259
|
+
* command has a line of its own too, never wrapped, so it can be copied.
|
|
1260
|
+
*/
|
|
1261
|
+
function noteSavedSnapshot(ctx, error, path, origin) {
|
|
1262
|
+
if (!(error instanceof LocalCheckupError) || !UPLOAD_LATER_CODES.has(error.code))
|
|
1263
|
+
return;
|
|
1264
|
+
const submit = submitCommand(path, origin);
|
|
1265
|
+
if (ctx.tui)
|
|
1266
|
+
ctx.tui.uploadLater(submit);
|
|
1267
|
+
else
|
|
1268
|
+
ctx.writer.stderr(`The snapshot stays saved. To upload it later without collecting again, run ${submit} (it verifies your email again).`);
|
|
1269
|
+
}
|
|
1270
|
+
/** Session checks that need no upload: CLI version, single use, and the bound account. */
|
|
1271
|
+
function assertSessionUsable(session, accountId) {
|
|
1272
|
+
assertServerAccepts(session, CLI_VERSION, SNAPSHOT_SCHEMA_VERSION);
|
|
1273
|
+
if (session.state === 'completed')
|
|
1274
|
+
throw new LocalCheckupError('SESSION_USED', 'This checkup already has results, so it cannot accept a new snapshot.');
|
|
1275
|
+
// A checkup the email verification just started is never processing; this guards any other state.
|
|
1276
|
+
if (session.state === 'processing')
|
|
1277
|
+
throw new LocalCheckupError('SESSION_BUSY', 'A snapshot is already being processed for this checkup.');
|
|
1278
|
+
if (accountId !== undefined && session.accountId !== null && session.accountId !== accountId) {
|
|
1279
|
+
throw new LocalCheckupError('ACCOUNT_MISMATCH', `This checkup is bound to Cloudflare account ${maskId(session.accountId)}, not ${maskId(accountId)}. Nothing was collected.`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Without a terminal, a missing cf login and a choice between accounts show
|
|
1284
|
+
* only once cf runs, after the email was verified (`assertConfirmationAvailable`
|
|
1285
|
+
* cannot tell beforehand). Their hint then adds that running the command again
|
|
1286
|
+
* with the same address waits for vigilbase.com's 8 minutes per address. A hint
|
|
1287
|
+
* that is not the code's default (the API token one) is left as it is.
|
|
1288
|
+
*/
|
|
1289
|
+
function withSameAddressWait(error) {
|
|
1290
|
+
if (!(error instanceof LocalCheckupError) || (error.code !== 'CF_AUTH_REQUIRED' && error.code !== 'ACCOUNT_SELECTION_REQUIRED'))
|
|
1291
|
+
return error;
|
|
1292
|
+
if (error.hint !== LOCAL_ERROR_HINTS[error.code])
|
|
1293
|
+
return error;
|
|
1294
|
+
return new LocalCheckupError(error.code, error.message, {
|
|
1295
|
+
hint: `${error.hint} ${SAME_ADDRESS_WAIT}.`,
|
|
1296
|
+
...(error.status !== undefined ? { status: error.status } : {}),
|
|
1297
|
+
...(error.retryAfterSec !== undefined ? { retryAfterSec: error.retryAfterSec } : {}),
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
function runFull(ctx, options) {
|
|
1301
|
+
return withTerminalUi(ctx, (local) => runOneCommand(local, options));
|
|
1302
|
+
}
|
|
1303
|
+
async function runOneCommand(ctx, options) {
|
|
1304
|
+
const origin = options.origin ?? PRODUCTION_ORIGIN;
|
|
1305
|
+
if (ctx.tui)
|
|
1306
|
+
ctx.tui.intro();
|
|
1307
|
+
else
|
|
1308
|
+
say(ctx, BANNER);
|
|
1309
|
+
assertLocalRuntimeSupported();
|
|
1310
|
+
// A bad --out never costs an emailed code (collectSnapshot checks it again).
|
|
1311
|
+
if (options.out !== undefined)
|
|
1312
|
+
await assertOutWritable(options.out, Boolean(ctx.values.isOverwrite));
|
|
1313
|
+
// 1-3. Availability, then the email, then the checkup it started are checked before cf is touched.
|
|
1314
|
+
await checkAvailability(ctx, origin, SNAPSHOT_SCHEMA_VERSION);
|
|
1315
|
+
const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
|
|
1316
|
+
const early = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
|
|
1317
|
+
assertSessionUsable(early, options.accountId);
|
|
1318
|
+
// 4-9. cf, sign-in, account, collection.
|
|
1319
|
+
let outcome;
|
|
1320
|
+
try {
|
|
1321
|
+
outcome = await collectSnapshot(ctx, options, early);
|
|
1322
|
+
}
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
throw isInteractive(ctx) ? error : withSameAddressWait(error);
|
|
1325
|
+
}
|
|
1326
|
+
printCollectSummary(ctx, outcome);
|
|
1327
|
+
if (ctx.tui)
|
|
1328
|
+
ctx.tui.cancelMessage = 'Cancelled. Nothing was uploaded; the snapshot stays saved.';
|
|
1329
|
+
// 10. The session, re-checked (and renewed once if it expired) right before uploading.
|
|
1330
|
+
let result;
|
|
1331
|
+
try {
|
|
1332
|
+
const session = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
|
|
1333
|
+
// Upload exactly the bytes on disk, re-validated.
|
|
1334
|
+
const loaded = await loadSnapshotFile(outcome.path);
|
|
1335
|
+
result = await uploadLoaded(ctx, { checkup, session, origin, loaded });
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
noteSavedSnapshot(ctx, error, outcome.path, origin);
|
|
1339
|
+
throw error;
|
|
1340
|
+
}
|
|
1341
|
+
if (ctx.mode === 'json') {
|
|
1342
|
+
outputJson(ctx, { ...result, snapshot: snapshotPayload(outcome), warnings: outcome.warnings });
|
|
1343
|
+
return ok();
|
|
1344
|
+
}
|
|
1345
|
+
sayLogoutHint(ctx, outcome.usedProfile, outcome.managedCf);
|
|
1346
|
+
return ok();
|
|
1347
|
+
}
|
|
1348
|
+
/** logout removes the profile, and only the cf that Vigilbase installed (never the user's own). */
|
|
1349
|
+
function sayLogoutHint(ctx, usedProfile, managedCf) {
|
|
1350
|
+
const logout = `${NPX_COMMAND} cloudflare-checkup logout`;
|
|
1351
|
+
const lead = usedProfile ? `Remove the read-only Cloudflare login${managedCf ? ' and the cf Vigilbase installed' : ''} later with:`
|
|
1352
|
+
: managedCf ? 'Remove the cf that Vigilbase installed later with:' : null;
|
|
1353
|
+
if (lead === null)
|
|
1354
|
+
return;
|
|
1355
|
+
if (ctx.tui)
|
|
1356
|
+
ctx.tui.footnote(lead, logout);
|
|
1357
|
+
else
|
|
1358
|
+
say(ctx, `${lead} ${logout}`);
|
|
1359
|
+
}
|
|
1360
|
+
// ============================================================================
|
|
1361
|
+
// inspect
|
|
1362
|
+
// ============================================================================
|
|
1363
|
+
async function runInspect(ctx, options) {
|
|
1364
|
+
const loaded = await loadSnapshotFile(options.file);
|
|
1365
|
+
const { snapshot } = loaded;
|
|
1366
|
+
const name = accountName(snapshot);
|
|
1367
|
+
const categories = dataCategories(snapshot);
|
|
1368
|
+
const byKind = countEntriesByKind(snapshot.entries);
|
|
1369
|
+
if (ctx.mode === 'json') {
|
|
1370
|
+
outputJson(ctx, {
|
|
1371
|
+
success: true,
|
|
1372
|
+
snapshot: {
|
|
1373
|
+
path: resolve(loaded.path),
|
|
1374
|
+
bytes: loaded.bytes.byteLength,
|
|
1375
|
+
sha256: loaded.sha256,
|
|
1376
|
+
schemaVersion: snapshot.schemaVersion,
|
|
1377
|
+
accountId: snapshot.accountId,
|
|
1378
|
+
accountName: name,
|
|
1379
|
+
zoneCount: zoneCount(snapshot),
|
|
1380
|
+
collectedAt: snapshot.collector.finishedAt,
|
|
1381
|
+
collector: snapshot.collector,
|
|
1382
|
+
},
|
|
1383
|
+
entries: { byKind },
|
|
1384
|
+
dataCategories: categories,
|
|
1385
|
+
});
|
|
1386
|
+
return ok();
|
|
1387
|
+
}
|
|
1388
|
+
const counts = countEntries(snapshot.entries);
|
|
1389
|
+
const { collector } = snapshot;
|
|
1390
|
+
const lines = [
|
|
1391
|
+
`Snapshot ${options.file} ${formatBytes(loaded.bytes.byteLength)} sha256 ${loaded.sha256}`,
|
|
1392
|
+
`Schema ${snapshot.schemaVersion} (valid)`,
|
|
1393
|
+
`Account ${snapshot.accountId}${name !== null ? ` ${name} (as reported)` : ''}`,
|
|
1394
|
+
`Zones ${zoneCount(snapshot)}`,
|
|
1395
|
+
`Collected ${collector.finishedAt} by ${collector.name} ${collector.version} with cf ${collector.cf.version} (${collector.authSource}, ${collector.platform}, ${collector.cfInvocations} cf calls)`,
|
|
1396
|
+
`Reads ${counts.total} · ${counts.ok} ok · ${counts.httpError} HTTP error · ${counts.notCollected} not collectable with cf · ${counts.collectionFailed} collection failed`,
|
|
1397
|
+
...Object.entries(byKind).map(([kind, kindCounts]) => {
|
|
1398
|
+
const notOk = kindCounts.total - kindCounts.ok;
|
|
1399
|
+
return ` ${kind.padEnd(34)} ${String(kindCounts.total).padStart(5)}${notOk ? ` (${notOk} not ok)` : ''}`;
|
|
1400
|
+
}),
|
|
1401
|
+
'Free text and identifiers in this file:',
|
|
1402
|
+
` member emails ${categories.memberEmails} · access-rule values ${categories.accessRuleValues} · non-proxied DNS names ${categories.dnsHostnames} · pseudonymised IPs ${categories.pseudonymizedIps} · rule descriptions ${categories.ruleDescriptions}`,
|
|
1403
|
+
'No credentials are included, and origin IPs are pseudonymised.',
|
|
1404
|
+
];
|
|
1405
|
+
for (const line of lines)
|
|
1406
|
+
ctx.writer.stdout(line);
|
|
1407
|
+
return ok();
|
|
1408
|
+
}
|
|
1409
|
+
// ============================================================================
|
|
1410
|
+
// submit (and the upload step of the one command)
|
|
1411
|
+
// ============================================================================
|
|
1412
|
+
function runSubmit(ctx, options) {
|
|
1413
|
+
return withTerminalUi(ctx, (local) => submitFile(local, options));
|
|
1414
|
+
}
|
|
1415
|
+
async function submitFile(ctx, options) {
|
|
1416
|
+
const origin = options.origin ?? PRODUCTION_ORIGIN;
|
|
1417
|
+
const loaded = await loadSnapshotFile(options.file);
|
|
1418
|
+
assertFresh(loaded.snapshot);
|
|
1419
|
+
const zones = zoneCount(loaded.snapshot);
|
|
1420
|
+
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)}`;
|
|
1421
|
+
if (ctx.tui) {
|
|
1422
|
+
ctx.tui.intro();
|
|
1423
|
+
const { ellipsis } = ctx.tui;
|
|
1424
|
+
ctx.tui.step('Snapshot', ctx.tui.join([
|
|
1425
|
+
displayText(displayPath(resolve(options.file), ctx.env), 120, ellipsis),
|
|
1426
|
+
formatBytes(loaded.bytes.byteLength),
|
|
1427
|
+
`account ${maskId(loaded.snapshot.accountId, ellipsis)}`,
|
|
1428
|
+
`${zones} zone${zones === 1 ? '' : 's'}`,
|
|
1429
|
+
`collected ${formatClock(loaded.snapshot.collector.finishedAt)}`,
|
|
1430
|
+
]));
|
|
1431
|
+
}
|
|
1432
|
+
else if (ctx.mode === 'json')
|
|
1433
|
+
ctx.writer.stderr(head);
|
|
1434
|
+
else
|
|
1435
|
+
ctx.writer.stdout(head);
|
|
1436
|
+
// The file, then vigilbase.com's availability, are checked before the email is verified, so neither costs a code.
|
|
1437
|
+
await checkAvailability(ctx, origin, loaded.snapshot.schemaVersion);
|
|
1438
|
+
const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
|
|
1439
|
+
const session = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
|
|
1440
|
+
const result = await uploadLoaded(ctx, { checkup, session, origin, loaded });
|
|
1441
|
+
if (ctx.mode === 'json')
|
|
1442
|
+
outputJson(ctx, result);
|
|
1443
|
+
return ok();
|
|
1444
|
+
}
|
|
1445
|
+
/** Refused locally: vigilbase.com accepts snapshots finished in the last 24 h (5 min clock skew). */
|
|
1446
|
+
function assertFresh(snapshot, now = Date.now()) {
|
|
1447
|
+
const finished = Date.parse(snapshot.collector.finishedAt);
|
|
1448
|
+
if (!(finished >= now - SNAPSHOT_LIMITS.maxAgeHours * 3_600_000) || finished > now + SNAPSHOT_LIMITS.maxFutureSkewSeconds * 1_000) {
|
|
1449
|
+
throw new LocalCheckupError('SNAPSHOT_STALE', 'The snapshot was collected more than 24 hours ago (or is dated in the future).');
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
function numberAt(value, ...path) {
|
|
1453
|
+
let current = value;
|
|
1454
|
+
for (const key of path) {
|
|
1455
|
+
if (!current || typeof current !== 'object')
|
|
1456
|
+
return undefined;
|
|
1457
|
+
current = current[key];
|
|
1458
|
+
}
|
|
1459
|
+
return typeof current === 'number' && Number.isFinite(current) ? current : undefined;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Opens the results page in the browser, unless `--no-open`, `--json` or no
|
|
1463
|
+
* browser is available, and only when it is on the upload origin; true when
|
|
1464
|
+
* a browser was started. The link is printed either way.
|
|
1465
|
+
*/
|
|
1466
|
+
function openResults(ctx, url, origin) {
|
|
1467
|
+
if (ctx.mode === 'json' || ctx.values.isNoOpen || isHeadlessEnvironment(ctx.env))
|
|
1468
|
+
return false;
|
|
1469
|
+
if (new URL(url).origin !== origin)
|
|
1470
|
+
return false;
|
|
1471
|
+
return openUrlInBrowser(url, ctx.env).isOpened;
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Checks against the session (CLI version, bound account, limits), the
|
|
1475
|
+
* confirmation naming the masked verified address, then `POST` of exactly the
|
|
1476
|
+
* loaded bytes, and the results link.
|
|
1477
|
+
*/
|
|
1478
|
+
async function uploadLoaded(ctx, input) {
|
|
1479
|
+
const { checkup, session, origin, loaded } = input;
|
|
1480
|
+
const { snapshot } = loaded;
|
|
1481
|
+
assertFresh(snapshot);
|
|
1482
|
+
assertServerAccepts(session, CLI_VERSION, snapshot.schemaVersion);
|
|
1483
|
+
if (session.accountId !== null && session.accountId !== snapshot.accountId) {
|
|
1484
|
+
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.`);
|
|
1485
|
+
}
|
|
1486
|
+
const zones = zoneCount(snapshot);
|
|
1487
|
+
if (loaded.bytes.byteLength > session.accepts.maxBytes || zones > session.accepts.maxZones) {
|
|
1488
|
+
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.`);
|
|
1489
|
+
}
|
|
1490
|
+
const requester = displayRequester(session.requester);
|
|
1491
|
+
const question = `Results go to ${requester}. Upload ${formatBytes(loaded.bytes.byteLength)} to ${origin}?`;
|
|
1492
|
+
const flag = ctx.values.isYes ? '--yes' : '--confirm';
|
|
1493
|
+
const isConfirmed = Boolean(ctx.values.isYes || ctx.values.isConfirm);
|
|
1494
|
+
const bytes = loaded.bytes.byteLength;
|
|
1495
|
+
const confirmed = ctx.tui
|
|
1496
|
+
? await ctx.tui.confirmUpload({ bytes, where: origin === PRODUCTION_ORIGIN ? new URL(origin).host : origin, requester, flag: isConfirmed ? flag : null })
|
|
1497
|
+
: await confirmUpload(promptIO(ctx), question, { isConfirmed, flag });
|
|
1498
|
+
if (!confirmed)
|
|
1499
|
+
throw new Error('Upload cancelled; nothing was sent. The snapshot file is kept.');
|
|
1500
|
+
const post = () => checkup.postSnapshot(loaded.bytes, loaded.sha256);
|
|
1501
|
+
const result = ctx.tui ? await ctx.tui.uploading(bytes, post) : await post();
|
|
1502
|
+
if (ctx.mode !== 'json') {
|
|
1503
|
+
const passed = numberAt(result.report, 'summary', 'passed');
|
|
1504
|
+
const warnings = numberAt(result.report, 'summary', 'warnings');
|
|
1505
|
+
const failedChecks = numberAt(result.report, 'summary', 'failed');
|
|
1506
|
+
const notCollected = (numberAt(result.provenance, 'coverage', 'reads', 'notCollected') ?? 0) +
|
|
1507
|
+
(numberAt(result.provenance, 'coverage', 'reads', 'collectionFailed') ?? 0);
|
|
1508
|
+
const parts = [
|
|
1509
|
+
passed !== undefined ? `${passed} passed` : null,
|
|
1510
|
+
warnings !== undefined ? `${warnings} warning${warnings === 1 ? '' : 's'}` : null,
|
|
1511
|
+
failedChecks !== undefined ? `${failedChecks} failed` : null,
|
|
1512
|
+
`${notCollected} reads not collected locally`,
|
|
1513
|
+
].filter((part) => part !== null);
|
|
1514
|
+
const summary = `${result.replayed ? '✓ Already uploaded (same file); nothing changed.' : '✓ Uploaded.'} Free summary: ${parts.join(' · ')}`;
|
|
1515
|
+
const emailed = result.resultsUrl !== undefined ? `A link has also been emailed to ${requester}.` : `A link to the results has been emailed to ${requester}.`;
|
|
1516
|
+
if (ctx.tui) {
|
|
1517
|
+
const opening = result.resultsUrl !== undefined && openResults(ctx, result.resultsUrl, origin);
|
|
1518
|
+
ctx.tui.uploaded({
|
|
1519
|
+
replayed: result.replayed, notCollected, requester, opening,
|
|
1520
|
+
...(passed !== undefined ? { passed } : {}),
|
|
1521
|
+
...(warnings !== undefined ? { warnings } : {}),
|
|
1522
|
+
...(failedChecks !== undefined ? { failed: failedChecks } : {}),
|
|
1523
|
+
...(result.resultsUrl !== undefined ? { resultsUrl: result.resultsUrl } : {}),
|
|
1524
|
+
}, [summary, ...(result.resultsUrl !== undefined ? [`Results: ${result.resultsUrl}`] : []), emailed]);
|
|
1525
|
+
}
|
|
1526
|
+
else {
|
|
1527
|
+
ctx.writer.stdout(summary);
|
|
1528
|
+
if (result.resultsUrl !== undefined) {
|
|
1529
|
+
ctx.writer.stdout(`Results: ${result.resultsUrl}`);
|
|
1530
|
+
openResults(ctx, result.resultsUrl, origin);
|
|
1531
|
+
}
|
|
1532
|
+
ctx.writer.stdout(emailed);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return {
|
|
1536
|
+
success: true,
|
|
1537
|
+
replayed: result.replayed,
|
|
1538
|
+
snapshotSha256: result.snapshotSha256,
|
|
1539
|
+
provenance: result.provenance,
|
|
1540
|
+
report: result.report,
|
|
1541
|
+
...(result.resultsUrl !== undefined ? { resultsUrl: result.resultsUrl } : {}),
|
|
1542
|
+
};
|
|
1543
|
+
}
|