UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

1,545 lines • 82 kB
/**
 * `vigilbase cloudflare-checkup [options]`
 * ≡ `vigilbase local cloudflare-checkup [run] [options]`, plus the advanced
 * `login|doctor|accounts|collect|inspect|submit|logout` subcommands.
 *
 * The one command verifies the user's work email (vigilbase.com emails a
 * 6-digit code), finds (or, after asking, installs) Cloudflare's cf CLI, signs
 * in to Cloudflare with a read-only cf login, lets the user choose the
 * account, collects a minimised snapshot with a fixed allowlist of read-only
 * cf commands, shows what it contains, uploads exactly that file to
 * vigilbase.com after an explicit confirmation and opens the results page.
 * cf owns the Cloudflare login: this process never reads, prints, stores or
 * uploads a Cloudflare credential. See LOCAL.md.
 */
import { access, constants, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
import { z } from 'zod';
import { CLI_VERSION } from '../version.js';
import { resolveConfigDir } from '../lib/config.js';
import { isHeadlessEnvironment, openUrlInBrowser } from '../lib/open-url.js';
import { shouldShowProgress } from '../lib/progress.js';
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';
import { ENV_TOKEN_AUTH_HINT, LOCAL_ERROR_HINTS, LocalCheckupError } from '../local/cloudflare-checkup/errors.js';
import { CF_TOKEN_ENV, buildChildEnv, childEnvVariableNames, hasEnvToken, missingEnvTokenMessage, strippedTokenNotice, } from '../local/cloudflare-checkup/cf/child-env.js';
import { assertLocalRuntimeSupported, checkCfVersion, isNodeSupported, locateCf } from '../local/cloudflare-checkup/cf/locate.js';
import { CfInstallError, MANAGED_CF, ensurePrivateDir, findManagedCf, installManagedCf, managedCfRoot, removeManagedCf } from '../local/cloudflare-checkup/cf/install.js';
import { withCfRunner } from '../local/cloudflare-checkup/cf/run-cf.js';
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';
import { CfScheduler, authSourceFor, doctorAuth, isReadOnlyScope } from '../local/cloudflare-checkup/cf/auth-lifecycle.js';
import { RequestBudget, estimateBudgetSeconds, parseRequestBudget, systemClock } from '../local/cloudflare-checkup/rate-budget.js';
import { buildSnapshot, collectAccount, estimateRequests, listAccounts } from '../local/cloudflare-checkup/collector.js';
import { accountName, countEntries, countEntriesByKind, dataCategories, loadSnapshotFile, writeSnapshotFile, zoneCount, } from '../local/cloudflare-checkup/snapshot-file.js';
import { confirmDefaultYes, confirmUpload, hasInteractiveInput, promptLine } from '../local/cloudflare-checkup/prompts.js';
import { SAME_ADDRESS_WAIT, emailLocale, parseEmailFlag, verifyEmail } from '../local/cloudflare-checkup/email-verification.js';
import { SubmitClient, VerificationClient, assertServerAccepts, displayRequester, isLoopbackOrigin, resolveSubmitOrigin, } from '../local/cloudflare-checkup/submit-client.js';
import { displayText, formatBytes, formatClock, formatDuration, maskId, readsSummary, shortSha, startCountsProgress } from '../local/cloudflare-checkup/output.js';
import { CheckupTui } from '../local/cloudflare-checkup/terminal-ui.js';
import { EXIT, assertCommandFlags, ok, outputJson } from './helpers.js';
/**
 * How the page, the docs and the hints spell the command: the bare package,
 * no version. vigilbase.com refuses CLIs older than it accepts, and that
 * error's hint (CLI_UPGRADE_REQUIRED) names `@latest` to get past a cached copy.
 */
export const NPX_COMMAND = 'npx @vigilbase/cli';
const BANNER = 'Vigilbase Cloudflare Checkup · collects on this machine with Cloudflare\'s cf CLI';
const SUBCOMMANDS = ['run', 'login', 'doctor', 'accounts', 'collect', 'inspect', 'submit', 'logout'];
const usage = `Usage: vigilbase cloudflare-checkup [${SUBCOMMANDS.join('|')}] [options]`;
export const LOCAL_HELP = `Usage: vigilbase cloudflare-checkup [options]
       vigilbase local cloudflare-checkup [<command>] [options]

Runs the Vigilbase Cloudflare Checkup on this machine, in one command:

  ${NPX_COMMAND} cloudflare-checkup

It asks for your work email and emails you a 6-digit code to enter, installs
Cloudflare's cf CLI if it is missing (asks first), signs in to Cloudflare with
a read-only cf login, lets you choose the account, collects a snapshot, shows
what it contains, uploads it only after you confirm and opens the results.
cf owns your Cloudflare login; this CLI never reads, prints, stores or
uploads it.

Options (the default command, also called run):
  --email <address>              Your work email (asked for when omitted)
  --account-id <id>              Skip the account picker
  --out <file>                   Snapshot path (default: <config dir>/checkups/…);
                                 --overwrite replaces an existing file
  --yes                          Install cf and upload without asking
                                 (--confirm answers only the upload question)
  --no-open                      Print the results link; do not open a browser
  --cf-auth auto|profile|env|default
                                 auto (default): env when ${CF_TOKEN_ENV} is set
                                 in this shell (cf reads it itself), else profile
  --profile <name>               cf profile (default ${DEFAULT_PROFILE})
  --cf-bin <path>                Use this cf package; never install one
  --max-requests-per-5min <n>    Cloudflare request budget, 100..1100 (default 900)
  --timeout <milliseconds> · --json

Without a terminal, add --yes: the email (unless --email is given) and the
code are read from stdin, one line each, after their prompts on stderr.

Advanced commands:
  login                          Create or refresh the read-only cf profile
                                 [--profile] [--no-open] [--dry-run] [--cf-bin]
  doctor                         Check Node, cf, the offline command mapping and cf auth
  accounts                       List the Cloudflare account IDs cf can read
  collect --out <file>           Collect only; --dry-run shows every cf command (offline)
  inspect <file>                 Validate a snapshot and show what it contains
  submit <file>                  Verify your email, then upload a collected snapshot
                                 [--email] [--yes] [--no-open]
  logout                         Delete the read-only cf profile and the cf this CLI
                                 installed [--profile] [--cf-bin]

Results go to the verified email address. Uploads go only to
${PRODUCTION_ORIGIN} (or a loopback --base-url).
Exit codes: 0 success, 1 error, 2 rate limited. See LOCAL.md for details.`;
const CF_FLAGS = ['profile', 'cf-auth', 'cf-bin'];
const COLLECT_FLAGS = [...CF_FLAGS, 'account-id', 'out', 'isOverwrite', 'max-requests-per-5min'];
const SUBMIT_FLAGS = ['email', 'isNoOpen', 'isConfirm', 'isYes', 'base-url', 'timeout'];
const ALLOWED_FLAGS = {
    run: [...COLLECT_FLAGS, ...SUBMIT_FLAGS, 'isJson'],
    login: ['profile', 'cf-bin', 'isDryRun', 'isNoOpen', 'isJson'],
    doctor: [...CF_FLAGS, 'isJson'],
    accounts: [...CF_FLAGS, 'isJson'],
    collect: [...COLLECT_FLAGS, 'isDryRun', 'isJson'],
    inspect: ['isJson'],
    submit: [...SUBMIT_FLAGS, 'isJson'],
    logout: ['profile', 'cf-bin', 'isJson'],
};
/** Validate flags before any spawn, file or network access. */
function assertLocalFlags(ctx, subcommand) {
    const allowed = ALLOWED_FLAGS[subcommand];
    assertCommandFlags(ctx, allowed);
    // assertCommandFlags always permits the global origin flags; local commands do not.
    for (const key of ['base-url', 'one-url', 'auth-url', 'client-id', 'timeout']) {
        if (ctx.values[key] !== undefined && !allowed.includes(key)) {
            throw new Error(`--${key} is not supported for cloudflare-checkup ${subcommand}.`);
        }
    }
}
const accountIdSchema = z.string()
    .regex(/^[0-9a-fA-F]{32}$/, '--account-id must contain 32 hexadecimal characters.')
    .transform((id) => id.toLowerCase());
const pathSchema = (flag) => z.string()
    .min(1, `${flag} must not be empty.`)
    .max(4096, `${flag} is too long.`)
    .refine((value) => !value.includes('\0'), `${flag} must not contain NUL characters.`);
function parse(schema, value) {
    const result = schema.safeParse(value);
    if (!result.success)
        throw new Error(result.error.issues.map((issue) => issue.message).join(' '));
    return result.data;
}
function parseLocalOptions(ctx, subcommand, args) {
    const takesFile = subcommand === 'inspect' || subcommand === 'submit';
    if (takesFile ? args.length !== 1 : args.length !== 0) {
        const fileUsage = `Usage: vigilbase cloudflare-checkup ${subcommand} <file>`;
        throw new Error(!takesFile ? usage : args.length === 0 ? `Missing <file>. ${fileUsage}` : fileUsage);
    }
    const values = ctx.values;
    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'));
    if (values.profile !== undefined && requested !== 'profile' && requested !== 'auto')
        throw new Error('--profile applies only to --cf-auth profile.');
    // auto: an explicit --profile wins; otherwise env when the token variable is set (presence only).
    const authMode = requested !== 'auto' ? requested : values.profile !== undefined ? 'profile' : hasEnvToken(ctx.env) ? 'env' : 'profile';
    const profile = validateProfileName(values.profile ?? DEFAULT_PROFILE);
    const cfBin = values['cf-bin'] === undefined ? undefined : parse(pathSchema('--cf-bin'), values['cf-bin']);
    if (cfBin !== undefined && !isAbsolute(cfBin))
        throw new Error('--cf-bin must be an absolute path.');
    const out = values.out === undefined ? undefined : parse(pathSchema('--out'), values.out);
    if (subcommand === 'collect' && values.isDryRun) {
        if (out !== undefined || values['account-id'] !== undefined || values.isOverwrite) {
            throw new Error('collect --dry-run makes no Cloudflare calls and writes no file; omit --out, --overwrite and --account-id.');
        }
    }
    else if (subcommand === 'collect' && out === undefined) {
        throw new Error('--out <file> is required for cloudflare-checkup collect.');
    }
    if (values.isOverwrite && out === undefined)
        throw new Error('--overwrite applies only with --out.');
    // The address is never echoed, even when it is malformed.
    const email = values.email === undefined ? undefined : parseEmailFlag(values.email);
    const options = {
        subcommand,
        ...(takesFile ? { file: parse(pathSchema('<file>'), args[0]) } : {}),
        auth: { authMode, profile },
        authAuto: requested === 'auto',
        ...(cfBin !== undefined ? { cfBin } : {}),
        ...(values['account-id'] !== undefined ? { accountId: parse(accountIdSchema, values['account-id']) } : {}),
        ...(out !== undefined ? { out: resolve(out) } : {}),
        maxRequests: parseRequestBudget(values['max-requests-per-5min']),
        // Environment variables never choose the submit origin.
        ...(subcommand === 'submit' || subcommand === 'run' ? { origin: resolveSubmitOrigin(values['base-url']) } : {}),
        ...(email !== undefined ? { email } : {}),
        configDir: localConfigDir(ctx, subcommand === 'run' || subcommand === 'logout'),
    };
    if (subcommand === 'submit' || subcommand === 'run')
        assertConfirmationAvailable(ctx, subcommand);
    return options;
}
/**
 * The config directory (VIGILBASE_CONFIG_DIR, XDG_CONFIG_HOME/vigilbase or
 * ~/.config/vigilbase). `run` and `logout` need it and fail on a malformed
 * value; the advanced commands only look for a managed cf there, so for them
 * a malformed value just means "no managed cf". `inspect` never reads it.
 */
function localConfigDir(ctx, required) {
    try {
        return resolveConfigDir(undefined, ctx.env);
    }
    catch (error) {
        if (required)
            throw error;
        return null;
    }
}
function isInteractive(ctx) {
    return hasInteractiveInput(ctx.stdin ?? process.stdin);
}
/**
 * Without a terminal, `submit` needs `--yes` or `--confirm` for the upload
 * question, and the one command needs `--yes`: `--confirm` cannot answer its
 * question about installing cf. Checked before anything is sent, so no code is
 * emailed for a run that could not install cf or confirm the upload. The email
 * and the emailed code themselves may come from piped stdin. (A missing cf
 * login, or several accounts without `--account-id`, still show only after the
 * email is verified: finding them needs cf.)
 */
function assertConfirmationAvailable(ctx, subcommand) {
    if (isInteractive(ctx) || ctx.values.isYes)
        return;
    if (subcommand === 'run') {
        throw new LocalCheckupError('CONFIRMATION_REQUIRED', ctx.values.isConfirm
            ? 'No terminal is available, and --confirm answers only the upload question, not installing cf. Add --yes.'
            : 'No terminal is available to confirm installing cf or the upload. Add --yes.');
    }
    if (!ctx.values.isConfirm)
        throw new LocalCheckupError('CONFIRMATION_REQUIRED', 'No terminal is available to confirm the upload.');
}
/** `vigilbase local cloudflare-checkup [<command>] …` */
export const handleLocal = async (ctx) => {
    const [group, subcommandRaw, ...args] = ctx.rest;
    if (group !== 'cloudflare-checkup')
        throw new Error(usage);
    const subcommand = (subcommandRaw ?? 'run').toLowerCase();
    if (!SUBCOMMANDS.includes(subcommand))
        throw new Error(usage);
    assertLocalFlags(ctx, subcommand);
    const options = parseLocalOptions(ctx, subcommand, args);
    switch (subcommand) {
        case 'run': return runFull(ctx, options);
        case 'login': return runLogin(ctx, options);
        case 'doctor': return runDoctor(ctx, options);
        case 'accounts': return runAccounts(ctx, options);
        case 'collect': return ctx.values.isDryRun ? runCollectDryRun(ctx, options) : runCollect(ctx, options);
        case 'inspect': return runInspect(ctx, options);
        case 'submit': return runSubmit(ctx, options);
        case 'logout': return runLogout(ctx, options);
    }
};
/** `vigilbase cloudflare-checkup …`: the top-level alias. */
export const handleCloudflareCheckup = (ctx) => handleLocal({ ...ctx, rest: ['cloudflare-checkup', ...ctx.rest] });
function stderrStream(ctx) {
    return ctx.stderrStream ?? process.stderr;
}
/**
 * run and submit on an interactive terminal (stdin and stderr are TTYs, TERM
 * is not dumb, CI is not set, no --json): the steps render as a timeline on
 * stderr (terminal-ui.ts), every other line goes through it too, and an error
 * that ends the command is shown there (■) instead of by runCli. Anywhere
 * else the plain lines and prompts are unchanged.
 */
async function withTerminalUi(ctx, work) {
    const tui = CheckupTui.open({
        mode: ctx.mode,
        stdin: ctx.stdin ?? process.stdin,
        stderr: stderrStream(ctx),
        stdout: ctx.stdoutStream ?? process.stdout,
        env: ctx.env,
        writer: ctx.writer,
    });
    if (!tui)
        return work(ctx);
    try {
        return await work({ ...ctx, writer: tui.lines, tui });
    }
    catch (error) {
        tui.fail(error);
        throw error;
    }
    finally {
        tui.close();
    }
}
/** `work` with a spinner (`label…`) in the terminal UI; plain output shows nothing for it. */
function busy(ctx, label, work) {
    return ctx.tui ? ctx.tui.busy(label, work) : work();
}
function promptIO(ctx) {
    return { stdin: ctx.stdin ?? process.stdin, stderr: stderrStream(ctx) };
}
/** Status lines of the advanced commands: stderr, text mode only. */
function info(ctx, line) {
    if (ctx.mode !== 'json')
        ctx.writer.stderr(line);
}
/** Status lines of the one command: stdout in text mode, stderr with --json (stdout stays one JSON object). */
function say(ctx, line) {
    if (ctx.mode === 'json')
        ctx.writer.stderr(line);
    else
        ctx.writer.stdout(line);
}
function warn(ctx, warnings, warning) {
    warnings.push(warning);
    if (ctx.mode !== 'json')
        ctx.writer.stderr(`Warning: ${warning.message}`);
}
/** Env-mode presence check and the profile-mode notice; the value is never read. */
function checkAuthEnvironment(ctx, options, warnings) {
    if (options.auth.authMode === 'env') {
        if (!hasEnvToken(ctx.env))
            throw new LocalCheckupError('CF_AUTH_REQUIRED', missingEnvTokenMessage(), { hint: ENV_TOKEN_AUTH_HINT });
    }
    else if (hasEnvToken(ctx.env)) {
        warn(ctx, warnings, { code: 'ENV_TOKEN_NOT_USED', message: strippedTokenNotice(options.auth.authMode) });
    }
}
async function managedDirFor(options) {
    if (options.cfBin !== undefined || options.configDir === null)
        return null;
    return findManagedCf(options.configDir);
}
async function locateAndWarn(ctx, options, warnings, managedDir) {
    const managed = managedDir === undefined ? await managedDirFor(options) : managedDir;
    const location = await locateCf({
        ...(options.cfBin !== undefined ? { cfBin: options.cfBin } : {}),
        ...(managed ? { managedDir: managed } : {}),
        env: ctx.env,
    });
    if (location.insideCwd) {
        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.' });
    }
    return location;
}
function offlineEnv(ctx, authMode) {
    return buildChildEnv(ctx.env, { purpose: 'offline', authMode, accountId: PREFLIGHT_ACCOUNT_ID });
}
async function checkVersion(ctx, runner, location, authMode, warnings) {
    const result = await runner.run(versionArgv(), { env: offlineEnv(ctx, authMode) });
    if (result.exitCode !== 0) {
        if (/cf requires Node\.js/.test(result.stderr))
            throw new LocalCheckupError('NODE_UNSUPPORTED', 'cf refused to run under this Node.js version.');
        throw new LocalCheckupError('CF_WRONG_BINARY', 'cf --version failed; the package may be damaged. Reinstall cf.');
    }
    const prepared = checkCfVersion(result.stdout, location.packageVersion);
    if (!prepared.tested) {
        warn(ctx, warnings, {
            code: 'CF_UNTESTED_VERSION',
            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.`,
        });
    }
    return prepared;
}
/** Run every template with `--dry-run`, eight at a time; no auth, no network. */
async function runPreflight(ctx, runner, auth, only) {
    const checks = preflightChecks(auth, only);
    const env = offlineEnv(ctx, auth.authMode);
    const mismatched = [];
    let verified = 0;
    let next = 0;
    const worker = async () => {
        while (next < checks.length) {
            const check = checks[next++];
            const result = await runner.run(check.argv, { env });
            if (result.exitCode === 0 && !result.timedOut && dryRunMatches(result.stdout, check))
                verified++;
            else
                mismatched.push(check.settingId ? `zone_setting:${check.settingId}` : check.id);
        }
    };
    await Promise.all(Array.from({ length: Math.min(8, checks.length) }, () => worker()));
    mismatched.sort();
    return { verified, total: checks.length, mismatched };
}
function assertCriticalTemplates(preflight, critical = CRITICAL_TEMPLATES) {
    const broken = critical.filter((id) => preflight.mismatched.includes(id));
    if (broken.length > 0) {
        throw new LocalCheckupError('CF_ALLOWLIST_MISMATCH', `This cf release does not map the ${broken.join(', ')} command${broken.length === 1 ? '' : 's'} the checkup needs.`);
    }
}
function createScheduler(ctx, runner, auth, maxRequests) {
    return new CfScheduler({
        invoke: (argv, callOptions) => runner.run(argv, {
            env: buildChildEnv(ctx.env, { purpose: 'api', authMode: auth.authMode, ...(callOptions.accountId ? { accountId: callOptions.accountId } : {}) }),
            ...(callOptions.stdoutCapBytes ? { stdoutCapBytes: callOptions.stdoutCapBytes } : {}),
        }),
        whoami: () => runner.run(whoamiArgv(auth), { env: buildChildEnv(ctx.env, { purpose: 'api', authMode: auth.authMode }) }),
        budget: new RequestBudget(maxRequests, systemClock),
        clock: systemClock,
        authMode: auth.authMode,
        terminateAll: () => runner.terminateAll(),
    });
}
function profileLine(auth, whoami) {
    const label = auth.authMode === 'profile' ? `cf profile "${auth.profile}"` : auth.authMode === 'env' ? 'cf with the API token from this shell' : 'cf default profile';
    if (auth.authMode === 'env' || !whoami.scopes)
        return label;
    const writes = whoami.scopes.filter((scope) => !isReadOnlyScope(scope)).length;
    const access = writes === 0 ? 'read-only' : `${writes} write scope${writes === 1 ? '' : 's'}`;
    return `${label} · ${access}${whoami.expiresAt ? ` · expires ${formatClock(whoami.expiresAt)}` : ''}`;
}
/** The terminal UI's sign-in line: `read-only profile "vigilbase-checkup"`. */
function signInSummary(auth, whoami, tui) {
    if (auth.authMode === 'env')
        return 'API token from this shell';
    const writes = whoami.scopes?.filter((scope) => !isReadOnlyScope(scope)).length;
    const profile = auth.authMode === 'profile' ? `profile "${auth.profile}"` : 'default cf profile';
    if (writes === undefined)
        return profile;
    return writes === 0 ? `read-only ${profile}` : tui.join([profile, `${writes} write scope${writes === 1 ? '' : 's'}`]);
}
const MAX_PICKER_ACCOUNTS = 20;
const MAX_PROMPT_ATTEMPTS = 3;
/**
 * Account choice without `--account-id` or a bound account: one account is
 * used as is; several get a numbered picker (name and masked ID, up to 20) on
 * a terminal, or the arrow-key picker of the terminal UI; without a terminal,
 * ACCOUNT_SELECTION_REQUIRED lists the full IDs.
 */
async function chooseAccount(ctx, env, log) {
    const accounts = await busy(ctx, 'Listing the Cloudflare accounts cf can read', () => listAccounts(env));
    if (accounts.length === 0) {
        throw new LocalCheckupError('ACCOUNT_NOT_VISIBLE', 'cf can read no Cloudflare accounts with this login.');
    }
    // The terminal UI's ellipsis is `...` on an ASCII-only console.
    const ellipsis = ctx.tui?.ellipsis;
    const label = (account) => displayText(account.name, 60, ellipsis) || '(no name)';
    if (accounts.length === 1) {
        if (ctx.tui)
            ctx.tui.account(label(accounts[0]), maskId(accounts[0].id, ellipsis));
        else
            log(`cf can read 1 Cloudflare account: ${label(accounts[0])}  ${maskId(accounts[0].id)}`);
        return accounts[0].id;
    }
    // The terminal UI scrolls through every account.
    if (ctx.tui) {
        const id = await ctx.tui.chooseAccount(accounts.map((account) => ({ id: account.id, label: label(account), detail: maskId(account.id, ellipsis) })));
        if (id === null)
            throw new LocalCheckupError('ACCOUNT_SELECTION_REQUIRED', 'No account was chosen.');
        return id;
    }
    const shown = accounts.slice(0, MAX_PICKER_ACCOUNTS);
    const more = accounts.length > shown.length ? accounts.length - shown.length : 0;
    if (!isInteractive(ctx)) {
        const listing = shown.map((account, index) => `  ${index + 1}) ${account.id}  ${displayText(account.name, 80)}`);
        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` : ''}`);
    }
    const width = Math.max(...shown.map((account) => label(account).length));
    ctx.writer.stderr(`cf can read ${accounts.length} Cloudflare accounts:`);
    shown.forEach((account, index) => ctx.writer.stderr(`  ${String(index + 1).padStart(String(shown.length).length)}) ${label(account).padEnd(width)}  ${maskId(account.id)}`));
    if (more)
        ctx.writer.stderr(`  … and ${more} more; pass --account-id <ACCOUNT_ID> for one of those.`);
    for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt++) {
        const answer = await promptLine(promptIO(ctx), `Account number (1-${shown.length}): `);
        if (answer === null)
            throw new LocalCheckupError('ACCOUNT_SELECTION_REQUIRED', 'No account was chosen, and the input ended.');
        const choice = Number(answer);
        if (Number.isInteger(choice) && choice >= 1 && choice <= shown.length)
            return shown[choice - 1].id;
    }
    throw new LocalCheckupError('ACCOUNT_SELECTION_REQUIRED', 'No account was chosen.');
}
/**
 * A path as one shell word, for hints the user may copy. POSIX shells get
 * single quotes (nothing inside is expanded), with a leading `~/` left outside
 * them so the shell still expands it.
 */
export function shellArg(value, platform = process.platform) {
    // Windows paths cannot contain `"`, and cmd and PowerShell keep backslashes inside double quotes.
    if (platform === 'win32')
        return /^[A-Za-z0-9_@+=:,.\\~-]+$/.test(value) ? value : `"${value}"`;
    if (/^[A-Za-z0-9_@%+=:,./~-]+$/.test(value))
        return value;
    const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`;
    return value.startsWith('~/') ? `~/${quote(value.slice(2))}` : quote(value);
}
/**
 * `~/…` for paths under the home directory. Not on Windows: cmd and Windows
 * PowerShell do not expand `~` for native commands, and the printed path is
 * what users pass to `submit`.
 */
export function displayPath(path, env = process.env, platform = process.platform) {
    if (platform === 'win32')
        return path;
    const home = env.HOME || homedir();
    return home && path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
}
/**
 * `cloudflare-checkup-<first 8 of the account ID>-<UTC yyyymmddThhmmssZ>.json`;
 * `-2`, `-3`, … only when a run in the same second already used the name.
 */
export function defaultSnapshotName(accountId, at, attempt = 1) {
    const stamp = at.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
    return `cloudflare-checkup-${accountId.slice(0, 8)}-${stamp}${attempt > 1 ? `-${attempt}` : ''}.json`;
}
/** Write to `--out`, or to a fresh default name in the checkups directory (never replacing a file there). */
async function writeToTarget(target, accountId, startedAt, text, overwrite) {
    if (target.kind === 'out')
        return { path: target.path, ...(await writeSnapshotFile(target.path, text, { overwrite })) };
    for (let attempt = 1;; attempt++) {
        const path = join(target.dir, defaultSnapshotName(accountId, startedAt, attempt));
        try {
            return { path, ...(await writeSnapshotFile(path, text, { overwrite: false })) };
        }
        catch (error) {
            if (!(error instanceof LocalCheckupError) || error.code !== 'OUTPUT_EXISTS' || attempt >= 20)
                throw error;
        }
    }
}
// ============================================================================
// login
// ============================================================================
async function runLogin(ctx, options) {
    const argv = loginArgv(options.auth.profile, RECOMMENDED_CF_SCOPES, { noBrowser: Boolean(ctx.values.isNoOpen) });
    if (ctx.values.isDryRun) {
        if (ctx.mode === 'json')
            outputJson(ctx, { success: true, dryRun: true, argv: ['cf', ...argv] });
        else
            ctx.writer.stdout(`cf ${argv.join(' ')}`);
        return ok();
    }
    assertLocalRuntimeSupported();
    const warnings = [];
    if (hasEnvToken(ctx.env))
        warn(ctx, warnings, { code: 'ENV_TOKEN_NOT_USED', message: strippedTokenNotice('profile') });
    const location = await locateAndWarn(ctx, options, warnings);
    await withCfRunner(location.entry, async (runner) => {
        await checkVersion(ctx, runner, location, 'profile', warnings);
        info(ctx, `Running: cf auth create ${options.auth.profile} --scopes … (${RECOMMENDED_CF_SCOPES.length} read scopes)`);
        // cf keeps the login in its own config directory.
        const code = await runner.runInteractive(argv, buildChildEnv(ctx.env, { purpose: 'login', authMode: 'profile' }), { stdoutToStderr: ctx.mode === 'json' });
        if (code !== 0)
            throw new LocalCheckupError('CF_AUTH_REQUIRED', 'cf auth create did not complete; the profile was not created or refreshed.');
    });
    if (ctx.mode === 'json') {
        outputJson(ctx, { success: true, profile: options.auth.profile, scopes: [...RECOMMENDED_CF_SCOPES], warnings });
    }
    else {
        ctx.writer.stdout(`✓ cf profile "${options.auth.profile}" is ready with read-only scopes.`);
        ctx.writer.stdout(`Next: run ${NPX_COMMAND} cloudflare-checkup`);
    }
    return ok();
}
// ============================================================================
// logout
// ============================================================================
/**
 * `cf auth delete <profile>` through the located cf (interactive passthrough,
 * so cf's own message is visible), then the managed cf install is removed.
 * A profile cf declines to delete (usually: it does not exist) is reported,
 * not an error, so logout can be repeated. A cf that cannot run at all is an
 * error (exit 1, `success: false`): the login may still be on disk, so the
 * managed cf is kept, since it is usually the only cf that can delete it.
 */
async function runLogout(ctx, options) {
    const configDir = options.configDir;
    const profile = options.auth.profile;
    const warnings = [];
    let location = null;
    try {
        location = await locateAndWarn(ctx, options, warnings);
    }
    catch (error) {
        // An explicit --cf-bin that is not cf is an error; with no cf at all, the profile cannot be checked.
        if (options.cfBin !== undefined || !(error instanceof LocalCheckupError) || !['CF_NOT_FOUND', 'CF_WRONG_BINARY'].includes(error.code))
            throw error;
    }
    let profileRemoved = false;
    let cfProblem = null;
    if (location) {
        const found = location;
        try {
            assertLocalRuntimeSupported();
            await withCfRunner(found.entry, async (runner) => {
                await checkVersion(ctx, runner, found, 'profile', warnings);
                say(ctx, `Running: cf auth delete ${profile}`);
                const code = await runner.runInteractive(logoutArgv(profile), buildChildEnv(ctx.env, { purpose: 'login', authMode: 'profile' }), { stdoutToStderr: ctx.mode === 'json' });
                profileRemoved = code === 0;
            });
        }
        catch (error) {
            // A cf that cannot run must not stop a broken managed install from being removed (unless --cf-bin named it).
            if (options.cfBin !== undefined || !(error instanceof LocalCheckupError))
                throw error;
            cfProblem = error;
        }
    }
    // The login could not be deleted: keep the cf Vigilbase installed (if any) for the next attempt.
    const keepManaged = cfProblem !== null && await pathExists(managedCfRoot(configDir));
    const managed = cfProblem ? { path: managedCfRoot(configDir), removed: false } : await removeManagedCf(configDir);
    const hint = cfProblem ? logoutHint(cfProblem, profile, location?.source === 'managed') : undefined;
    if (ctx.mode === 'json') {
        outputJson(ctx, {
            success: cfProblem === null,
            profile: { name: profile, removed: profileRemoved, ...(cfProblem ? { problem: cfProblem.code } : {}) },
            managedCf: { path: managed.path, removed: managed.removed, ...(keepManaged ? { kept: true } : {}) },
            warnings,
            ...(hint ? { hint } : {}),
        });
        return cfProblem ? EXIT.ERROR : ok();
    }
    if (profileRemoved)
        ctx.writer.stdout(`✓ Removed the read-only Cloudflare login (cf profile "${profile}").`);
    else if (location && !cfProblem)
        ctx.writer.stdout(`cf did not delete the profile "${profile}" (see cf's message above; usually it does not exist).`);
    else if (!location)
        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}.`);
    if (keepManaged)
        ctx.writer.stdout(`Kept the cf that Vigilbase installed (${displayPath(managed.path, ctx.env)}) to remove the login later.`);
    else
        ctx.writer.stdout(managed.removed ? `✓ Removed the cf that Vigilbase installed (${displayPath(managed.path, ctx.env)}).` : 'No Vigilbase-installed cf to remove.');
    if (cfProblem) {
        ctx.writer.stderr(`Error: cf could not run (${cfProblem.code}: ${cfProblem.message}), so the read-only Cloudflare login (cf profile "${profile}") was not removed.`);
        ctx.writer.stderr(`Hint: ${hint}`);
        return EXIT.ERROR;
    }
    return ok();
}
/** What to do when logout could not run cf to delete the profile. */
function logoutHint(problem, profile, managedFailed) {
    const logout = `${NPX_COMMAND} cloudflare-checkup logout`;
    if (problem.code === 'NODE_UNSUPPORTED')
        return `Run ${logout} again under Node.js 22.18 or newer (https://nodejs.org).`;
    const install = `Install cf with \`npm install --global cf@${MANAGED_CF.version}\``;
    // A damaged managed cf would be found first again; --cf-bin names the new one, and a successful logout then removes the old.
    if (managedFailed)
        return `${install}, then run ${logout} --cf-bin "$(npm root -g)/cf".`;
    return `${install}, then run ${logout} again (or \`cf auth delete ${profile}\`).`;
}
async function pathExists(target) {
    return (await stat(target).catch(() => null)) !== null;
}
// ============================================================================
// doctor
// ============================================================================
/** doctor makes one whoami call (two when that call refreshed an expired token). */
const DOCTOR_BUDGET = 100;
async function runDoctor(ctx, options) {
    const warnings = [];
    const problems = [];
    const record = (error) => {
        if (error instanceof LocalCheckupError)
            problems.push({ code: error.code, message: error.message, hint: error.hint });
        else
            problems.push({ code: 'CF_ERROR', message: error instanceof Error ? error.message : 'Unexpected error.' });
    };
    const nodeSupported = isNodeSupported();
    const cf = { found: false, path: null, version: null, supported: false, tested: false, identityVerified: false };
    const commands = { verified: 0, mismatched: [] };
    let auth = {
        source: authSourceFor(options.auth.authMode),
        profile: options.auth.authMode === 'profile' ? options.auth.profile : null,
        authenticated: false,
        scopes: 'unknown',
    };
    let runtimeOk = true;
    try {
        assertLocalRuntimeSupported();
    }
    catch (error) {
        runtimeOk = false;
        record(error);
    }
    let location = null;
    try {
        location = await locateAndWarn(ctx, options, warnings);
        Object.assign(cf, { found: true, path: location.packageDir, identityVerified: true });
    }
    catch (error) {
        record(error);
    }
    if (location && runtimeOk) {
        const found = location;
        await withCfRunner(found.entry, async (runner) => {
            try {
                const prepared = await checkVersion(ctx, runner, found, options.auth.authMode, warnings);
                Object.assign(cf, { version: prepared.version, supported: true, tested: prepared.tested });
            }
            catch (error) {
                record(error);
                return;
            }
            const preflight = await runPreflight(ctx, runner, options.auth);
            commands.verified = preflight.verified;
            commands.mismatched = preflight.mismatched;
            if (preflight.mismatched.length > 0) {
                problems.push({ code: 'CF_ALLOWLIST_MISMATCH', message: `${preflight.mismatched.length} of ${preflight.total} command templates do not map in this cf release.` });
            }
            try {
                checkAuthEnvironment(ctx, options, warnings);
                auth = doctorAuth(await createScheduler(ctx, runner, options.auth, DOCTOR_BUDGET).start(), options.auth.authMode, options.auth.profile);
            }
            catch (error) {
                record(error);
            }
        });
    }
    if (auth.authenticated && options.auth.authMode === 'default' && (auth.writeScopeCount ?? 0) > 0) {
        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).` });
    }
    const success = nodeSupported && cf.supported && commands.mismatched.length === 0 && auth.authenticated && problems.length === 0;
    if (ctx.mode === 'json') {
        outputJson(ctx, { success, node: { version: process.versions.node, supported: nodeSupported }, cf, auth, commands, warnings, problems });
        return success ? EXIT.OK : EXIT.ERROR;
    }
    const mark = (isOk) => (isOk ? '✓' : '✗');
    ctx.writer.stdout(`${mark(nodeSupported)} Node.js ${process.versions.node}${nodeSupported ? '' : ' (22.18 or newer required)'}`);
    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)' : ''}`);
    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(', ')}` : ''}`);
    const authLabel = options.auth.authMode === 'profile' ? `profile "${options.auth.profile}"` : options.auth.authMode === 'env' ? 'API token from this shell' : 'default profile';
    ctx.writer.stdout(`${mark(auth.authenticated)} cf auth (${authLabel}): ${auth.authenticated ? 'signed in' : 'not signed in'}${auth.expiresAt ? ` · expires ${formatClock(auth.expiresAt)}` : ''}`);
    if (auth.authenticated && auth.scopes === 'unknown')
        ctx.writer.stdout('  scopes: unknown (API token permissions cannot be introspected)');
    if (auth.missingRecommendedScopes?.length)
        ctx.writer.stdout(`  missing recommended scopes: ${auth.missingRecommendedScopes.join(' ')}`);
    if (auth.writeScopeCount)
        ctx.writer.stdout(`  write scopes: ${auth.writeScopeCount}`);
    for (const problem of problems) {
        ctx.writer.stderr(`${problem.code}: ${problem.message}`);
        if (problem.hint)
            ctx.writer.stderr(`  Hint: ${problem.hint}`);
    }
    return success ? EXIT.OK : EXIT.ERROR;
}
// ============================================================================
// accounts
// ============================================================================
async function runAccounts(ctx, options) {
    assertLocalRuntimeSupported();
    const warnings = [];
    checkAuthEnvironment(ctx, options, warnings);
    const location = await locateAndWarn(ctx, options, warnings);
    const accounts = await withCfRunner(location.entry, async (runner) => {
        await checkVersion(ctx, runner, location, options.auth.authMode, warnings);
        assertCriticalTemplates(await runPreflight(ctx, runner, options.auth, ['account_selection']), ['account_selection']);
        const scheduler = createScheduler(ctx, runner, options.auth, options.maxRequests);
        await scheduler.start();
        return listAccounts({ scheduler, auth: options.auth, unavailable: new Set() });
    });
    if (ctx.mode === 'json') {
        outputJson(ctx, { success: true, accounts });
    }
    else if (accounts.length === 0) {
        ctx.writer.stdout('cf can read no Cloudflare accounts with this login.');
    }
    else {
        for (const account of accounts)
            ctx.writer.stdout(`${account.id}  ${displayText(account.name, 120)}`);
    }
    return ok();
}
// ============================================================================
// collect --dry-run
// ============================================================================
const REQUEST_FORMULA = 'about 19 + extra pages + zones × (33 + rulesets per zone) cf requests';
async function runCollectDryRun(ctx, options) {
    assertLocalRuntimeSupported();
    const warnings = [];
    const location = await locateAndWarn(ctx, options, warnings);
    const { prepared, preflight } = await withCfRunner(location.entry, async (runner) => ({
        prepared: await checkVersion(ctx, runner, location, options.auth.authMode, warnings),
        preflight: await runPreflight(ctx, runner, options.auth),
    }));
    const display = (id) => ['cf', ...templateDisplay(id, options.auth)];
    const plan = {
        accountSelection: [display('account_selection')],
        account: TEMPLATE_IDS.filter((id) => id !== 'account_selection' && TEMPLATES[id].scope === 'account').map(display),
        perZone: TEMPLATE_IDS.filter((id) => TEMPLATES[id].scope === 'zone' || TEMPLATES[id].scope === 'setting').map(display),
        perRuleset: [display('zone_ruleset')],
        notCollected: [...NOT_COLLECTABLE_ZONE_KINDS],
    };
    const childEnv = childEnvVariableNames(ctx.env, options.auth.authMode);
    if (ctx.mode === 'json') {
        outputJson(ctx, {
            success: true,
            dryRun: true,
            cf: { version: prepared.version, path: location.packageDir, tested: prepared.tested },
            plan,
            verified: { commands: preflight.verified, mismatched: preflight.mismatched },
            childEnv,
            requestFormula: REQUEST_FORMULA,
            warnings,
        });
        return ok();
    }
    const lines = [
        `Using Cloudflare cf ${prepared.version} (${location.packageDir}) · ${preflight.verified}/${preflight.total} command templates mapped offline`,
        ...(preflight.mismatched.length ? [`Not mapped (recorded as command_unavailable): ${preflight.mismatched.join(', ')}`] : []),
        '',
        'Account selection (only without --account-id):',
        ...plan.accountSelection.map((argv) => `  ${argv.join(' ')}`),
        'Account (CLOUDFLARE_ACCOUNT_ID=<ACCOUNT_ID> for every call):',
        ...plan.account.map((argv) => `  ${argv.join(' ')}`),
        'Per zone (<SETTING_ID> is each of the 19 fixed zone settings):',
        ...plan.perZone.map((argv) => `  ${argv.join(' ')}`),
        'Per listed ruleset:',
        ...plan.perRuleset.map((argv) => `  ${argv.join(' ')}`),
        `Not collectable with cf (recorded as not_collected for every zone): ${plan.notCollected.join(', ')}`,
        `cf child environment (names only): ${childEnv.join(' ')}`,
        `Requests: ${REQUEST_FORMULA}, at most ${options.maxRequests} per 5 minutes.`,
        'No Cloudflare or Vigilbase requests were made.',
    ];
    for (const line of lines)
        ctx.writer.stdout(line);
    return ok();
}
/**
 * Advisory pre-flight for --out, before any cf spawn, so a typo cannot throw
 * away a whole collection. writeSnapshotFile stays the race-safe final guard.
 */
async function assertOutWritable(out, overwrite) {
    const existing = await stat(out).catch(() => null);
    if (existing && !overwrite)
        throw new LocalCheckupError('OUTPUT_EXISTS', 'The --out file already exists; nothing was collected.');
    if (existing && !existing.isFile())
        throw new Error('--out must name a regular file; nothing was collected.');
    const dir = dirname(out);
    const parent = await stat(dir).catch(() => null);
    if (!parent || !parent.isDirectory())
        throw new Error('The --out directory does not exist; nothing was collected.');
    try {
        await access(dir, constants.W_OK);
    }
    catch {
        throw new Error('The --out directory is not writable; nothing was collected.');
    }
}
/** Before any cf spawn: check --out, or create the default checkups directory (0700, never through a symlink). */
async function prepareOutput(ctx, options, overwrite) {
    if (options.out !== undefined) {
        await assertOutWritable(options.out, overwrite);
        return { kind: 'out', path: options.out };
    }
    const dir = join(options.configDir, 'checkups');
    try {
        await ensurePrivateDir(dir);
    }
    catch {
        throw new Error(`Could not create ${displayPath(dir, ctx.env)} for the snapshot; pass --out <file> instead.`);
    }
    return { kind: 'default', dir };
}
/**
 * Why a located cf cannot be used, found before any authenticated call: none
 * at all, a different program, an unsupported version, or (after `--version`
 * and the offline `--dry-run` mapping) a release that renamed commands the
 * checkup needs, for example a newer global cf.
 */
const INSTALLABLE_CODES = new Set(['CF_NOT_FOUND', 'CF_WRONG_BINARY', 'CF_UNSUPPORTED_VERSION', 'CF_ALLOWLIST_MISMATCH']);
/** Thrown inside the runner when the located cf is unusable before anything but `--version` and `--dry-run` ran. */
class UnusableCf extends Error {
    cause;
    constructor(cause) {
        super(cause.message);
        this.cause = cause;
    }
}
/** `cf --version` and the offline mapping of every template; no auth, no network. */
async function prepareCf(ctx, runner, location, auth, warnings) {
    const prepared = await checkVersion(ctx, runner, location, auth.authMode, warnings);
    const preflight = await runPreflight(ctx, runner, auth);
    assertCriticalTemplates(preflight);
    // An untested cf release is used only when every template maps (spec §A3).
    if (!prepared.tested && preflight.mismatched.length > 0) {
        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(', ')}.`);
    }
    return { ...prepared, preflight };
}
/**
 * Locate cf (`--cf-bin`, the managed install, PATH, pnpm, Volta), check its
 * version and offline mapping, and run `work`. In the one command, an
 * unusable or missing cf (without `--cf-bin`) leads to the managed-install
 * offer, then `work` runs with the managed cf; the user's own cf is left alone.
 */
async function withUsableCf(ctx, options, warnings, work) {
    const canInstall = options.subcommand === 'run' && options.cfBin === undefined && options.configDir !== null;
    const attempt = (location) => withCfRunner(location.entry, async (runner) => {
        const warned = warnings.length;
        let ready;
        try {
            ready = await busy(ctx, 'Checking Cloudflare\'s cf CLI', () => prepareCf(ctx, runner, location, options.auth, warnings));
        }
        catch (error) {
            if (canInstall && error instanceof LocalCheckupError && INSTALLABLE_CODES.has(error.code)) {
                // Warnings about a cf that is replaced (CF_UNTESTED_VERSION) do not describe the run.
                warnings.splice(warned);
                throw new UnusableCf(error);
            }
            throw error;
        }
        return work(runner, location, ready);
    });
    let unusable = null;
    let replace = false;
    let location = null;
    try {
        location = await busy(ctx, 'Looking for Cloudflare\'s cf CLI', () => locateAndWarn(ctx, options, warnings));
    }
    catch (error) {
        if (!canInstall || !(error instanceof LocalCheckupError) || !INSTALLABLE_CODES.has(error.code))
            throw error;
        unusable = error;
    }
    if (location) {
        try {
            return { result: await attempt(location), managed: location.source === 'managed' };
        }
        catch (error) {
            if (!(error instanceof UnusableCf))
                throw error;
            unusable = error.cause;
            // A managed install whose cf cannot run is reinstalled, not reused.
            replace = location.source === 'managed';
        }
    }
    const installed = await offerManagedInstall(ctx, options, unusable, replace);
    const managed = await locateAndWarn(ctx, options, warnings, installed);
    try {
        return { result: await attempt(managed), managed: managed.source === 'managed' };
    }
    catch (error) {
        throw error instanceof UnusableCf ? error.cause : error;
    }
}
/** The `[Y/n]` offer to install the pinned cf into the config directory; returns the package directory. */
async function offerManagedInstall(ctx, options, reason, replace) {
    const { tui } = ctx;
    const what = `Cloudflare's cf CLI (cf@${MANAGED_CF.version}, ${MANAGED_CF.approxSize})`;
    if (reason.code !== 'CF_NOT_FOUND') {
        if (tui)
            tui.warning(`The cf found on this machine cannot be used: ${reason.message}`);
        else
            say(ctx, `The cf found on this machine cannot be used: ${reason.message}`);
    }
    const question = reason.code === 'CF_NOT_FOUND'
        ? `${what} is not installed. Install it for Vigilbase now?`
        : `Install ${what} for Vigilbase now?`;
    // The terminal UI's question fits one line; the size goes under it.
    const short = reason.code === 'CF_NOT_FOUND' ? 'cf is not installed. Install it for Vigilbase?' : 'Install a cf for Vigilbase?';
    const detail = `Cloudflare's cf CLI ${MANAGED_CF.version}, ${MANAGED_CF.approxSize}; your own cf is left alone`;
    if (ctx.values.isYes) {
        if (tui)
            tui.answeredByFlag(short, '--yes');
        else
            ctx.writer.stderr(`${question} [Y/n] y (--yes)`);
    }
    else if (!isInteractive(ctx)) {
        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.`);
    }
    else if (!(await (tui ? tui.confirm(short, { initial: true, detail }) : confirmDefaultYes(promptIO(ctx), question)))) {
        throw new LocalCheckupError('CF_NOT_FOUND', 'cf was not installed, so the checkup cannot collect.');
    }
    const label = `Installing cf ${MANAGED_CF.version} (one time)`;
    const progress = tui ? tui.spinner(label) : startCountsProgress(shouldShowProgress(ctx.mode), stderrStream(ctx));
    if (!tui) {
        if (shouldShowProgress(ctx.mode))
            progress.update(`${label}…`);
        else
            say(ctx, `${label}…`);
    }
    try {
        const packageDir = await installManagedCf({ configDir: options.configDir, env: ctx.env, replace, onInterrupt: () => progress.stop() });
        progress.stop();
        if (tui)
            tui.step(`Installed cf ${MANAGED_CF.version}`);
        else
            say(ctx, `✓ Installed cf ${MANAGED_CF.version}`);
        return packageDir;
    }
    catch (error) {
        progress.stop();
        if (error instanceof CfInstallError && error.npmOutput.length > 0) {
            ctx.writer.stderr(`npm output (last ${error.npmOutput.length} lines):`);
            for (const line of error.npmOutput)
                ctx.writer.stderr(`  ${line}`);
        }
        throw error;
    }
}
/**
 * whoami before any fan-out. In the one command with a cf profile, a missing
 * or expired login on a terminal starts the read-only `cf auth create`
 * sign-in, then whoami is retried once (the terminal UI offers the sign-in
 * up to twice more when it fails).
 */
async function startAuth(ctx, runner, options, scheduler) {
    const whoami = () => busy(ctx, 'Checking the Cloudflare sign-in', () => scheduler.start());
    try {
        return await whoami();
    }
    catch (error) {
        const canSignIn = options.subcommand === 'run' && options.auth.authMode === 'profile' &&
            error instanceof LocalCheckupError && error.code === 'CF_AUTH_REQUIRED' && isInteractive(ctx);
        if (!canSignIn)
            throw error;
    }
    const argv = loginArgv(options.auth.profile, RECOMMENDED_CF_SCOPES, { noBrowser: false });
    const loginEnv = buildChildEnv(ctx.env, { purpose: 'login', authMode: 'profile' });
    // cf prints and reads on the terminal itself; the terminal UI hands it over and offers retries.
    if (ctx.tui)
        return ctx.tui.signIn(() => runner.runInteractive(argv, loginEnv), whoami);
    say(ctx, 'Signing in to Cloudflare with a read-only login. Your browser will open…');
    const code = await runner.runInteractive(argv, loginEnv, { stdoutToStderr: ctx.mode === 'json' });
    if (code !== 0)
        throw new LocalCheckupError('CF_AUTH_REQUIRED', 'The Cloudflare sign-in (cf auth create) did not complete.');
    return scheduler.start();
}
async function collectSnapshot(ctx, options, session) {
    const full = options.subcommand === 'run';
    const log = (line) => (full ? say(ctx, line) : info(ctx, line));
    const overwrite = Boolean(ctx.values.isOverwrite);
    const target = await prepareOutput(ctx, options, overwrite);
    const warnings = [];
    checkAuthEnvironment(ctx, options, warnings);
    const started = Date.now();
    const { result, managed } = await withUsableCf(ctx, options, warnings, async (runner, location, prepared) => {
        const { preflight } = prepared;
        if (!full)
            info(ctx, `Using Cloudflare cf ${prepared.version} (${location.packageDir}) · ${preflight.verified}/${preflight.total} command templates mapped offline`);
        if (preflight.mismatched.length) {
            warn(ctx, warnings, { code: 'CF_COMMANDS_UNAVAILABLE', message: `Not mapped in this cf release (recorded as command_unavailable): ${preflight.mismatched.join(', ')}` });
        }
        const scheduler = createScheduler(ctx, runner, options.auth, options.maxRequests);
        const whoami = await startAuth(ctx, runner, options, scheduler);
        if (ctx.tui)
            ctx.tui.signedIn(signInSummary(options.auth, whoami, ctx.tui));
        else
            log(full ? `✓ Signed in · ${profileLine(options.auth, whoami)}` : profileLine(options.auth, whoami));
        if (options.auth.authMode === 'default' && whoami.scopes?.some((scope) => !isReadOnlyScope(scope))) {
            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).' });
        }
        let progress = startCountsProgress(false);
        // The terminal UI's progress bar, once the zones are known.
        let live = null;
        const env = {
            scheduler,
            auth: options.auth,
            unavailable: new Set(preflight.mismatched),
            onProgress: (state) => (live ? live.update(state) : progress.update(`zones ${state.zonesDone}/${state.zonesTotal} · ${state.requests} requests`)),
        };
        const bound = options.accountId ?? session?.accountId ?? null;
        const accountId = bound ?? await chooseAccount(ctx, env, log);
        if (ctx.tui && bound !== null)
            ctx.tui.account(maskId(accountId, ctx.tui.ellipsis), options.accountId !== undefined ? 'from --account-id' : 'bound to this checkup');
        try {
            const startedAt = new Date();
            const entries = await collectAccount(env, accountId, {
                onZones: (zones) => {
                    const requests = estimateRequests(zones.length);
                    if (ctx.tui) {
                        live = ctx.tui.collecting({ zones: zones.length, plannedReads: requests, maxRequests: options.maxRequests });
                        return;
                    }
                    const seconds = estimateBudgetSeconds(options.maxRequests, requests);
                    log(`Account ${maskId(accountId)} · ${zones.length} zone${zones.length === 1 ? '' : 's'} · ~${requests} cf requests at ≤${options.maxRequests} per 5 min (${formatDuration(seconds)})`);
                    // The spinner starts once zones are known (after any account prompt and the estimate line).
                    progress = startCountsProgress(shouldShowProgress(ctx.mode), stderrStream(ctx));
                    progress.update(`zones 0/${zones.length} · ${scheduler.invocations} requests`);
                },
            });
            progress.stop();
            live?.stop();
            const collector = {
                name: SNAPSHOT_COLLECTOR_NAME,
                version: CLI_VERSION,
                cf: { version: prepared.version },
                authSource: authSourceFor(options.auth.authMode),
                platform: process.platform,
                startedAt: startedAt.toISOString(),
                finishedAt: new Date().toISOString(),
                cfInvocations: scheduler.invocations,
            };
            const { snapshot, text } = buildSnapshot(accountId, collector, entries);
            if (Buffer.byteLength(text) > SNAPSHOT_LIMITS.defaultServerMaxBytes) {
                warn(ctx, warnings, {
                    code: 'SNAPSHOT_OVER_DEFAULT_UPLOAD_LIMIT',
                    message: `The snapshot is larger than ${SNAPSHOT_LIMITS.defaultServerMaxBytes / 1024 / 1024} MiB, the default upload limit.`,
                });
            }
            const written = await writeToTarget(target, accountId, startedAt, text, overwrite);
            return { ...written, snapshot, version: prepared.version, cfInvocations: scheduler.invocations };
        }
        finally {
            progress.stop();
            live?.stop();
        }
    });
    return { ...result, durationMs: Date.now() - started, warnings, usedProfile: options.auth.authMode === 'profile', managedCf: managed };
}
function snapshotPayload(outcome) {
    return {
        path: outcome.path,
        bytes: outcome.bytes,
        sha256: outcome.sha256,
        schemaVersion: outcome.snapshot.schemaVersion,
        accountId: outcome.snapshot.accountId,
        zoneCount: zoneCount(outcome.snapshot),
        entries: countEntries(outcome.snapshot.entries),
    };
}
function deniedReads(snapshot) {
    return snapshot.entries.filter((entry) => {
        const result = entry.result;
        return !result.ok && result.reason === 'http_error' && result.status === 403;
    }).length;
}
async function runCollect(ctx, options) {
    assertLocalRuntimeSupported();
    const outcome = await collectSnapshot(ctx, options, null);
    if (ctx.mode === 'json') {
        outputJson(ctx, {
            success: true,
            snapshot: snapshotPayload(outcome),
            cf: { version: outcome.version, authSource: authSourceFor(options.auth.authMode) },
            cfInvocations: outcome.cfInvocations,
            durationMs: outcome.durationMs,
            warnings: outcome.warnings,
        });
        return ok();
    }
    const summary = readsSummary(countEntries(outcome.snapshot.entries), deniedReads(outcome.snapshot));
    ctx.writer.stdout(`✓ Wrote ${outcome.path} (${formatBytes(outcome.bytes)}, sha256 ${shortSha(outcome.sha256)})${summary ? ` · ${summary}` : ''}`);
    ctx.writer.stdout(`Next: inspect it, then upload it with ${NPX_COMMAND} cloudflare-checkup submit ${shellArg(outcome.path)}`);
    return ok();
}
/** The one command's summary of the written file: counts and data categories, no values. */
function printCollectSummary(ctx, outcome) {
    const { snapshot } = outcome;
    const counts = countEntries(snapshot.entries);
    const denied = deniedReads(snapshot);
    const zones = zoneCount(snapshot);
    const notOk = [
        counts.notCollected ? `${counts.notCollected} not collectable with cf` : null,
        denied ? `${denied} denied` : null,
        counts.httpError - denied ? `${counts.httpError - denied} failed with another HTTP error` : null,
        counts.collectionFailed ? `${counts.collectionFailed} could not be collected` : null,
    ].filter((part) => part !== null);
    const categories = dataCategories(snapshot);
    const data = [
        [categories.memberEmails, 'member email', 'member emails'],
        [categories.accessRuleValues, 'access-rule value', 'access-rule values'],
        [categories.dnsHostnames, 'DNS name', 'DNS names'],
        [categories.pseudonymizedIps, 'pseudonymised IP', 'pseudonymised IPs'],
        [categories.ruleDescriptions, 'rule description', 'rule descriptions'],
    ];
    const dataParts = data.filter(([count]) => count > 0).map(([count, one, many]) => `${count} ${count === 1 ? one : many}`);
    if (ctx.tui) {
        ctx.tui.collected({
            zones, total: counts.total, ok: counts.ok, notCollectable: counts.notCollected, denied,
            failed: counts.httpError - denied + counts.collectionFailed,
            path: displayPath(outcome.path, ctx.env), bytes: outcome.bytes, contents: dataParts,
            inspect: `${NPX_COMMAND} cloudflare-checkup inspect ${shellArg(displayPath(outcome.path, ctx.env))}`,
        });
        return;
    }
    say(ctx, `✓ Saved ${displayPath(outcome.path, ctx.env)} (${formatBytes(outcome.bytes)})`);
    say(ctx, `  ${zones} zone${zones === 1 ? '' : 's'} · ${counts.total} reads (${[`${counts.ok} ok`, ...notOk].join(', ')})${dataParts.length ? ` · ${dataParts.join(' · ')}` : ''}`);
    say(ctx, `  No credentials. Inspect it: ${NPX_COMMAND} cloudflare-checkup inspect ${shellArg(outcome.path)}`);
}
// ============================================================================
// The one command
// ============================================================================
function clientOptions(ctx, origin) {
    return {
        origin,
        cliVersion: CLI_VERSION,
        userAgent: ctx.config.userAgent,
        ...(ctx.values.timeout !== undefined ? { timeoutMs: ctx.config.timeoutMs } : {}),
    };
}
/**
 * Before the email is asked for (or `--email` is used): vigilbase.com says
 * whether local checkups are on and which CLI and snapshot schema it accepts,
 * so a switched-off local mode (LOCAL_MODE_DISABLED) or an old CLI
 * (CLI_UPGRADE_REQUIRED) never costs an emailed code. Without a usable answer
 * the run goes on, and the session check after the email decides as before.
 */
async function checkAvailability(ctx, origin, schemaVersion) {
    const client = new VerificationClient(clientOptions(ctx, origin));
    const availability = await busy(ctx, 'Checking vigilbase.com', () => client.availability());
    if (availability)
        assertServerAccepts(availability, CLI_VERSION, schemaVersion);
}
/**
 * The email verification (prompts on stderr, answers from piped stdin
 * without a terminal), then `✓ Verified j***@acme.com`. No address is sent
 * before one is entered, and cf is not touched before it is verified.
 */
async function verifyRequester(ctx, options, origin) {
    const verified = await verifyEmail({
        client: new VerificationClient(clientOptions(ctx, origin)),
        io: promptIO(ctx),
        locale: emailLocale(ctx.env),
        ...(options.email !== undefined ? { email: options.email } : {}),
        say: (line) => say(ctx, line),
        notice: (line) => ctx.writer.stderr(line),
        // The terminal UI shows `✓ verified` on the code's line.
        ...(ctx.tui ? { view: ctx.tui.emailView() } : {}),
    });
    if (!ctx.tui)
        say(ctx, `✓ Verified ${verified.requester}`);
    return verified;
}
/**
 * The checkup the verified email started. A session that expired (a long
 * collection, or a slow answer to the upload question) is renewed once with
 * the token and code still in memory; vigilbase.com mints the next session
 * code for the same checkup. The snapshot file is kept either way.
 */
class CheckupSession {
    ctx;
    origin;
    verified;
    constructor(ctx, origin, verified) {
        this.ctx = ctx;
        this.origin = origin;
        this.verified = verified;
    }
    client() {
        return new SubmitClient({ ...clientOptions(this.ctx, this.origin), code: this.verified.sessionCode });
    }
    async withRenewal(call) {
        try {
            return await call(this.client());
        }
        catch (error) {
            if (!(error instanceof LocalCheckupError) || error.code !== 'SESSION_EXPIRED' || !this.verified.renewable)
                throw error;
        }
        try {
            await this.verified.renew();
        }
        catch (error) {
            if (error instanceof LocalCheckupError && (error.code === 'VERIFICATION_EXPIRED' || error.code === 'VERIFICATION_INVALID')) {
                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 } : {});
            }
            throw error;
        }
        if (this.ctx.tui)
            this.ctx.tui.step('Renewed the checkup session, which had expired');
        else
            say(this.ctx, '✓ Renewed the checkup session, which had expired');
        return call(this.client());
    }
    getSession() {
        return this.withRenewal((client) => client.getSession());
    }
    postSnapshot(bytes, sha256) {
        return this.withRenewal((client) => client.postSnapshot(bytes, sha256));
    }
}
/** Failures after the snapshot is saved that a later `submit <file>` can fix without collecting again. */
const UPLOAD_LATER_CODES = new Set(['SESSION_INVALID', 'SESSION_EXPIRED', 'SESSION_CLOSED', 'VERIFICATION_EXPIRED', 'VERIFICATION_INVALID',
    'SUBMIT_FAILED', 'VIGILBASE_RATE_LIMITED', 'VIGILBASE_CHALLENGED', 'LOCAL_MODE_DISABLED']);
/**
 * `submit` for a saved file. A loopback origin is named with `--base-url`, so a
 * development run never suggests an upload to production; production is the
 * CLI's default and is not named.
 */
export function submitCommand(path, origin) {
    const baseUrl = isLoopbackOrigin(origin) ? ` --base-url ${shellArg(origin)}` : '';
    return `${NPX_COMMAND} cloudflare-checkup submit ${shellArg(path)}${baseUrl}`;
}
/**
 * Right before such an error is reported (stderr, both modes): the `submit`
 * command for the saved file. A line of its own, because hints are capped at
 * 300 characters and a path has no fixed length; in the terminal UI, the
 * command has a line of its own too, never wrapped, so it can be copied.
 */
function noteSavedSnapshot(ctx, error, path, origin) {
    if (!(error instanceof LocalCheckupError) || !UPLOAD_LATER_CODES.has(error.code))
        return;
    const submit = submitCommand(path, origin);
    if (ctx.tui)
        ctx.tui.uploadLater(submit);
    else
        ctx.writer.stderr(`The snapshot stays saved. To upload it later without collecting again, run ${submit} (it verifies your email again).`);
}
/** Session checks that need no upload: CLI version, single use, and the bound account. */
function assertSessionUsable(session, accountId) {
    assertServerAccepts(session, CLI_VERSION, SNAPSHOT_SCHEMA_VERSION);
    if (session.state === 'completed')
        throw new LocalCheckupError('SESSION_USED', 'This checkup already has results, so it cannot accept a new snapshot.');
    // A checkup the email verification just started is never processing; this guards any other state.
    if (session.state === 'processing')
        throw new LocalCheckupError('SESSION_BUSY', 'A snapshot is already being processed for this checkup.');
    if (accountId !== undefined && session.accountId !== null && session.accountId !== accountId) {
        throw new LocalCheckupError('ACCOUNT_MISMATCH', `This checkup is bound to Cloudflare account ${maskId(session.accountId)}, not ${maskId(accountId)}. Nothing was collected.`);
    }
}
/**
 * Without a terminal, a missing cf login and a choice between accounts show
 * only once cf runs, after the email was verified (`assertConfirmationAvailable`
 * cannot tell beforehand). Their hint then adds that running the command again
 * with the same address waits for vigilbase.com's 8 minutes per address. A hint
 * that is not the code's default (the API token one) is left as it is.
 */
function withSameAddressWait(error) {
    if (!(error instanceof LocalCheckupError) || (error.code !== 'CF_AUTH_REQUIRED' && error.code !== 'ACCOUNT_SELECTION_REQUIRED'))
        return error;
    if (error.hint !== LOCAL_ERROR_HINTS[error.code])
        return error;
    return new LocalCheckupError(error.code, error.message, {
        hint: `${error.hint} ${SAME_ADDRESS_WAIT}.`,
        ...(error.status !== undefined ? { status: error.status } : {}),
        ...(error.retryAfterSec !== undefined ? { retryAfterSec: error.retryAfterSec } : {}),
    });
}
function runFull(ctx, options) {
    return withTerminalUi(ctx, (local) => runOneCommand(local, options));
}
async function runOneCommand(ctx, options) {
    const origin = options.origin ?? PRODUCTION_ORIGIN;
    if (ctx.tui)
        ctx.tui.intro();
    else
        say(ctx, BANNER);
    assertLocalRuntimeSupported();
    // A bad --out never costs an emailed code (collectSnapshot checks it again).
    if (options.out !== undefined)
        await assertOutWritable(options.out, Boolean(ctx.values.isOverwrite));
    // 1-3. Availability, then the email, then the checkup it started are checked before cf is touched.
    await checkAvailability(ctx, origin, SNAPSHOT_SCHEMA_VERSION);
    const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
    const early = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
    assertSessionUsable(early, options.accountId);
    // 4-9. cf, sign-in, account, collection.
    let outcome;
    try {
        outcome = await collectSnapshot(ctx, options, early);
    }
    catch (error) {
        throw isInteractive(ctx) ? error : withSameAddressWait(error);
    }
    printCollectSummary(ctx, outcome);
    if (ctx.tui)
        ctx.tui.cancelMessage = 'Cancelled. Nothing was uploaded; the snapshot stays saved.';
    // 10. The session, re-checked (and renewed once if it expired) right before uploading.
    let result;
    try {
        const session = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
        // Upload exactly the bytes on disk, re-validated.
        const loaded = await loadSnapshotFile(outcome.path);
        result = await uploadLoaded(ctx, { checkup, session, origin, loaded });
    }
    catch (error) {
        noteSavedSnapshot(ctx, error, outcome.path, origin);
        throw error;
    }
    if (ctx.mode === 'json') {
        outputJson(ctx, { ...result, snapshot: snapshotPayload(outcome), warnings: outcome.warnings });
        return ok();
    }
    sayLogoutHint(ctx, outcome.usedProfile, outcome.managedCf);
    return ok();
}
/** logout removes the profile, and only the cf that Vigilbase installed (never the user's own). */
function sayLogoutHint(ctx, usedProfile, managedCf) {
    const logout = `${NPX_COMMAND} cloudflare-checkup logout`;
    const lead = usedProfile ? `Remove the read-only Cloudflare login${managedCf ? ' and the cf Vigilbase installed' : ''} later with:`
        : managedCf ? 'Remove the cf that Vigilbase installed later with:' : null;
    if (lead === null)
        return;
    if (ctx.tui)
        ctx.tui.footnote(lead, logout);
    else
        say(ctx, `${lead} ${logout}`);
}
// ============================================================================
// inspect
// ============================================================================
async function runInspect(ctx, options) {
    const loaded = await loadSnapshotFile(options.file);
    const { snapshot } = loaded;
    const name = accountName(snapshot);
    const categories = dataCategories(snapshot);
    const byKind = countEntriesByKind(snapshot.entries);
    if (ctx.mode === 'json') {
        outputJson(ctx, {
            success: true,
            snapshot: {
                path: resolve(loaded.path),
                bytes: loaded.bytes.byteLength,
                sha256: loaded.sha256,
                schemaVersion: snapshot.schemaVersion,
                accountId: snapshot.accountId,
                accountName: name,
                zoneCount: zoneCount(snapshot),
                collectedAt: snapshot.collector.finishedAt,
                collector: snapshot.collector,
            },
            entries: { byKind },
            dataCategories: categories,
        });
        return ok();
    }
    const counts = countEntries(snapshot.entries);
    const { collector } = snapshot;
    const lines = [
        `Snapshot   ${options.file}  ${formatBytes(loaded.bytes.byteLength)}  sha256 ${loaded.sha256}`,
        `Schema     ${snapshot.schemaVersion} (valid)`,
        `Account    ${snapshot.accountId}${name !== null ? `  ${name} (as reported)` : ''}`,
        `Zones      ${zoneCount(snapshot)}`,
        `Collected  ${collector.finishedAt} by ${collector.name} ${collector.version} with cf ${collector.cf.version} (${collector.authSource}, ${collector.platform}, ${collector.cfInvocations} cf calls)`,
        `Reads      ${counts.total} · ${counts.ok} ok · ${counts.httpError} HTTP error · ${counts.notCollected} not collectable with cf · ${counts.collectionFailed} collection failed`,
        ...Object.entries(byKind).map(([kind, kindCounts]) => {
            const notOk = kindCounts.total - kindCounts.ok;
            return `  ${kind.padEnd(34)} ${String(kindCounts.total).padStart(5)}${notOk ? `  (${notOk} not ok)` : ''}`;
        }),
        'Free text and identifiers in this file:',
        `  member emails ${categories.memberEmails} · access-rule values ${categories.accessRuleValues} · non-proxied DNS names ${categories.dnsHostnames} · pseudonymised IPs ${categories.pseudonymizedIps} · rule descriptions ${categories.ruleDescriptions}`,
        'No credentials are included, and origin IPs are pseudonymised.',
    ];
    for (const line of lines)
        ctx.writer.stdout(line);
    return ok();
}
// ============================================================================
// submit (and the upload step of the one command)
// ============================================================================
function runSubmit(ctx, options) {
    return withTerminalUi(ctx, (local) => submitFile(local, options));
}
async function submitFile(ctx, options) {
    const origin = options.origin ?? PRODUCTION_ORIGIN;
    const loaded = await loadSnapshotFile(options.file);
    assertFresh(loaded.snapshot);
    const zones = zoneCount(loaded.snapshot);
    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)}`;
    if (ctx.tui) {
        ctx.tui.intro();
        const { ellipsis } = ctx.tui;
        ctx.tui.step('Snapshot', ctx.tui.join([
            displayText(displayPath(resolve(options.file), ctx.env), 120, ellipsis),
            formatBytes(loaded.bytes.byteLength),
            `account ${maskId(loaded.snapshot.accountId, ellipsis)}`,
            `${zones} zone${zones === 1 ? '' : 's'}`,
            `collected ${formatClock(loaded.snapshot.collector.finishedAt)}`,
        ]));
    }
    else if (ctx.mode === 'json')
        ctx.writer.stderr(head);
    else
        ctx.writer.stdout(head);
    // The file, then vigilbase.com's availability, are checked before the email is verified, so neither costs a code.
    await checkAvailability(ctx, origin, loaded.snapshot.schemaVersion);
    const checkup = new CheckupSession(ctx, origin, await verifyRequester(ctx, options, origin));
    const session = await busy(ctx, 'Checking the checkup', () => checkup.getSession());
    const result = await uploadLoaded(ctx, { checkup, session, origin, loaded });
    if (ctx.mode === 'json')
        outputJson(ctx, result);
    return ok();
}
/** Refused locally: vigilbase.com accepts snapshots finished in the last 24 h (5 min clock skew). */
function assertFresh(snapshot, now = Date.now()) {
    const finished = Date.parse(snapshot.collector.finishedAt);
    if (!(finished >= now - SNAPSHOT_LIMITS.maxAgeHours * 3_600_000) || finished > now + SNAPSHOT_LIMITS.maxFutureSkewSeconds * 1_000) {
        throw new LocalCheckupError('SNAPSHOT_STALE', 'The snapshot was collected more than 24 hours ago (or is dated in the future).');
    }
}
function numberAt(value, ...path) {
    let current = value;
    for (const key of path) {
        if (!current || typeof current !== 'object')
            return undefined;
        current = current[key];
    }
    return typeof current === 'number' && Number.isFinite(current) ? current : undefined;
}
/**
 * Opens the results page in the browser, unless `--no-open`, `--json` or no
 * browser is available, and only when it is on the upload origin; true when
 * a browser was started. The link is printed either way.
 */
function openResults(ctx, url, origin) {
    if (ctx.mode === 'json' || ctx.values.isNoOpen || isHeadlessEnvironment(ctx.env))
        return false;
    if (new URL(url).origin !== origin)
        return false;
    return openUrlInBrowser(url, ctx.env).isOpened;
}
/**
 * Checks against the session (CLI version, bound account, limits), the
 * confirmation naming the masked verified address, then `POST` of exactly the
 * loaded bytes, and the results link.
 */
async function uploadLoaded(ctx, input) {
    const { checkup, session, origin, loaded } = input;
    const { snapshot } = loaded;
    assertFresh(snapshot);
    assertServerAccepts(session, CLI_VERSION, snapshot.schemaVersion);
    if (session.accountId !== null && session.accountId !== snapshot.accountId) {
        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.`);
    }
    const zones = zoneCount(snapshot);
    if (loaded.bytes.byteLength > session.accepts.maxBytes || zones > session.accepts.maxZones) {
        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.`);
    }
    const requester = displayRequester(session.requester);
    const question = `Results go to ${requester}. Upload ${formatBytes(loaded.bytes.byteLength)} to ${origin}?`;
    const flag = ctx.values.isYes ? '--yes' : '--confirm';
    const isConfirmed = Boolean(ctx.values.isYes || ctx.values.isConfirm);
    const bytes = loaded.bytes.byteLength;
    const confirmed = ctx.tui
        ? await ctx.tui.confirmUpload({ bytes, where: origin === PRODUCTION_ORIGIN ? new URL(origin).host : origin, requester, flag: isConfirmed ? flag : null })
        : await confirmUpload(promptIO(ctx), question, { isConfirmed, flag });
    if (!confirmed)
        throw new Error('Upload cancelled; nothing was sent. The snapshot file is kept.');
    const post = () => checkup.postSnapshot(loaded.bytes, loaded.sha256);
    const result = ctx.tui ? await ctx.tui.uploading(bytes, post) : await post();
    if (ctx.mode !== 'json') {
        const passed = numberAt(result.report, 'summary', 'passed');
        const warnings = numberAt(result.report, 'summary', 'warnings');
        const failedChecks = numberAt(result.report, 'summary', 'failed');
        const notCollected = (numberAt(result.provenance, 'coverage', 'reads', 'notCollected') ?? 0) +
            (numberAt(result.provenance, 'coverage', 'reads', 'collectionFailed') ?? 0);
        const parts = [
            passed !== undefined ? `${passed} passed` : null,
            warnings !== undefined ? `${warnings} warning${warnings === 1 ? '' : 's'}` : null,
            failedChecks !== undefined ? `${failedChecks} failed` : null,
            `${notCollected} reads not collected locally`,
        ].filter((part) => part !== null);
        const summary = `${result.replayed ? '✓ Already uploaded (same file); nothing changed.' : '✓ Uploaded.'} Free summary: ${parts.join(' · ')}`;
        const emailed = result.resultsUrl !== undefined ? `A link has also been emailed to ${requester}.` : `A link to the results has been emailed to ${requester}.`;
        if (ctx.tui) {
            const opening = result.resultsUrl !== undefined && openResults(ctx, result.resultsUrl, origin);
            ctx.tui.uploaded({
                replayed: result.replayed, notCollected, requester, opening,
                ...(passed !== undefined ? { passed } : {}),
                ...(warnings !== undefined ? { warnings } : {}),
                ...(failedChecks !== undefined ? { failed: failedChecks } : {}),
                ...(result.resultsUrl !== undefined ? { resultsUrl: result.resultsUrl } : {}),
            }, [summary, ...(result.resultsUrl !== undefined ? [`Results: ${result.resultsUrl}`] : []), emailed]);
        }
        else {
            ctx.writer.stdout(summary);
            if (result.resultsUrl !== undefined) {
                ctx.writer.stdout(`Results: ${result.resultsUrl}`);
                openResults(ctx, result.resultsUrl, origin);
            }
            ctx.writer.stdout(emailed);
        }
    }
    return {
        success: true,
        replayed: result.replayed,
        snapshotSha256: result.snapshotSha256,
        provenance: result.provenance,
        report: result.report,
        ...(result.resultsUrl !== undefined ? { resultsUrl: result.resultsUrl } : {}),
    };
}