diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 04dd0a08b..d393e204c 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -28,6 +28,7 @@ yarn void init ``` ```sh [bun] +# bunx runs Void with the `node` on your PATH: use Node.js 24.22+ or 26.12+ bun add -D void bunx void init ``` diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index a7c1e2d10..b97de8f11 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -174,7 +174,7 @@ Set production server values with `void secret put `. Void preserves exist ### Readiness and rollback -Void normally verifies an uploaded version before sending it traffic. If readiness or trigger updates fail, it restores the previous deployment. Finish or cancel any gradual rollout in Cloudflare before deploying through Void. +Void normally verifies an uploaded version before sending it traffic. If readiness or trigger updates fail, it restores the previous deployment. If trigger updates fail and Void cannot restore the previous version's triggers, for example because another tool deployed it or it is an empty draft, the new version stays live and the error says which version is serving traffic. Finish or cancel any gradual rollout in Cloudflare before deploying through Void. For an existing Durable Object Worker that Cloudflare cannot stage, review the changes and use: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8f4bea9ed..4488923f8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -82,6 +82,8 @@ Use this page as a command reference. If you are setting up a project for the fi The docs use `void` for brevity. Outside package scripts, run it with your package manager: `npx void`, `pnpm void`, `yarn void`, `bunx void`, or `vpx void`. +The CLI needs Node.js 24.22+ or 26.12+. `npx`, `bunx` and package scripts use the `node` on your `PATH`; `vpx` uses the version in `.node-version`. + Alternatively, you can add `./node_modules/.bin` to your `PATH` so that you can invoke `void` directly when you are in the root directory of your app. :::warning ⚠️ Prefer local install diff --git a/packages/create-void/src/index.ts b/packages/create-void/src/index.ts index 4aaefae26..c2b278665 100644 --- a/packages/create-void/src/index.ts +++ b/packages/create-void/src/index.ts @@ -1,86 +1,17 @@ #!/usr/bin/env node -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import * as p from '@voidzero-dev/vite-plus-prompts'; -import { resolve } from 'pathslash'; -import pc from 'picocolors'; -import { parseCreateArgs, validateProjectName } from './args.ts'; -import { detectPM, pmExec, pmInstall, run, writePnpmBuildPolicy } from './pm.ts'; - -const pm = detectPM(); - -async function main() { - console.log(); - - const createArgs = parseCreateArgs(process.argv.slice(2)); - let appName = createArgs.appName; - - if (!appName && !process.stdin.isTTY) { - p.log.error('create-void: Pass a project name when running without an interactive terminal.'); - process.exit(1); - } - - if (!appName) { - const name = await p.text({ - message: 'Project name:', - defaultValue: 'my-app', - placeholder: 'my-app', - validate: validateProjectName, - }); - if (p.isCancel(name) || typeof name !== 'string') { - p.cancel('Cancelled.'); - process.exit(0); - } - appName = name; - } - - const targetDir = resolve(appName); - - if (existsSync(targetDir)) { - const entries = readdirSync(targetDir); - if (entries.length > 0) { - p.log.error(`create-void: Directory '${pc.bold(appName)}' is not empty.`); - process.exit(1); - } - } - - // The release publishes the scaffolder and CLI at the same version. Preserve - // that exact version, including prereleases, instead of switching to latest. - const { version: voidVersion } = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), - ) as { version: string }; - - mkdirSync(targetDir, { recursive: true }); - - const pkg = { - name: appName, - private: true, - type: 'module', - devDependencies: { - void: voidVersion, - }, - }; - - writeFileSync(resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); - // pnpm 12 fails the first install when a dependency has an unapproved build - // script. Write Void's narrow policy before installing the CLI so a fresh - // project can bootstrap without disabling pnpm's build-script protection. - writePnpmBuildPolicy(targetDir, pm); - - p.log.step('Installing Void...'); - await run(pmInstall(pm), targetDir); - - p.log.step('Scaffolding starter...'); - await run([...pmExec(pm), 'void', 'init', ...createArgs.initArgs], targetDir); - - p.log.step('Installing starter dependencies...'); - await run(pmInstall(pm), targetDir); - - p.outro(`\n${pc.green(`✓ Created ${appName}`)}`, { withGuide: false }); - p.outro(`cd ${appName} && ${pm === 'npm' ? 'npm run' : pm} dev`); +// The bin entry. It imports only the dependency-free runtime check, so an +// unsupported Node or Bun gets a clear message instead of a link-time +// SyntaxError from a newer API in the bundled prompts. `void init` stops on an +// unsupported runtime too, so checking first keeps a failed run from leaving a +// half-created folder that blocks the next attempt. +import { unsupportedRuntimeMessage } from './runtime-support.ts'; + +const message = unsupportedRuntimeMessage(process.versions, process.execPath, 'create-void'); +if (message) { + process.stderr.write(`${message}\n`); + // exitCode instead of exit() so the message is flushed when stderr is a pipe. + process.exitCode = 1; +} else { + await import('./main.ts'); } - -main().catch((err) => { - p.log.error(err.message); - process.exit(1); -}); diff --git a/packages/create-void/src/main.ts b/packages/create-void/src/main.ts new file mode 100644 index 000000000..c5488404c --- /dev/null +++ b/packages/create-void/src/main.ts @@ -0,0 +1,84 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import * as p from '@voidzero-dev/vite-plus-prompts'; +import { resolve } from 'pathslash'; +import pc from 'picocolors'; +import { parseCreateArgs, validateProjectName } from './args.ts'; +import { detectPM, pmExec, pmInstall, run, writePnpmBuildPolicy } from './pm.ts'; + +const pm = detectPM(); + +async function main() { + console.log(); + + const createArgs = parseCreateArgs(process.argv.slice(2)); + let appName = createArgs.appName; + + if (!appName && !process.stdin.isTTY) { + p.log.error('create-void: Pass a project name when running without an interactive terminal.'); + process.exit(1); + } + + if (!appName) { + const name = await p.text({ + message: 'Project name:', + defaultValue: 'my-app', + placeholder: 'my-app', + validate: validateProjectName, + }); + if (p.isCancel(name) || typeof name !== 'string') { + p.cancel('Cancelled.'); + process.exit(0); + } + appName = name; + } + + const targetDir = resolve(appName); + + if (existsSync(targetDir)) { + const entries = readdirSync(targetDir); + if (entries.length > 0) { + p.log.error(`create-void: Directory '${pc.bold(appName)}' is not empty.`); + process.exit(1); + } + } + + // The release publishes the scaffolder and CLI at the same version. Preserve + // that exact version, including prereleases, instead of switching to latest. + const { version: voidVersion } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ) as { version: string }; + + mkdirSync(targetDir, { recursive: true }); + + const pkg = { + name: appName, + private: true, + type: 'module', + devDependencies: { + void: voidVersion, + }, + }; + + writeFileSync(resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + // pnpm 12 fails the first install when a dependency has an unapproved build + // script. Write Void's narrow policy before installing the CLI so a fresh + // project can bootstrap without disabling pnpm's build-script protection. + writePnpmBuildPolicy(targetDir, pm); + + p.log.step('Installing Void...'); + await run(pmInstall(pm), targetDir); + + p.log.step('Scaffolding starter...'); + await run([...pmExec(pm), 'void', 'init', ...createArgs.initArgs], targetDir); + + p.log.step('Installing starter dependencies...'); + await run(pmInstall(pm), targetDir); + + p.outro(`\n${pc.green(`✓ Created ${appName}`)}`, { withGuide: false }); + p.outro(`cd ${appName} && ${pm === 'npm' ? 'npm run' : pm} dev`); +} + +main().catch((err) => { + p.log.error(err.message); + process.exit(1); +}); diff --git a/packages/create-void/src/runtime-support.ts b/packages/create-void/src/runtime-support.ts new file mode 100644 index 000000000..9c5bb452e --- /dev/null +++ b/packages/create-void/src/runtime-support.ts @@ -0,0 +1,130 @@ +// Checks the runtime before the CLI loads. On an older Node, a static import of a +// newer API (such as `setGlobalProxyFromEnv` from `node:http`) fails at link time +// with a SyntaxError before any code can run, and child processes reuse +// `process.execPath`, so a deploy would stop halfway. This file must therefore +// load on any Node or Bun release: it has no imports and uses no new APIs. +// +// packages/void/src/cli/runtime-support.ts and packages/create-void/src/runtime-support.ts +// are byte-identical copies, because create-void runs before `void` is installed. +// A unit test keeps the two files and both `engines.node` fields equal. + +/** Must equal `engines.node` in packages/void and packages/create-void. */ +export const NODE_ENGINES = '^24.22.0 || >=26.12.0'; + +/** + * Bun is not a documented runtime, but `bunx --bun` and Bun-only images (where + * `node` is Bun) run the CLI on it. Bun reports a Node version below 26.12, so + * it gets its own floor: 1.4.0 is the first release where Void's Cloudflare + * helper process loads. This lets working setups keep working. + */ +export const BUN_MINIMUM = '1.4.0'; + +type Version = [major: number, minor: number, patch: number]; + +export type RuntimeVersions = { readonly node?: string; readonly bun?: string }; + +function isDigits(text: string): boolean { + if (text.length === 0) { + return false; + } + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } + } + return true; +} + +/** + * Parses `1.2.3`, with an optional leading `v`. A pre-release or build tag + * (`27.0.0-nightly2026...`, `1.4.0+abc`) is ignored, so a nightly counts as its + * release line. + */ +export function parseVersion(text: string): Version | null { + let core = text.trim(); + if (core.startsWith('v')) { + core = core.slice(1); + } + for (const separator of ['-', '+']) { + const index = core.indexOf(separator); + if (index !== -1) { + core = core.slice(0, index); + } + } + const parts = core.split('.'); + if (parts.length !== 3 || !parts.every(isDigits)) { + return null; + } + return [Number(parts[0]), Number(parts[1]), Number(parts[2])]; +} + +function compareVersions(a: Version, b: Version): number { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +type Clause = { caret: boolean; version: Version }; + +/** Supports only the forms used in `engines.node`: `^x.y.z` and `>=x.y.z`, joined by `||`. */ +function parseRange(range: string): Array { + return range.split('||').map((raw) => { + const clause = raw.trim(); + const caret = clause.startsWith('^'); + const prefix = caret ? '^' : clause.startsWith('>=') ? '>=' : null; + const version = prefix === null ? null : parseVersion(clause.slice(prefix.length)); + if (!version) { + throw new Error(`runtime-support: unsupported range clause '${clause}'`); + } + return { caret, version }; + }); +} + +export function satisfiesRange(version: Version, range: string): boolean { + return parseRange(range).some( + ({ caret, version: floor }) => + compareVersions(version, floor) >= 0 && (!caret || version[0] === floor[0]), + ); +} + +/** `^24.22.0 || >=26.12.0` -> `24.22+ or 26.12+`. */ +function describeRange(range: string): string { + return parseRange(range) + .map(({ version: [major, minor, patch] }) => + patch === 0 ? `${major}.${minor}+` : `${major}.${minor}.${patch}+`, + ) + .join(' or '); +} + +/** + * Returns the error to print when this runtime cannot run the CLI, or `null`. + * The text names only the runtime version and executable path, never env or argv. + */ +export function unsupportedRuntimeMessage( + versions: RuntimeVersions, + execPath: string, + command = 'void', +): string | null { + const nodeRange = describeRange(NODE_ENGINES); + if (versions.bun !== undefined) { + const bun = parseVersion(versions.bun); + const minimum = parseVersion(BUN_MINIMUM); + if (bun && minimum && compareVersions(bun, minimum) >= 0) { + return null; + } + return ( + `${command}: Bun ${BUN_MINIMUM} or later is required. Found Bun ${versions.bun} (${execPath}).\n` + + `Update Bun, or run ${command} on Node.js ${nodeRange}.` + ); + } + const node = versions.node === undefined ? null : parseVersion(versions.node); + if (node && satisfiesRange(node, NODE_ENGINES)) { + return null; + } + const found = versions.node === undefined ? 'an unknown version' : `v${versions.node}`; + return ( + `${command}: Node.js ${nodeRange} is required. Found Node.js ${found} (${execPath}).\n` + + 'bunx, npx and package scripts use the `node` on PATH; vp/vpx use .node-version.\n' + + 'Update .node-version or the node on PATH. In GitHub Actions add actions/setup-node\n' + + 'with `node-version: lts/*` and `check-latest: true` before this step.' + ); +} diff --git a/packages/create-void/test/unit/entry.test.ts b/packages/create-void/test/unit/entry.test.ts new file mode 100644 index 000000000..5db5ec347 --- /dev/null +++ b/packages/create-void/test/unit/entry.test.ts @@ -0,0 +1,56 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +const ENTRY = join(import.meta.dirname, '..', '..', 'src', 'index.ts'); + +let root: string; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'create-void-entry-')); +}); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('create-void entry', () => { + it('prints the runtime message before any other module links', () => { + // An old Node fails to link a module that imports a newer API (for example `styleText` + // from node:util in the bundled prompts). The hook makes every module other than the + // entry and the runtime check fail the same way, so the message must come first. + const preload = join(root, 'old-node.mjs'); + writeFileSync( + preload, + `import { registerHooks } from 'node:module'; +Object.defineProperty(process, 'versions', { value: { ...process.versions, node: '18.20.8' } }); +const allowed = [${JSON.stringify(pathToFileURL(ENTRY).href)}, ${JSON.stringify( + pathToFileURL(join(ENTRY, '..', 'runtime-support.ts')).href, + )}]; +registerHooks({ + resolve(specifier, context, next) { + const result = next(specifier, context); + if (!allowed.includes(result.url)) { + throw new SyntaxError('cannot link ' + result.url); + } + return result; + }, +}); +`, + ); + const result = spawnSync( + process.execPath, + ['--import', pathToFileURL(preload).href, ENTRY, 'my-app'], + { cwd: root, encoding: 'utf8', env: { PATH: process.env.PATH } }, + ); + + expect(result.status).toBe(1); + expect(result.stderr.split('\n')[0]).toBe( + `create-void: Node.js 24.22+ or 26.12+ is required. Found Node.js v18.20.8 (${process.execPath}).`, + ); + expect(readdirSync(root)).not.toContain('my-app'); + }); +}); diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 279acfd65..12c341f5f 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -15,7 +15,11 @@ import { type CloudflareCommandRunner, } from './execute.ts'; import type { CloudflareUploadResult } from './operations.ts'; -import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts'; +import { + isVoidSecretOnlyDraft, + isWranglerSecretOnlyDraft, + VOID_SECRET_DRAFT_SOURCE, +} from './secret-draft.ts'; const DURABLE_MIGRATION_VERSION_ERROR = 10211; const CLOUDFLARE_SECRET_CHANGED_ERROR = 10220; @@ -124,8 +128,26 @@ export type CloudflareVersionActivation = * with its earlier secret values. */ restoredOlderSecrets?: true; - /** Version that owned production after a concurrent operation superseded this command. */ + /** + * Version serving 100% of production when this command stopped, or null when Void could + * not read it. Set when a concurrent operation superseded this command, on every + * `triggers-failed` result, and after any automatic traffic restoration that read status. + */ activeVersionId?: string | null; + /** + * Set on `triggers-failed`. `yes`: Void re-applied the live version's own trigger snapshot. + * `no`: the live version runs with partly updated triggers. `unknown`: Void replayed a + * snapshot but could not confirm the outcome. + */ + triggersRestored?: 'yes' | 'no' | 'unknown'; + /** + * Set on `triggers-failed` when Void kept this deploy's version live instead of switching + * back. `placeholder`: the previous version is an empty placeholder or secret-only draft. + * `no-trigger-snapshot`: the previous version has no Void trigger snapshot to replay. + * `atomic`: the deploy published without a staged version, so Void does not switch back. + * Absent when there was no previous version or Void could not inspect it. + */ + restoreSkipped?: 'placeholder' | 'no-trigger-snapshot' | 'atomic'; /** Whether Void restored the active version's immutable trigger snapshot. */ triggerRepair?: 'succeeded' | 'failed' | 'unavailable'; /** @@ -351,6 +373,8 @@ export type CloudflareTriggerStabilization = reason: 'status-failed' | 'traffic-split' | 'metadata-unavailable' | 'triggers-failed'; status?: number; activeVersionId?: string | null; + /** Bounded trigger command error text, redacted like other uploader diagnostics. */ + diagnostic?: string; }>; /** @@ -421,6 +445,7 @@ export async function stabilizeCloudflareVersionTriggers(options: { reason: 'triggers-failed', status: triggers.status, activeVersionId, + ...commandDiagnosticFields(triggers), }; } @@ -1027,6 +1052,24 @@ async function activateCloudflareVersionWithSecretsFile( ); return previousVersion?.isSecretOnlyDraft === true; } + // Recovery only: true when the previous version is an empty Worker that must never get traffic + // back. Unlike `isVoidSecretOnlyDraft`, this also accepts Void's zero-binding placeholder, which + // the bootstrap path must not treat as first-deployment proof. + async function previousIsRestorePlaceholder(): Promise { + if (!previousVersionId) { + return false; + } + if (await previousIsSecretOnlyDraft()) { + return true; + } + if (!previousVersion || previousVersion.draftUnknown) { + return null; + } + if (previousVersion.message !== VOID_SECRET_DRAFT_MESSAGE) { + return false; + } + return await isVoidSecretDraftPlaceholderVersion(options, previousVersionId); + } const previousMetadata = desiredMetadata?.schema === 2 ? previousVersion?.metadata : null; const continuesOpaqueTriggerPreservation = !options.preserveTriggers && @@ -1143,6 +1186,7 @@ async function activateCloudflareVersionWithSecretsFile( previous.versionId, options.atomic === true, previousTriggerConfig, + previousIsRestorePlaceholder, ); } // The ordinary cf uploader refuses a non-interactive revision of an API-origin @@ -1239,6 +1283,7 @@ async function activateCloudflareVersionWithSecretsFile( previous.versionId, false, previousTriggerConfig, + previousIsRestorePlaceholder, ); } return { @@ -1338,6 +1383,7 @@ async function activateCloudflareVersionWithSecretsFile( previous.versionId, false, previousTriggerConfig, + previousIsRestorePlaceholder, ); } readinessUrl = desiredTriggerConfig.workers_dev @@ -1589,9 +1635,12 @@ async function activateCloudflareVersionWithSecretsFile( ok: false, reason: 'triggers-failed', status: triggers.status, + ...(triggers.diagnostic ? { diagnostic: triggers.diagnostic } : {}), versionId, previousVersionId: previous.versionId, + activeVersionId: null, rollback: 'unsafe', + triggersRestored: 'no', }; } const ownerBeforeCompensation = await readCurrentProductionVersion(options); @@ -1600,9 +1649,12 @@ async function activateCloudflareVersionWithSecretsFile( ok: false, reason: 'triggers-failed', status: triggers.status, + ...(triggers.diagnostic ? { diagnostic: triggers.diagnostic } : {}), versionId, previousVersionId: previous.versionId, + activeVersionId: null, rollback: 'unsafe', + triggersRestored: 'no', }; } if (ownerBeforeCompensation.versionId !== versionId) { @@ -1628,18 +1680,47 @@ async function activateCloudflareVersionWithSecretsFile( triggerRepair: triggerRepairOutcome(repair), }; } + // This deploy owns 100%. Switch back only when Void can restore the previous version's + // triggers too. An empty placeholder or a version without a Void trigger snapshot stays + // inactive: moving traffic to it would serve an empty Worker or code with this deploy's + // partly applied triggers. + const placeholder = await previousIsRestorePlaceholder(); + const previousSnapshot = + placeholder === false + ? (versionedPreviousTriggerConfigWithTraffic ?? + (previousVersion?.metadata?.schema === 2 + ? previousVersion.metadata.triggerConfig + : undefined)) + : undefined; + if (!previous.versionId || !previousSnapshot || previousSnapshot.preserve) { + return { + ok: false, + reason: 'triggers-failed', + status: triggers.status, + ...(triggers.diagnostic ? { diagnostic: triggers.diagnostic } : {}), + versionId, + previousVersionId: previous.versionId, + activeVersionId: versionId, + rollback: 'unavailable', + triggersRestored: 'no', + ...(previous.versionId && placeholder !== null + ? { restoreSkipped: placeholder ? 'placeholder' : 'no-trigger-snapshot' } + : {}), + }; + } const restore = await restorePreviousVersionAndTriggers( options, previous.versionId, 'trigger activation failed', desiredTriggerConfig, versionId, - versionedPreviousTriggerConfigWithTraffic, + previousSnapshot, ); return { ok: false, reason: 'triggers-failed', status: triggers.status, + ...(triggers.diagnostic ? { diagnostic: triggers.diagnostic } : {}), versionId, previousVersionId: previous.versionId, ...restore, @@ -1687,40 +1768,31 @@ async function restorePreviousVersionAndTriggers( | 'publishEmptyCustomDomains' | 'publishEmptyCrons' >, - previousVersionId: string | null, + previousVersionId: string, cause: string, failedTriggerConfig: CloudflareTriggerConfig, failedVersionId: string, - capturedTriggerConfig?: CloudflareTriggerConfig, + targetTriggerConfig: CloudflareTriggerConfig, ): Promise { const traffic = await restorePreviousVersion(options, previousVersionId, cause, [ [{ versionId: failedVersionId, percentage: 100 }], ]); - if (traffic.rollback !== 'succeeded' || !previousVersionId) { - return traffic; - } - if (!options.verifyUploader()) { - return { ...traffic, rollback: 'unsafe' }; + if (traffic.rollback !== 'succeeded') { + // Without a status read, the failed version still owns traffic: the caller saw it at 100% + // just before, and no restore command ran. + return { + ...traffic, + activeVersionId: + traffic.activeVersionId === undefined ? failedVersionId : traffic.activeVersionId, + triggersRestored: 'no', + }; } // Traffic attachments can change between uploads (for example `void domain - // add`). Restore the snapshot captured before this deployment, not the older - // immutable version annotation. Cleanup-only queue names are deliberately not - // part of this target snapshot. - let targetTriggerConfig = capturedTriggerConfig; - if (!targetTriggerConfig) { - const metadata = await readCloudflareVersionMetadata(options, previousVersionId); - if (metadata?.schema !== 2) { - return { ...traffic, rollback: 'unavailable' }; - } - targetTriggerConfig = metadata.triggerConfig; - } - // A preserve marker leaves routes, schedules, queue consumers and workflows opaque, so there - // is no trigger snapshot to restore. Traffic is already back on the previous version. - if (targetTriggerConfig.preserve) { - return { ...traffic, rollback: 'unavailable' }; - } + // add`), so the caller passes the snapshot captured before this deployment, + // not the older immutable version annotation. Cleanup-only queue names are + // deliberately not part of this target snapshot. if (!options.verifyUploader()) { - return { ...traffic, rollback: 'unsafe' }; + return { ...traffic, rollback: 'unsafe', triggersRestored: 'no' }; } const triggers = await stabilizeCloudflareVersionTriggers({ runWrangler: options.runWrangler, @@ -1733,9 +1805,21 @@ async function restorePreviousVersionAndTriggers( publishEmptyCustomDomains: options.publishEmptyCustomDomains, publishEmptyCrons: options.publishEmptyCrons, }); + if (triggers.ok) { + return { + ...traffic, + rollback: triggers.targetOwnsTraffic ? 'succeeded' : 'failed', + activeVersionId: triggers.activeVersionId, + triggersRestored: 'yes', + }; + } + // A status read or traffic split during the replay leaves both owner and triggers unknown. + const ownerUnknown = triggers.reason === 'status-failed' || triggers.reason === 'traffic-split'; return { ...traffic, - rollback: triggers.ok && triggers.targetOwnsTraffic ? 'succeeded' : 'failed', + rollback: 'failed', + activeVersionId: ownerUnknown ? null : (triggers.activeVersionId ?? null), + triggersRestored: ownerUnknown ? 'unknown' : 'no', }; } @@ -1751,9 +1835,13 @@ async function readCloudflareVersionMetadata( type CloudflareVersionInspection = Readonly<{ metadata: CloudflareVersionMetadata | null; + message: string | null; isSecretOnlyDraft: boolean; isVoidSecretOnlyDraft: boolean; voidDraftSecretNames: ReadonlyArray | null; + // A Wrangler `secret put` version whose resources could not be read, so it may still be an + // empty draft. Recovery must not offer traffic back to it. + draftUnknown: boolean; }>; async function readCloudflareVersionInspection( @@ -1790,11 +1878,13 @@ async function readCloudflareVersionInspection( // The list endpoint omits resources. Inspect the exact active version before deciding // whether secret provisioning left a placeholder rather than an existing application. let draft = entry; + let draftUnknown = false; if ( annotations?.['workers/triggered_by'] === 'secret' && Object.keys(annotations).length === 1 && !isWranglerSecretOnlyDraft(entry) ) { + draftUnknown = true; const inspection = await options.runWrangler( { type: 'versions.view', @@ -1809,17 +1899,20 @@ async function readCloudflareVersionInspection( const inspected: unknown = inspection.data; if (isRecord(inspected) && inspected.id === versionId) { draft = inspected; + draftUnknown = false; } } } const isVoidDraft = isVoidSecretOnlyDraft(draft); return { metadata: typeof message === 'string' ? parseCloudflareVersionMessage(message) : null, + message: typeof message === 'string' ? message : null, isSecretOnlyDraft: isWranglerSecretOnlyDraft(draft) || isVoidDraft, isVoidSecretOnlyDraft: isVoidDraft, voidDraftSecretNames: isVoidDraft ? (draft.bindings as Array<{ name: string }>).map((binding) => binding.name) : null, + draftUnknown, }; } } catch { @@ -1828,6 +1921,62 @@ async function readCloudflareVersionInspection( return null; } +// Message Void's secret transport gives the zero-binding placeholder it deploys when a Worker +// does not exist yet (packages/void cloudflare-operation-process.ts). +const VOID_SECRET_DRAFT_MESSAGE = 'void:secret-draft'; + +/** + * Recovery-only proof that a version is Void's secret placeholder: its marker plus the exact + * placeholder source, with no binding other than secrets. It may have zero bindings, so it is + * deliberately not `isVoidSecretOnlyDraft`, which bootstrap uses as first-deployment proof. + * Returns null when the version could not be read. + */ +async function isVoidSecretDraftPlaceholderVersion( + options: Pick< + Parameters[0], + 'configPath' | 'runWrangler' | 'workerName' + >, + versionId: string, +): Promise { + try { + const view = await options.runWrangler( + { + type: 'versions.view', + versionId, + configPath: options.configPath, + workerName: options.workerName, + includeSource: true, + }, + { capture: true }, + ); + // null: the version could not be read, so it may still be the placeholder. + if (!isCleanCommandExit(view)) { + return null; + } + const entry: unknown = view.data; + if ( + !isRecord(entry) || + entry.id !== versionId || + !isRecord(entry.annotations) || + entry.annotations['workers/message'] !== VOID_SECRET_DRAFT_MESSAGE || + !Array.isArray(entry.modules) || + entry.modules.length !== 1 || + !Array.isArray(entry.bindings) + ) { + return false; + } + const module: unknown = entry.modules[0]; + return ( + isRecord(module) && + module.name === entry.main_module && + module.content_base64 === Buffer.from(VOID_SECRET_DRAFT_SOURCE).toString('base64') && + entry.bindings.every((binding) => isRecord(binding) && binding.type === 'secret_text') + ); + } catch { + return null; + } +} + async function readRecentCloudflareVersionIds( options: Pick< Parameters[0], @@ -1947,6 +2096,7 @@ async function bootstrapFirstDeployment( previousVersionId: string | null, atomic = false, previousTriggerConfig?: CloudflareTriggerConfig, + previousIsRestorePlaceholder?: () => Promise, ): Promise { if (!options.verifyUploader()) { return { ok: false, reason: 'uploader-changed' }; @@ -2180,19 +2330,43 @@ async function bootstrapFirstDeployment( triggers.activeVersionId !== undefined && triggers.activeVersionId !== null && triggers.activeVersionId !== versionId; + if (superseded) { + return { + ok: false, + reason: 'deployment-changed', + ...(!triggers.ok ? { status: triggers.status } : {}), + versionId, + previousVersionId, + activeVersionId: triggers.activeVersionId, + rollback: 'not-needed', + triggerRepair: triggerRepairOutcome(triggers), + }; + } + // A non-versioned publish has no staged predecessor to switch back to, so this version + // stays live with whatever triggers were applied. + const placeholder = + previousVersionId && triggers.activeVersionId === versionId + ? await previousIsRestorePlaceholder?.() + : null; return { ok: false, - reason: superseded ? 'deployment-changed' : 'triggers-failed', - ...(!triggers.ok ? { status: triggers.status } : {}), - versionId, - previousVersionId, - activeVersionId: triggers.activeVersionId, - rollback: superseded ? 'not-needed' : triggers.activeVersionId ? 'unavailable' : 'unsafe', - ...(superseded + reason: 'triggers-failed', + ...(!triggers.ok ? { - triggerRepair: triggerRepairOutcome(triggers), + status: triggers.status, + ...(triggers.diagnostic ? { diagnostic: triggers.diagnostic } : {}), } : {}), + versionId, + previousVersionId, + activeVersionId: triggers.activeVersionId ?? null, + rollback: triggers.activeVersionId ? 'unavailable' : 'unsafe', + triggersRestored: 'no', + ...(placeholder === true + ? { restoreSkipped: 'placeholder' } + : placeholder === false + ? { restoreSkipped: 'atomic' } + : {}), }; } const previewUrl = publishedCloudflareUrl( @@ -2763,6 +2937,12 @@ export async function deployCloudflareVersionTraffic(options: { type PreviousVersionRestore = Readonly<{ rollback: 'succeeded' | 'failed' | 'unavailable' | 'unsafe'; restoredOlderSecrets?: true; + /** + * Version serving 100% after the attempt, from the restore command or the last status read; + * null when that read failed or found no single owner. Absent when nothing was read. + */ + activeVersionId?: string | null; + triggersRestored?: 'yes' | 'no' | 'unknown'; }>; async function restorePreviousVersion( @@ -2780,17 +2960,20 @@ async function restorePreviousVersion( if (!options.verifyUploader()) { return { rollback: 'unsafe' }; } - const ownsExpectedTraffic = async () => { + const readOwner = async () => { const owner = await readCurrentProductionVersion(options); - return ( - owner.kind === 'ok' && - expectedOwnedTraffic.some((expected) => - productionVersionPercentagesEqual(owner.versions, expected), - ) - ); + return { + owns: + owner.kind === 'ok' && + expectedOwnedTraffic.some((expected) => + productionVersionPercentagesEqual(owner.versions, expected), + ), + activeVersionId: owner.kind === 'ok' ? owner.versionId : null, + }; }; - if (!(await ownsExpectedTraffic())) { - return { rollback: 'unsafe' }; + let owner = await readOwner(); + if (!owner.owns) { + return { rollback: 'unsafe', activeVersionId: owner.activeVersionId }; } const restore = (bypassDeploymentChecks: boolean) => deployVersionPercentages( @@ -2798,20 +2981,30 @@ async function restorePreviousVersion( [`${previousVersionId}@100`], `Void automatic rollback after ${cause}.`, ); + // A restore that did not exit cleanly may still have moved traffic (a killed child, or a lost + // response after Cloudflare applied it), so read the owner again instead of reusing the + // earlier read. A failed read reports the owner as unknown. + const failed = async (): Promise => ({ + rollback: 'failed', + activeVersionId: (await readOwner()).activeVersionId, + }); const result = await restore(false); // Cloudflare refuses to move traffic back to a version older than a secret // change (10220). When the failed version made that change, force the // restore, as Wrangler's rollback does. Check again first that Void still // owns traffic, so a deployment made by someone else is not overwritten. if (result.errorCode === CLOUDFLARE_SECRET_CHANGED_ERROR) { - if (!(await ownsExpectedTraffic())) { - return { rollback: 'unsafe' }; + owner = await readOwner(); + if (!owner.owns) { + return { rollback: 'unsafe', activeVersionId: owner.activeVersionId }; } return isCleanCommandExit(await restore(true)) - ? { rollback: 'succeeded', restoredOlderSecrets: true } - : { rollback: 'failed' }; + ? { rollback: 'succeeded', restoredOlderSecrets: true, activeVersionId: previousVersionId } + : await failed(); } - return { rollback: isCleanCommandExit(result) ? 'succeeded' : 'failed' }; + return isCleanCommandExit(result) + ? { rollback: 'succeeded', activeVersionId: previousVersionId } + : await failed(); } function workersDevReadinessUrl(workerName: string, value: string | undefined): string | undefined { diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 36e3a2b42..127645514 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -315,6 +315,155 @@ function runner(overrides?: { }); } +const TRIGGER_FAILURE: CloudflareCommandResult = { + stdout: '', + stderr: 'Queue "jobs" already has a consumer. [code: 11004]', + status: 1, +}; + +/** Placeholder Void's secret transport deploys before a Worker exists: no bindings yet. */ +function voidZeroBindingPlaceholder(): Record { + return { + ...voidSecretDraftVersion(), + number: 1, + annotations: { 'workers/message': 'void:secret-draft', 'workers/triggered_by': 'deployment' }, + bindings: [], + env: {}, + }; +} + +/** + * Typed fake that tracks production traffic, so recovery tests assert which version is live at + * the end instead of matching a substring of some versions.deploy call. + */ +function recoveryActivation(setup: { + previous: Record; + previousSource?: Record; + triggers?: Array; + /** `applied` moves traffic even though the command result is a failure. */ + deploys?: Array; + statusFailsAfter?: 'first-trigger-failure' | 'restore'; + verifyUploaderAfterRestore?: boolean; + atomic?: boolean; +}) { + let traffic: Array<{ version_id: string; percentage: number }> = [ + { version_id: CURRENT, percentage: 100 }, + ]; + let uploaded = false; + let statusFails = false; + let restored = false; + const deploys: Array> = []; + let triggerCalls = 0; + const runWrangler = vi.fn( + async (operation: CloudflareOperation): Promise => { + switch (operation.type) { + case 'deployments.status': + return statusFails + ? { stdout: '', stderr: 'Cloudflare API unavailable.', status: 1 } + : { ...ok(), data: { versions: traffic } }; + case 'versions.list': + return { + ...ok(), + data: [ + setup.previous, + ...(uploaded + ? [{ id: NEXT, number: 2, annotations: { 'workers/message': 'next' } }] + : []), + ], + }; + case 'versions.view': + if (operation.includeSource && setup.previousSource) { + return { ...ok(), data: setup.previousSource }; + } + return { stdout: '', stderr: 'Version view is unavailable.', status: 1 }; + case 'versions.upload': + uploaded = true; + return { + ...ok(), + data: { + versionId: NEXT, + previewUrl: `https://${NEXT.slice(0, 8)}-app.example.workers.dev`, + }, + }; + case 'worker.deploy': + uploaded = true; + traffic = [{ version_id: NEXT, percentage: 100 }]; + return { + ...ok(), + data: { versionId: NEXT, workersDevUrl: 'https://app.example.workers.dev' }, + }; + case 'versions.deploy': { + const versions = operation.versions.map( + ({ versionId, percentage }) => `${versionId}@${percentage}`, + ); + const planned: CloudflareCommandResult & { applied?: true } = + setup.deploys?.[deploys.length] ?? ok(); + const { applied, ...result } = planned; + deploys.push(versions); + if (result.status === 0 || applied) { + traffic = operation.versions.map(({ versionId, percentage }) => ({ + version_id: versionId, + percentage, + })); + if (versions.length === 1 && versions[0] === `${CURRENT}@100`) { + restored = true; + statusFails ||= setup.statusFailsAfter === 'restore'; + } + } + return result; + } + case 'triggers.deploy': { + const result = setup.triggers?.[triggerCalls] ?? ok(); + triggerCalls++; + statusFails ||= setup.statusFailsAfter === 'first-trigger-failure' && result.status !== 0; + return result; + } + default: + throw new Error(`Unexpected recovery operation: ${operation.type}`); + } + }, + ); + return { + deploys, + owner: () => traffic, + triggerCalls: () => triggerCalls, + options: { + runWrangler, + workerName: 'app', + message: encodeCloudflareVersionMessage([], triggersWith({ crons: ['*/5 * * * *'] })), + verifyUploader: () => !(restored && setup.verifyUploaderAfterRestore === false), + fetch: async () => Response.json({ ready: true }), + readinessAttempts: 1, + ...(setup.atomic ? { atomic: true } : {}), + }, + }; +} + +const VOID_SCHEMA_2_PREVIOUS = { + id: CURRENT, + number: 1, + annotations: { + 'workers/message': encodeCloudflareVersionMessage([], triggersWith({ crons: ['0 * * * *'] })), + }, +}; + +/** Every versions.deploy request the argv runner received, as version@percentage lists. */ +function deployRequests(runWrangler: ReturnType): Array> { + return runWrangler.mock.calls + .filter(([args]) => args[0] === 'versions' && args[1] === 'deploy') + .map(([args]) => { + // Version targets come right after `versions deploy`, before the first flag. + const end = args.findIndex((argument, index) => index > 1 && argument.startsWith('-')); + return args.slice(2, end < 0 ? undefined : end); + }); +} + +/** Production traffic the argv runner holds after the activation finished. */ +async function productionTraffic(runWrangler: ReturnType): Promise { + const status = await runWrangler({ type: 'deployments.status', workerName: 'my-worker' }); + return (status.data as { versions: unknown }).versions; +} + describe('Cloudflare version activation', () => { it('pins ordinary revision secrets to the inspected active UUID before upload and readiness', async () => { const fixture = sdkDraftActivation(); @@ -3187,12 +3336,13 @@ describe('Cloudflare version activation', () => { reason: 'triggers-failed', rollback: 'succeeded', previousVersionId: CURRENT, + activeVersionId: CURRENT, + triggersRestored: 'yes', }); - expect( - runWrangler.mock.calls.some(([args]) => - args.some((argument) => argument === `${CURRENT}@100`), - ), - ).toBe(true); + expect(deployRequests(runWrangler)).toEqual([[`${NEXT}@100`], [`${CURRENT}@100`]]); + expect(await productionTraffic(runWrangler)).toEqual([ + { version_id: CURRENT, percentage: 100 }, + ]); expect(runWrangler.mock.calls.filter(([args]) => args[0] === 'triggers')).toHaveLength(2); }); @@ -3263,7 +3413,7 @@ describe('Cloudflare version activation', () => { expect(publications[1].queues).toEqual({ consumers: [] }); }); - it('does not claim opaque trigger recovery succeeded after restoring live traffic', async () => { + it('keeps the new version live when the previous version has only opaque triggers', async () => { const opaque = triggersWith({ preserve: true, preserveRoutes: true, @@ -3308,16 +3458,17 @@ describe('Cloudflare version activation', () => { reason: 'triggers-failed', rollback: 'unavailable', previousVersionId: CURRENT, + activeVersionId: NEXT, + triggersRestored: 'no', + restoreSkipped: 'no-trigger-snapshot', }); - expect( - runWrangler.mock.calls.some(([args]) => - args.some((argument) => argument === `${CURRENT}@100`), - ), - ).toBe(true); + // Readiness staging, then activation; no switch back to the opaque version. + expect(deployRequests(runWrangler)).toEqual([[`${CURRENT}@100`, `${NEXT}@0`], [`${NEXT}@100`]]); + expect(await productionTraffic(runWrangler)).toEqual([{ version_id: NEXT, percentage: 100 }]); expect(runWrangler.mock.calls.filter(([args]) => args[0] === 'triggers')).toHaveLength(1); }); - it('does not claim trigger rollback succeeded without prior trigger metadata', async () => { + it('keeps the new version live when the previous version has no trigger snapshot', async () => { const runWrangler = runner({ triggers: { ...ok(), status: 1 }, versions: ok( @@ -3342,7 +3493,12 @@ describe('Cloudflare version activation', () => { ok: false, reason: 'triggers-failed', rollback: 'unavailable', + activeVersionId: NEXT, + triggersRestored: 'no', + restoreSkipped: 'no-trigger-snapshot', }); + expect(deployRequests(runWrangler)).toEqual([[`${NEXT}@100`]]); + expect(await productionTraffic(runWrangler)).toEqual([{ version_id: NEXT, percentage: 100 }]); expect(runWrangler.mock.calls.filter(([args]) => args[0] === 'triggers')).toHaveLength(1); }); @@ -4515,3 +4671,287 @@ describe('Cloudflare version activation', () => { }, ); }); + +describe('Cloudflare trigger synchronization recovery', () => { + const keptNew = { + ok: false, + reason: 'triggers-failed', + versionId: NEXT, + previousVersionId: CURRENT, + activeVersionId: NEXT, + rollback: 'unavailable', + triggersRestored: 'no', + }; + + it('switches back to a Void version with a trigger snapshot and replays its triggers', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + ok: false, + reason: 'triggers-failed', + activeVersionId: CURRENT, + rollback: 'succeeded', + triggersRestored: 'yes', + }); + expect(result).not.toHaveProperty('restoreSkipped'); + expect(fixture.deploys).toEqual([[`${NEXT}@100`], [`${CURRENT}@100`]]); + expect(fixture.owner()).toEqual([{ version_id: CURRENT, percentage: 100 }]); + expect(fixture.triggerCalls()).toBe(2); + }); + + it.each([ + { + kind: 'a version deployed without Void', + previous: { id: CURRENT, number: 1, annotations: { 'workers/triggered_by': 'upload' } }, + restoreSkipped: 'no-trigger-snapshot', + }, + { + kind: 'a Wrangler secret-only draft', + previous: { ...secretOnlyDraftVersion(), number: 1 }, + restoreSkipped: 'placeholder', + }, + { + kind: "Void's zero-binding secret placeholder", + previous: { + id: CURRENT, + number: 1, + annotations: { + 'workers/message': 'void:secret-draft', + 'workers/triggered_by': 'deployment', + }, + }, + previousSource: voidZeroBindingPlaceholder(), + restoreSkipped: 'placeholder', + }, + { + kind: 'a version that only reuses the placeholder marker', + previous: { + id: CURRENT, + number: 1, + annotations: { + 'workers/message': 'void:secret-draft', + 'workers/triggered_by': 'deployment', + }, + }, + previousSource: { + ...voidZeroBindingPlaceholder(), + modules: [ + { + name: 'index.js', + content_type: 'application/javascript+module', + content_base64: Buffer.from('export default { fetch() { return app(); } };\n').toString( + 'base64', + ), + }, + ], + }, + restoreSkipped: 'no-trigger-snapshot', + }, + ])('keeps this deploy live when the previous version is $kind', async (setup) => { + const fixture = recoveryActivation({ ...setup, triggers: [TRIGGER_FAILURE] }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toEqual({ + ...keptNew, + status: 1, + diagnostic: 'Queue "jobs" already has a consumer. [code: 11004]', + restoreSkipped: setup.restoreSkipped, + }); + expect(fixture.deploys).toEqual([[`${NEXT}@100`]]); + expect(fixture.owner()).toEqual([{ version_id: NEXT, percentage: 100 }]); + expect(fixture.triggerCalls()).toBe(1); + }); + + it.each( + [ + { + kind: 'Void placeholder marker', + annotations: { + 'workers/message': 'void:secret-draft', + 'workers/triggered_by': 'deployment', + }, + }, + { kind: 'Wrangler secret version', annotations: { 'workers/triggered_by': 'secret' } }, + ].flatMap((setup) => [false, true].map((atomic) => ({ ...setup, atomic }))), + )( + 'does not offer a rollback to a $kind it could not read (atomic: $atomic)', + async ({ annotations, atomic }) => { + // No previousSource: the fixture's `versions.view` fails. + const fixture = recoveryActivation({ + previous: { id: CURRENT, number: 1, annotations }, + triggers: [TRIGGER_FAILURE], + atomic, + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ ...keptNew, rollback: 'unavailable' }); + expect(result).not.toHaveProperty('restoreSkipped'); + expect(fixture.owner()).toEqual([{ version_id: NEXT, percentage: 100 }]); + }, + ); + + it("keeps this deploy live over Void's secret draft through the bootstrap path", async () => { + const fixture = sdkDraftActivation(); + const original = fixture.runWrangler.getMockImplementation()!; + fixture.runWrangler.mockImplementation(async (operation) => + operation.type === 'triggers.deploy' ? TRIGGER_FAILURE : original(operation), + ); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + ...keptNew, + diagnostic: 'Queue "jobs" already has a consumer. [code: 11004]', + restoreSkipped: 'placeholder', + }); + expect(fixture.operations.filter((op) => op.type === 'versions.deploy')).toEqual([]); + expect( + await fixture.runWrangler({ type: 'deployments.status', workerName: 'app' }), + ).toMatchObject({ + data: { versions: [{ version_id: NEXT, percentage: 100 }] }, + }); + }); + + it('keeps an atomic publish live when its triggers fail', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + atomic: true, + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + ...keptNew, + diagnostic: 'Queue "jobs" already has a consumer. [code: 11004]', + restoreSkipped: 'atomic', + }); + expect(fixture.deploys).toEqual([]); + expect(fixture.owner()).toEqual([{ version_id: NEXT, percentage: 100 }]); + expect(fixture.triggerCalls()).toBe(1); + }); + + it('reports this deploy live when the switch back is refused', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + deploys: [ok(), { stdout: '', stderr: 'Cloudflare refused the deployment.', status: 1 }], + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + reason: 'triggers-failed', + activeVersionId: NEXT, + rollback: 'failed', + triggersRestored: 'no', + }); + expect(fixture.deploys).toEqual([[`${NEXT}@100`], [`${CURRENT}@100`]]); + expect(fixture.owner()).toEqual([{ version_id: NEXT, percentage: 100 }]); + expect(fixture.triggerCalls()).toBe(1); + }); + + it('reads the owner again when the switch back was applied but its command was killed', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + deploys: [ok(), { stdout: '', stderr: '', status: 1, signal: 'SIGTERM', applied: true }], + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + reason: 'triggers-failed', + activeVersionId: CURRENT, + rollback: 'failed', + triggersRestored: 'no', + }); + expect(fixture.owner()).toEqual([{ version_id: CURRENT, percentage: 100 }]); + expect(fixture.triggerCalls()).toBe(1); + }); + + it('reads the owner again after a forced switch back fails', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + deploys: [ + ok(), + { stdout: '', stderr: 'Secrets changed. [code: 10220]', status: 1, errorCode: 10220 }, + { stdout: '', stderr: '', status: 1, error: 'socket hang up', applied: true }, + ], + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + reason: 'triggers-failed', + activeVersionId: CURRENT, + rollback: 'failed', + triggersRestored: 'no', + }); + expect(fixture.deploys).toEqual([[`${NEXT}@100`], [`${CURRENT}@100`], [`${CURRENT}@100`]]); + expect(fixture.owner()).toEqual([{ version_id: CURRENT, percentage: 100 }]); + }); + + it('reports the previous version live when the uploader changes after the switch back', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + verifyUploaderAfterRestore: false, + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + reason: 'triggers-failed', + activeVersionId: CURRENT, + rollback: 'unsafe', + triggersRestored: 'no', + }); + expect(fixture.deploys).toEqual([[`${NEXT}@100`], [`${CURRENT}@100`]]); + expect(fixture.owner()).toEqual([{ version_id: CURRENT, percentage: 100 }]); + expect(fixture.triggerCalls()).toBe(1); + }); + + it('reports the previous version live without its triggers when the replay fails', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE, TRIGGER_FAILURE], + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + reason: 'triggers-failed', + activeVersionId: CURRENT, + rollback: 'failed', + triggersRestored: 'no', + }); + expect(fixture.deploys).toEqual([[`${NEXT}@100`], [`${CURRENT}@100`]]); + expect(fixture.owner()).toEqual([{ version_id: CURRENT, percentage: 100 }]); + expect(fixture.triggerCalls()).toBe(2); + }); + + it('reports an unknown owner when status cannot be read after the trigger failure', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + statusFailsAfter: 'first-trigger-failure', + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + reason: 'triggers-failed', + activeVersionId: null, + rollback: 'unsafe', + triggersRestored: 'no', + diagnostic: 'Queue "jobs" already has a consumer. [code: 11004]', + }); + expect(result).not.toHaveProperty('restoreSkipped'); + expect(fixture.deploys).toEqual([[`${NEXT}@100`]]); + expect(fixture.owner()).toEqual([{ version_id: NEXT, percentage: 100 }]); + }); + + it('reports an unknown owner and trigger state when status fails during the replay', async () => { + const fixture = recoveryActivation({ + previous: VOID_SCHEMA_2_PREVIOUS, + triggers: [TRIGGER_FAILURE], + statusFailsAfter: 'restore', + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + reason: 'triggers-failed', + activeVersionId: null, + rollback: 'failed', + triggersRestored: 'unknown', + }); + expect(fixture.deploys).toEqual([[`${NEXT}@100`], [`${CURRENT}@100`]]); + expect(fixture.triggerCalls()).toBe(1); + }); +}); diff --git a/packages/void/src/cli/cf-config-migrate.ts b/packages/void/src/cli/cf-config-migrate.ts index 933663bf7..14403bec9 100644 --- a/packages/void/src/cli/cf-config-migrate.ts +++ b/packages/void/src/cli/cf-config-migrate.ts @@ -65,10 +65,12 @@ function migrationInput(raw: JsonObject): JsonObject { function applyPreservationFlags(raw: JsonObject, worker: JsonObject): void { // cf forwards unsafe.metadata to the Worker upload metadata, including these API flags. - const keepBindings = [ - ...(raw.keep_vars === true ? ['plain_text', 'json'] : []), - ...(raw.keep_secrets === true ? ['secret_text', 'secret_key'] : []), - ]; + // A written keep_bindings replaces cf's default, which keeps secrets. Wrangler always adds the + // secret types next to keep_vars, so keep_vars alone must not drop secrets either. + const keepBindings = + raw.keep_vars === true || raw.keep_secrets === true + ? [...(raw.keep_vars === true ? ['plain_text', 'json'] : []), 'secret_text', 'secret_key'] + : []; if (keepBindings.length === 0 && raw.keep_assets !== true) { return; } diff --git a/packages/void/src/cli/cf-version-runner.ts b/packages/void/src/cli/cf-version-runner.ts index 305e965aa..bf1fb35b3 100644 --- a/packages/void/src/cli/cf-version-runner.ts +++ b/packages/void/src/cli/cf-version-runner.ts @@ -3,11 +3,12 @@ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:f import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { getWorkerConfigPath, readBuildOutput } from '@cloudflare/build-output-utils'; -import type { - CloudflareCommandResult, - CloudflareCommandRunner, - CloudflareOperation, - CloudflareVersionActivationOptions, +import { + parseCloudflareVersionMessage, + type CloudflareCommandResult, + type CloudflareCommandRunner, + type CloudflareOperation, + type CloudflareVersionActivationOptions, } from '@void/deploy-cloudflare'; import { isRecord as record } from '@void/deploy-core'; import { dirname, join } from 'pathslash'; @@ -26,11 +27,41 @@ import { } from './cf-version-api.ts'; import { publishCloudflareWorkerSettings } from './cf-worker-settings.ts'; import { isCleanCloudflareExit } from './cloudflare-process.ts'; +import type { WranglerAuthTokenResult } from './deploy-cloudflare-auth.ts'; function failed(message: string): CloudflareCommandResult { return { status: 1, stdout: '', stderr: message }; } +/** Keep the credential child's real status, signal and diagnostic under one header. */ +function credentialFailed( + header: string, + failure: CloudflareCommandResult, +): CloudflareCommandResult { + return { + status: failure.status, + ...(failure.signal ? { signal: failure.signal } : {}), + stdout: '', + stderr: `${header}: ${failure.stderr}`, + }; +} + +/** + * Whether activating a version with this message publishes Worker settings (observability or + * logpush) after traffic moves. The schema-2 snapshot is what trigger sync replays; a preserved + * snapshot publishes nothing. + */ +function publishesWorkerSettings(message: string | undefined): boolean { + const metadata = parseCloudflareVersionMessage(message); + if (metadata?.schema !== 2 || metadata.triggerConfig.preserve === true) { + return false; + } + return ( + metadata.triggerConfig.observability !== undefined || + metadata.triggerConfig.logpush !== undefined + ); +} + function parseJson(value: string): unknown { try { return JSON.parse(value) as unknown; @@ -373,7 +404,7 @@ export function createCfVersionRunner(options: { pinnedUpload?: { accountId: string; apiBaseUrl: string; - getToken: () => Promise; + getToken: () => Promise; verifyUploader: () => boolean; }; }): CloudflareCommandRunner { @@ -401,6 +432,63 @@ export function createCfVersionRunner(options: { : undefined, }; }; + // Session bearer read before upload, so a deploy that cannot get one (Global API Key session, + // broken helper child) stops while traffic is unchanged. The pinned upload reuses it. The + // settings publisher, which runs after traffic moves, reads again so an OAuth token that + // expired during the deploy is refreshed, and falls back to this one if that read fails. + let sessionToken: string | undefined; + const readSessionToken = async ( + credentials: NonNullable, + ): Promise => { + if (sessionToken !== undefined) { + return { ok: true, token: sessionToken }; + } + const credential = await credentials.getToken(); + if (credential.ok) { + sessionToken = credential.token; + } + return credential; + }; + const readFreshSessionToken = async ( + credentials: NonNullable, + ): Promise => { + let credential: WranglerAuthTokenResult; + try { + credential = await credentials.getToken(); + } catch (cause) { + if (sessionToken === undefined) { + throw cause; + } + return { ok: true, token: sessionToken }; + } + if (credential.ok) { + sessionToken = credential.token; + return credential; + } + return sessionToken === undefined ? credential : { ok: true, token: sessionToken }; + }; + /** + * Read the settings publisher's credential before upload, so a deploy that cannot publish its + * observability or logpush settings stops while production traffic is unchanged. + */ + const prepareSettingsCredential = async ( + message: string | undefined, + ): Promise => { + if (!publishesWorkerSettings(message)) { + return null; + } + const credentials = options.pinnedUpload; + if (!credentials || !credentials.verifyUploader()) { + return failed('The Cloudflare settings publisher is unavailable; no version was uploaded.'); + } + const credential = await readSessionToken(credentials); + return credential.ok + ? null + : credentialFailed( + 'Could not get the Cloudflare session credential for publishing Worker settings (observability, logpush), so no version was uploaded', + credential.failure, + ); + }; const verifyResources = async (directory: string) => { try { return await options.verifyResources(directory); @@ -431,10 +519,14 @@ export function createCfVersionRunner(options: { if (!options.pinnedUpload || !options.pinnedUpload.verifyUploader()) { return failed('The version-aware Cloudflare uploader is unavailable.'); } - const token = await options.pinnedUpload.getToken(); - if (!token) { - return failed('Cloudflare session has no bearer for version-aware upload.'); + const credential = await readSessionToken(options.pinnedUpload); + if (!credential.ok) { + return credentialFailed( + 'Could not get the Cloudflare session credential for the upload', + credential.failure, + ); } + const { token } = credential; try { const version = await uploadPinnedCloudflareVersion({ output, @@ -611,6 +703,10 @@ export function createCfVersionRunner(options: { if (blocked) { return blocked; } + const settings = await prepareSettingsCredential(operation.message); + if (settings) { + return settings; + } if (output.activeSecrets) { return uploadPinnedVersion(operation, false); } @@ -690,6 +786,10 @@ export function createCfVersionRunner(options: { if (blocked) { return blocked; } + const settings = await prepareSettingsCredential(operation.message); + if (settings) { + return settings; + } if (output.activeSecrets) { return uploadPinnedVersion(operation, true); } @@ -789,10 +889,20 @@ export function createCfVersionRunner(options: { return failed('The Cloudflare settings publisher is unavailable.'); } try { - const token = await credentials.getToken(); - if (!token || !credentials.verifyUploader()) { + // Read again: the bearer read before upload may have expired while traffic moved. + // A recovery that replays the previous version's settings when the new version had + // none reads it here for the first time. + const credential = await readFreshSessionToken(credentials); + if (!credential.ok) { + return credentialFailed( + 'Could not get the Cloudflare session credential for publishing Worker settings', + credential.failure, + ); + } + if (!credentials.verifyUploader()) { return failed('Cloudflare session is unavailable for publishing Worker settings.'); } + const { token } = credential; await publishCloudflareWorkerSettings({ apiBaseUrl: credentials.apiBaseUrl, accountId: credentials.accountId, diff --git a/packages/void/src/cli/cli-main.ts b/packages/void/src/cli/cli-main.ts new file mode 100644 index 000000000..33b863b05 --- /dev/null +++ b/packages/void/src/cli/cli-main.ts @@ -0,0 +1,416 @@ +// The Void CLI. cli.ts checks the runtime and then loads this module. + +import { log } from '@voidzero-dev/vite-plus-prompts'; +import { accountSubcommands, parseAccountArgs } from './account-args.ts'; +import { getOwnVersion } from './agents.ts'; +import { parseDeployArgs } from './args.ts'; +import { authSubcommands, parseAuthArgs } from './auth-args.ts'; +import { buildSubcommands, parseBuildArgs } from './build-args.ts'; +import { cloudflareSubcommands, parseCloudflareArgs } from './cloudflare-args.ts'; +import { formatCloudflareUserMessage } from './cloudflare-user-output.ts'; +import { parseConnectArgs } from './connect-args.ts'; +import { dbSubcommands, parseDbArgs } from './db-args.ts'; +import { domainSubcommands, parseDomainArgs } from './domain-args.ts'; +import { emailSubcommands, parseEmailArgs } from './email-args.ts'; +import { envSubcommands, parseEnvArgs } from './env-args.ts'; +import { genSubcommands, parseGenArgs } from './gen-args.ts'; +import { githubSubcommands, parseGithubArgs } from './github-args.ts'; +import { getHelpPath, printGlobalHelp, printHelpPage, printVoidHeader } from './help.ts'; +import { parseInfoArgs } from './info-args.ts'; +import { operatorGroups } from './operator-args.ts'; +import { OperatorClientError } from './operator-client.ts'; +import { formatUrl, pc } from './output.ts'; +import { parsePlatformArgs, platformSubcommands } from './platform-args.ts'; +import { parsePrepareArgs } from './prepare-args.ts'; +import { projectSubcommands, parseProjectArgs } from './project-args.ts'; +import { secretSubcommands, parseSecretArgs } from './secret-args.ts'; +import type { SubcommandDef, SubcommandResult } from './subcommand-prompt.ts'; + +function buildArgs(result: SubcommandResult, trailingArgs: Array): Array { + const out = [result.subcommand]; + if (result.arg) { + out.push(result.arg); + } + if (result.extraArgs) { + out.push(...result.extraArgs); + } + if (result.flags) { + for (const [flag, value] of Object.entries(result.flags)) { + out.push(flag); + if (typeof value === 'string') { + out.push(value); + } + } + } + for (const a of trailingArgs) { + if (a.startsWith('-')) { + out.push(a); + } + } + return out; +} + +async function runSubcommandGroup( + command: string, + subcommands: Array, + parse: (args: Array) => T, + run: (root: string, parsed: T) => Promise, +) { + const root = process.cwd(); + const sub = args[1]; + const hasNamedSubcommand = args + .slice(1) + .some((arg) => subcommands.some(({ name }) => name === arg)); + try { + if ((!sub || (sub.startsWith('-') && !hasNamedSubcommand)) && process.stdin.isTTY) { + const { promptSubcommand } = await import('./subcommand-prompt.ts'); + if (command === 'platform' && !sub) { + log.info( + 'A Void platform lets a team deploy Void apps to a shared Cloudflare account.\n' + + 'This command sets up and manages platforms. Join with `void connect `.\n' + + `Docs: ${formatUrl('https://void.cloud/guide/self-hosted-platform')}`, + ); + } + const result = await promptSubcommand(command, subcommands); + if (!result) { + process.exit(0); + } + const parsed = parse(buildArgs(result, args.slice(1))); + await run(root, parsed); + } else { + const parsed = parse(args.slice(1)); + await run(root, parsed); + } + } catch (err) { + if (command === 'platform' && args.includes('--json')) { + console.error( + JSON.stringify( + err instanceof OperatorClientError + ? err.toJSON() + : { + error: 'invalid_command', + message: err instanceof Error ? err.message : String(err), + }, + ), + ); + process.exit(1); + } + const message = formatCloudflareUserMessage(err instanceof Error ? err.message : String(err)); + if (command === 'platform') { + const platformMessage = message.replace(/^platform:/, pc.bold(pc.red('platform:'))); + console.error( + err instanceof OperatorClientError && err.details !== undefined + ? `${platformMessage}\nPartial result:\n${JSON.stringify(err.details, null, 2)}` + : platformMessage, + ); + } else { + log.error(message); + } + process.exit(1); + } +} + +const args = process.argv.slice(2); +const command = args[0]; +const helpPath = getHelpPath(args); + +const subcommandGroups: Record Promise> = { + auth: () => + runSubcommandGroup('auth', authSubcommands, parseAuthArgs, (root, parsed) => + import('./auth-router.ts').then(({ runAuthRouter }) => runAuthRouter(root, parsed)), + ), + account: () => + runSubcommandGroup('account', accountSubcommands, parseAccountArgs, (root, parsed) => + import('./account-cmd.ts').then(({ runAccountCommand }) => runAccountCommand(root, parsed)), + ), + platform: () => + runSubcommandGroup('platform', platformSubcommands, parsePlatformArgs, (root, parsed) => + import('./platform-cmd.ts').then(({ runPlatformCommand }) => + runPlatformCommand(root, parsed), + ), + ), + cloudflare: () => + runSubcommandGroup('cloudflare', cloudflareSubcommands, parseCloudflareArgs, (root, parsed) => + import('./cloudflare-cmd.ts').then(({ runCloudflareCommand }) => + runCloudflareCommand(root, parsed), + ), + ), + project: () => + runSubcommandGroup('project', projectSubcommands, parseProjectArgs, (root, parsed) => + import('./project-cmd.ts').then(({ runProjectCommand }) => runProjectCommand(root, parsed)), + ), + secret: () => + runSubcommandGroup('secret', secretSubcommands, parseSecretArgs, (root, parsed) => + import('./secret.ts').then(({ runSecretCommand }) => runSecretCommand(root, parsed)), + ), + domain: () => + runSubcommandGroup('domain', domainSubcommands, parseDomainArgs, (root, parsed) => + import('./domain.ts').then(({ runDomainCommand }) => runDomainCommand(root, parsed)), + ), + email: () => + runSubcommandGroup('email', emailSubcommands, parseEmailArgs, (root, parsed) => + import('./email.ts').then(({ runEmailCommand }) => runEmailCommand(root, parsed)), + ), + db: () => + runSubcommandGroup('db', dbSubcommands, parseDbArgs, (root, parsed) => + import('./db.ts').then(({ runDbCommand }) => runDbCommand(root, parsed)), + ), + env: () => + runSubcommandGroup('env', envSubcommands, parseEnvArgs, (root, parsed) => + import('./env.ts').then(({ runEnvCommand }) => runEnvCommand(root, parsed)), + ), + gen: () => + runSubcommandGroup('gen', genSubcommands, parseGenArgs, (root, parsed) => + import('./gen.ts').then(({ runGenCommand }) => runGenCommand(root, parsed)), + ), + github: () => + runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (root, parsed) => + import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(root, parsed)), + ), + build: () => + runSubcommandGroup('build', buildSubcommands, parseBuildArgs, (root, parsed) => + import('./build-cmd.ts').then(({ runBuildCommand }) => runBuildCommand(root, parsed)), + ), +}; + +if (helpPath) { + if (helpPath.length === 0) { + printGlobalHelp(); + } else { + try { + printHelpPage(helpPath); + } catch (err) { + log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + } +} else if (command === 'deploy') { + const root = process.cwd(); + let deployArgs; + try { + deployArgs = parseDeployArgs(args.slice(1)); + } catch (err) { + log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + + import('./deploy.ts').then(({ runDeploy }) => { + runDeploy(root, deployArgs).catch((err) => { + log.error(formatCloudflareUserMessage(err.message ?? String(err))); + process.exit(1); + }); + }); +} else if (command === 'connect') { + (async () => { + const parsed = parseConnectArgs(args.slice(1)); + const { runConnectCommand } = await import('./connect.ts'); + await runConnectCommand(process.cwd(), parsed); + })().catch((err) => { + log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); + process.exit(1); + }); +} else if (command && Object.hasOwn(subcommandGroups, command)) { + subcommandGroups[command]!(); +} else if (command === 'info') { + import('./info.ts').then(async ({ runInfoCommand }) => { + try { + await runInfoCommand(process.cwd(), parseInfoArgs(args.slice(1))); + } catch (err) { + const message = formatCloudflareUserMessage(err instanceof Error ? err.message : String(err)); + if (args.includes('--json')) { + console.error(JSON.stringify({ error: 'invalid_command', message })); + } else { + log.error(message); + } + process.exit(1); + } + }); +} else if (command === 'prepare') { + const root = process.cwd(); + try { + parsePrepareArgs(args.slice(1)); + } catch (err) { + log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + + import('./prepare.ts').then(({ runPrepareCommand }) => { + runPrepareCommand(root).catch((err) => { + log.error(formatCloudflareUserMessage(err.message ?? String(err))); + process.exit(1); + }); + }); +} else if (command === 'init') { + const root = process.cwd(); + import('./init.ts').then(({ runInitCommand, parseInitArgs }) => { + let initArgs; + try { + initArgs = parseInitArgs(args.slice(1)); + } catch (err) { + log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + runInitCommand(root, initArgs).catch((err) => { + log.error(formatCloudflareUserMessage(err.message ?? String(err))); + process.exit(1); + }); + }); +} else if (command === 'migrate') { + if (args.length > 1) { + log.error('migrate: This command takes no arguments.'); + process.exit(1); + } + import('./migrate.ts').then(({ runMigrateCommand }) => { + runMigrateCommand(process.cwd()).catch((err) => { + log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); + process.exit(1); + }); + }); +} else if (command === '-v' || command === '--version') { + console.log(getOwnVersion()); +} else if (command) { + log.error(`cli: Unknown command '${command}'. Run \`void --help\` for usage.`); + process.exit(1); +} else if (!command && process.stdin.isTTY) { + // Interactive top-level prompt + const topLevelCommands: Array = [ + { + name: 'init', + hint: '--tsconfig | --github | --git | --no-git', + description: 'Set up void in a new or existing project', + }, + { name: 'migrate', description: 'Update project configuration' }, + { + name: 'info', + hint: '--json', + description: 'Inspect persistent resource names in this project', + }, + { name: 'connect', description: 'Connect to Cloudflare or a Void platform' }, + { name: 'deploy', description: 'Build and deploy, or link an existing Cloudflare Worker' }, + { + name: 'auth', + hint: 'login | status | logout', + description: 'Sign in to this project’s Cloudflare or Void destination', + }, + { + name: 'account', + hint: 'login | link | logout | whoami | token', + description: 'Manage your Void platform account', + }, + { + name: 'platform', + hint: ['config', ...operatorGroups, 'install'].join(' | '), + description: + 'Install platforms and administer plans, users, projects, access, and operations', + }, + { + name: 'cloudflare', + hint: 'login | status | logout', + description: 'Manage Cloudflare sign-in and inspect the deployment account', + }, + { + name: 'project', + hint: 'status | link | list | team | token | zero-trust | logs | requests | rollback | cancel | delete | purge-cache', + description: 'Manage projects, teams, deployments, and scoped CI credentials', + }, + { name: 'secret', hint: 'list | put | delete | sync', description: 'Manage project secrets' }, + { + name: 'domain', + hint: 'add | delete | list | status', + description: 'Configure custom domains', + }, + { + name: 'email', + hint: 'usage | logs | destinations | allow | disallow | alias | domain | status | setup', + description: 'Inspect email activity and manage verified recipients', + }, + { + name: 'github', + hint: 'install | installations | connect | update | status | disconnect | link | join', + description: 'Connect a GitHub repository for automatic deploys', + }, + { + name: 'build', + hint: 'logs', + description: 'Inspect remote builds', + }, + { + name: 'db', + hint: dbSubcommands.map(({ name }) => name).join(' | '), + description: 'Database migrations and management', + }, + { + name: 'env', + hint: 'check | types', + description: 'Validate env.ts or generate types', + }, + { + name: 'gen', + hint: 'model | migration | route | middleware | ssr | cron | queue', + description: 'Scaffold routes, models, and more', + }, + { + name: 'prepare', + description: 'Generate .void artifacts without starting Vite', + }, + { name: 'help', description: 'Show help message' }, + ]; + + (async () => { + printVoidHeader(); + + const { promptSubcommand } = await import('./subcommand-prompt.ts'); + const result = await promptSubcommand('', topLevelCommands); + if (!result) { + process.exit(0); + } + + const cmd = result.subcommand; + const root = process.cwd(); + + if (Object.hasOwn(subcommandGroups, cmd)) { + return subcommandGroups[cmd]!(); + } + + switch (cmd) { + case 'connect': { + const { runConnectCommand } = await import('./connect.ts'); + await runConnectCommand(root, parseConnectArgs([])); + return; + } + case 'init': { + const { runInitCommand, parseInitArgs } = await import('./init.ts'); + await runInitCommand(root, parseInitArgs([])); + return; + } + case 'migrate': { + const { runMigrateCommand } = await import('./migrate.ts'); + await runMigrateCommand(root); + return; + } + case 'deploy': { + const { runDeploy } = await import('./deploy.ts'); + await runDeploy(root); + return; + } + case 'info': { + const { runInfoCommand } = await import('./info.ts'); + await runInfoCommand(root, parseInfoArgs([])); + return; + } + case 'prepare': { + const { runPrepareCommand } = await import('./prepare.ts'); + await runPrepareCommand(root); + return; + } + case 'help': + printGlobalHelp(); + return; + } + })().catch((err) => { + log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); + process.exit(1); + }); +} else { + printGlobalHelp(); +} diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 195220fcd..83f541fb5 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -1,416 +1,15 @@ #!/usr/bin/env node -import { log } from '@voidzero-dev/vite-plus-prompts'; -import { accountSubcommands, parseAccountArgs } from './account-args.ts'; -import { getOwnVersion } from './agents.ts'; -import { parseDeployArgs } from './args.ts'; -import { authSubcommands, parseAuthArgs } from './auth-args.ts'; -import { buildSubcommands, parseBuildArgs } from './build-args.ts'; -import { cloudflareSubcommands, parseCloudflareArgs } from './cloudflare-args.ts'; -import { formatCloudflareUserMessage } from './cloudflare-user-output.ts'; -import { parseConnectArgs } from './connect-args.ts'; -import { dbSubcommands, parseDbArgs } from './db-args.ts'; -import { domainSubcommands, parseDomainArgs } from './domain-args.ts'; -import { emailSubcommands, parseEmailArgs } from './email-args.ts'; -import { envSubcommands, parseEnvArgs } from './env-args.ts'; -import { genSubcommands, parseGenArgs } from './gen-args.ts'; -import { githubSubcommands, parseGithubArgs } from './github-args.ts'; -import { getHelpPath, printGlobalHelp, printHelpPage, printVoidHeader } from './help.ts'; -import { parseInfoArgs } from './info-args.ts'; -import { operatorGroups } from './operator-args.ts'; -import { OperatorClientError } from './operator-client.ts'; -import { formatUrl, pc } from './output.ts'; -import { parsePlatformArgs, platformSubcommands } from './platform-args.ts'; -import { parsePrepareArgs } from './prepare-args.ts'; -import { projectSubcommands, parseProjectArgs } from './project-args.ts'; -import { secretSubcommands, parseSecretArgs } from './secret-args.ts'; -import type { SubcommandDef, SubcommandResult } from './subcommand-prompt.ts'; - -function buildArgs(result: SubcommandResult, trailingArgs: Array): Array { - const out = [result.subcommand]; - if (result.arg) { - out.push(result.arg); - } - if (result.extraArgs) { - out.push(...result.extraArgs); - } - if (result.flags) { - for (const [flag, value] of Object.entries(result.flags)) { - out.push(flag); - if (typeof value === 'string') { - out.push(value); - } - } - } - for (const a of trailingArgs) { - if (a.startsWith('-')) { - out.push(a); - } - } - return out; -} - -async function runSubcommandGroup( - command: string, - subcommands: Array, - parse: (args: Array) => T, - run: (root: string, parsed: T) => Promise, -) { - const root = process.cwd(); - const sub = args[1]; - const hasNamedSubcommand = args - .slice(1) - .some((arg) => subcommands.some(({ name }) => name === arg)); - try { - if ((!sub || (sub.startsWith('-') && !hasNamedSubcommand)) && process.stdin.isTTY) { - const { promptSubcommand } = await import('./subcommand-prompt.ts'); - if (command === 'platform' && !sub) { - log.info( - 'A Void platform lets a team deploy Void apps to a shared Cloudflare account.\n' + - 'This command sets up and manages platforms. Join with `void connect `.\n' + - `Docs: ${formatUrl('https://void.cloud/guide/self-hosted-platform')}`, - ); - } - const result = await promptSubcommand(command, subcommands); - if (!result) { - process.exit(0); - } - const parsed = parse(buildArgs(result, args.slice(1))); - await run(root, parsed); - } else { - const parsed = parse(args.slice(1)); - await run(root, parsed); - } - } catch (err) { - if (command === 'platform' && args.includes('--json')) { - console.error( - JSON.stringify( - err instanceof OperatorClientError - ? err.toJSON() - : { - error: 'invalid_command', - message: err instanceof Error ? err.message : String(err), - }, - ), - ); - process.exit(1); - } - const message = formatCloudflareUserMessage(err instanceof Error ? err.message : String(err)); - if (command === 'platform') { - const platformMessage = message.replace(/^platform:/, pc.bold(pc.red('platform:'))); - console.error( - err instanceof OperatorClientError && err.details !== undefined - ? `${platformMessage}\nPartial result:\n${JSON.stringify(err.details, null, 2)}` - : platformMessage, - ); - } else { - log.error(message); - } - process.exit(1); - } -} - -const args = process.argv.slice(2); -const command = args[0]; -const helpPath = getHelpPath(args); - -const subcommandGroups: Record Promise> = { - auth: () => - runSubcommandGroup('auth', authSubcommands, parseAuthArgs, (root, parsed) => - import('./auth-router.ts').then(({ runAuthRouter }) => runAuthRouter(root, parsed)), - ), - account: () => - runSubcommandGroup('account', accountSubcommands, parseAccountArgs, (root, parsed) => - import('./account-cmd.ts').then(({ runAccountCommand }) => runAccountCommand(root, parsed)), - ), - platform: () => - runSubcommandGroup('platform', platformSubcommands, parsePlatformArgs, (root, parsed) => - import('./platform-cmd.ts').then(({ runPlatformCommand }) => - runPlatformCommand(root, parsed), - ), - ), - cloudflare: () => - runSubcommandGroup('cloudflare', cloudflareSubcommands, parseCloudflareArgs, (root, parsed) => - import('./cloudflare-cmd.ts').then(({ runCloudflareCommand }) => - runCloudflareCommand(root, parsed), - ), - ), - project: () => - runSubcommandGroup('project', projectSubcommands, parseProjectArgs, (root, parsed) => - import('./project-cmd.ts').then(({ runProjectCommand }) => runProjectCommand(root, parsed)), - ), - secret: () => - runSubcommandGroup('secret', secretSubcommands, parseSecretArgs, (root, parsed) => - import('./secret.ts').then(({ runSecretCommand }) => runSecretCommand(root, parsed)), - ), - domain: () => - runSubcommandGroup('domain', domainSubcommands, parseDomainArgs, (root, parsed) => - import('./domain.ts').then(({ runDomainCommand }) => runDomainCommand(root, parsed)), - ), - email: () => - runSubcommandGroup('email', emailSubcommands, parseEmailArgs, (root, parsed) => - import('./email.ts').then(({ runEmailCommand }) => runEmailCommand(root, parsed)), - ), - db: () => - runSubcommandGroup('db', dbSubcommands, parseDbArgs, (root, parsed) => - import('./db.ts').then(({ runDbCommand }) => runDbCommand(root, parsed)), - ), - env: () => - runSubcommandGroup('env', envSubcommands, parseEnvArgs, (root, parsed) => - import('./env.ts').then(({ runEnvCommand }) => runEnvCommand(root, parsed)), - ), - gen: () => - runSubcommandGroup('gen', genSubcommands, parseGenArgs, (root, parsed) => - import('./gen.ts').then(({ runGenCommand }) => runGenCommand(root, parsed)), - ), - github: () => - runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (root, parsed) => - import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(root, parsed)), - ), - build: () => - runSubcommandGroup('build', buildSubcommands, parseBuildArgs, (root, parsed) => - import('./build-cmd.ts').then(({ runBuildCommand }) => runBuildCommand(root, parsed)), - ), -}; - -if (helpPath) { - if (helpPath.length === 0) { - printGlobalHelp(); - } else { - try { - printHelpPage(helpPath); - } catch (err) { - log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); - process.exit(1); - } - } -} else if (command === 'deploy') { - const root = process.cwd(); - let deployArgs; - try { - deployArgs = parseDeployArgs(args.slice(1)); - } catch (err) { - log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); - process.exit(1); - } - - import('./deploy.ts').then(({ runDeploy }) => { - runDeploy(root, deployArgs).catch((err) => { - log.error(formatCloudflareUserMessage(err.message ?? String(err))); - process.exit(1); - }); - }); -} else if (command === 'connect') { - (async () => { - const parsed = parseConnectArgs(args.slice(1)); - const { runConnectCommand } = await import('./connect.ts'); - await runConnectCommand(process.cwd(), parsed); - })().catch((err) => { - log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); - process.exit(1); - }); -} else if (command && Object.hasOwn(subcommandGroups, command)) { - subcommandGroups[command]!(); -} else if (command === 'info') { - import('./info.ts').then(async ({ runInfoCommand }) => { - try { - await runInfoCommand(process.cwd(), parseInfoArgs(args.slice(1))); - } catch (err) { - const message = formatCloudflareUserMessage(err instanceof Error ? err.message : String(err)); - if (args.includes('--json')) { - console.error(JSON.stringify({ error: 'invalid_command', message })); - } else { - log.error(message); - } - process.exit(1); - } - }); -} else if (command === 'prepare') { - const root = process.cwd(); - try { - parsePrepareArgs(args.slice(1)); - } catch (err) { - log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); - process.exit(1); - } - - import('./prepare.ts').then(({ runPrepareCommand }) => { - runPrepareCommand(root).catch((err) => { - log.error(formatCloudflareUserMessage(err.message ?? String(err))); - process.exit(1); - }); - }); -} else if (command === 'init') { - const root = process.cwd(); - import('./init.ts').then(({ runInitCommand, parseInitArgs }) => { - let initArgs; - try { - initArgs = parseInitArgs(args.slice(1)); - } catch (err) { - log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); - process.exit(1); - } - runInitCommand(root, initArgs).catch((err) => { - log.error(formatCloudflareUserMessage(err.message ?? String(err))); - process.exit(1); - }); - }); -} else if (command === 'migrate') { - if (args.length > 1) { - log.error('migrate: This command takes no arguments.'); - process.exit(1); - } - import('./migrate.ts').then(({ runMigrateCommand }) => { - runMigrateCommand(process.cwd()).catch((err) => { - log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); - process.exit(1); - }); - }); -} else if (command === '-v' || command === '--version') { - console.log(getOwnVersion()); -} else if (command) { - log.error(`cli: Unknown command '${command}'. Run \`void --help\` for usage.`); - process.exit(1); -} else if (!command && process.stdin.isTTY) { - // Interactive top-level prompt - const topLevelCommands: Array = [ - { - name: 'init', - hint: '--tsconfig | --github | --git | --no-git', - description: 'Set up void in a new or existing project', - }, - { name: 'migrate', description: 'Update project configuration' }, - { - name: 'info', - hint: '--json', - description: 'Inspect persistent resource names in this project', - }, - { name: 'connect', description: 'Connect to Cloudflare or a Void platform' }, - { name: 'deploy', description: 'Build and deploy, or link an existing Cloudflare Worker' }, - { - name: 'auth', - hint: 'login | status | logout', - description: 'Sign in to this project’s Cloudflare or Void destination', - }, - { - name: 'account', - hint: 'login | link | logout | whoami | token', - description: 'Manage your Void platform account', - }, - { - name: 'platform', - hint: ['config', ...operatorGroups, 'install'].join(' | '), - description: - 'Install platforms and administer plans, users, projects, access, and operations', - }, - { - name: 'cloudflare', - hint: 'login | status | logout', - description: 'Manage Cloudflare sign-in and inspect the deployment account', - }, - { - name: 'project', - hint: 'status | link | list | team | token | zero-trust | logs | requests | rollback | cancel | delete | purge-cache', - description: 'Manage projects, teams, deployments, and scoped CI credentials', - }, - { name: 'secret', hint: 'list | put | delete | sync', description: 'Manage project secrets' }, - { - name: 'domain', - hint: 'add | delete | list | status', - description: 'Configure custom domains', - }, - { - name: 'email', - hint: 'usage | logs | destinations | allow | disallow | alias | domain | status | setup', - description: 'Inspect email activity and manage verified recipients', - }, - { - name: 'github', - hint: 'install | installations | connect | update | status | disconnect | link | join', - description: 'Connect a GitHub repository for automatic deploys', - }, - { - name: 'build', - hint: 'logs', - description: 'Inspect remote builds', - }, - { - name: 'db', - hint: dbSubcommands.map(({ name }) => name).join(' | '), - description: 'Database migrations and management', - }, - { - name: 'env', - hint: 'check | types', - description: 'Validate env.ts or generate types', - }, - { - name: 'gen', - hint: 'model | migration | route | middleware | ssr | cron | queue', - description: 'Scaffold routes, models, and more', - }, - { - name: 'prepare', - description: 'Generate .void artifacts without starting Vite', - }, - { name: 'help', description: 'Show help message' }, - ]; - - (async () => { - printVoidHeader(); - - const { promptSubcommand } = await import('./subcommand-prompt.ts'); - const result = await promptSubcommand('', topLevelCommands); - if (!result) { - process.exit(0); - } - - const cmd = result.subcommand; - const root = process.cwd(); - - if (Object.hasOwn(subcommandGroups, cmd)) { - return subcommandGroups[cmd]!(); - } - - switch (cmd) { - case 'connect': { - const { runConnectCommand } = await import('./connect.ts'); - await runConnectCommand(root, parseConnectArgs([])); - return; - } - case 'init': { - const { runInitCommand, parseInitArgs } = await import('./init.ts'); - await runInitCommand(root, parseInitArgs([])); - return; - } - case 'migrate': { - const { runMigrateCommand } = await import('./migrate.ts'); - await runMigrateCommand(root); - return; - } - case 'deploy': { - const { runDeploy } = await import('./deploy.ts'); - await runDeploy(root); - return; - } - case 'info': { - const { runInfoCommand } = await import('./info.ts'); - await runInfoCommand(root, parseInfoArgs([])); - return; - } - case 'prepare': { - const { runPrepareCommand } = await import('./prepare.ts'); - await runPrepareCommand(root); - return; - } - case 'help': - printGlobalHelp(); - return; - } - })().catch((err) => { - log.error(formatCloudflareUserMessage(err instanceof Error ? err.message : String(err))); - process.exit(1); - }); +// The bin entry. It imports only the dependency-free runtime check, so an +// unsupported Node or Bun gets a clear message instead of a link-time +// SyntaxError from a newer API somewhere in the CLI's module graph. +import { unsupportedRuntimeMessage } from './runtime-support.ts'; + +const message = unsupportedRuntimeMessage(process.versions, process.execPath); +if (message) { + process.stderr.write(`${message}\n`); + // exitCode instead of exit() so the message is flushed when stderr is a pipe. + process.exitCode = 1; } else { - printGlobalHelp(); + await import('./cli-main.ts'); } diff --git a/packages/void/src/cli/cloudflare-child-report.ts b/packages/void/src/cli/cloudflare-child-report.ts new file mode 100644 index 000000000..5341a0770 --- /dev/null +++ b/packages/void/src/cli/cloudflare-child-report.ts @@ -0,0 +1,239 @@ +import { + cloudflareCommandDiagnostic, + type CloudflareCommandResult, + type CloudflareOperation, +} from '@void/deploy-cloudflare'; +import { redactMessage } from '../shared/env-mask.ts'; +import { WRANGLER_AUTH_CREDENTIAL_ENV_VARS } from '../shared/untrusted-env.ts'; +import type { CfCommand } from './cf-version-api.ts'; +import { isCleanCloudflareExit } from './cloudflare-process.ts'; +import type { DeployWranglerRun } from './deploy-types.ts'; +import type { CliLogger } from './log-file.ts'; + +// Emails and the Access client id identify an account; they are not secrets, and +// scrubbing them would mangle ordinary messages. +const NOT_SECRET = new Set(['CLOUDFLARE_EMAIL', 'CF_EMAIL', 'CLOUDFLARE_ACCESS_CLIENT_ID']); +const CREDENTIAL_ENV_VARS: ReadonlyArray = [ + ...WRANGLER_AUTH_CREDENTIAL_ENV_VARS.filter((name) => !NOT_SECRET.has(name)), + 'CF_ACCESS_CLIENT_SECRET', +]; +// Shorter values would match ordinary words. +const MIN_SECRET_LENGTH = 8; + +/** Exact-value scrubber for the credentials this deploy holds. */ +export type CredentialRedactor = { + redact(text: string): string; + /** Add a credential learned at run time, such as an OAuth bearer. */ + add(value: unknown): void; +}; + +export function createCredentialRedactor(env: NodeJS.ProcessEnv = process.env): CredentialRedactor { + const values = new Set(); + let ordered: Array = []; + const add = (value: unknown): void => { + if (typeof value !== 'string') { + return; + } + for (const candidate of [value, value.trim()]) { + if (candidate.length >= MIN_SECRET_LENGTH && !values.has(candidate)) { + values.add(candidate); + // Longer first, so a value that contains another is removed whole. + ordered = [...values].sort((a, b) => b.length - a.length); + } + } + }; + for (const name of CREDENTIAL_ENV_VARS) { + add(env[name]); + } + return { + add, + redact(text) { + let out = text; + for (const value of ordered) { + out = redactMessage(value, out); + } + return out; + }, + }; +} + +/** One redactor per deploy, shared by every observed runner so a learned bearer covers them all. */ +export type CloudflareChildReporter = { + cliLog: CliLogger; + redactor: CredentialRedactor; +}; + +export function createCloudflareChildReporter( + cliLog: CliLogger, + env: NodeJS.ProcessEnv = process.env, +): CloudflareChildReporter { + return { cliLog, redactor: createCredentialRedactor(env) }; +} + +/** + * Scrub exact credential values from a child result. This must run before any formatter + * truncates the text, or a token that crosses the cut would leak its prefix. A clean + * stdout and `data` are left alone: callers parse them, and they carry results, not errors. + */ +export function scrubCloudflareResult( + result: T, + redactor: CredentialRedactor, +): T { + return { + ...result, + stderr: redactor.redact(result.stderr), + ...(isCleanCloudflareExit(result) ? {} : { stdout: redactor.redact(result.stdout) }), + ...(result.error ? { error: redactor.redact(result.error) } : {}), + ...(result.errors + ? { + errors: result.errors.map((error) => ({ + ...error, + message: redactor.redact(error.message), + })), + } + : {}), + }; +} + +/** Scrub a runner rejection in place, so fields callers check on the Error survive. */ +export function scrubCloudflareError(error: unknown, redactor: CredentialRedactor): Error { + if (!(error instanceof Error)) { + return new Error(redactor.redact(String(error))); + } + error.message = redactor.redact(error.message); + if (typeof error.stack === 'string') { + error.stack = redactor.redact(error.stack); + } + return error; +} + +/** Why a child failed: its spawn error, the signal that stopped it, or its exit code. */ +export function describeCloudflareChildFailure( + result: Pick, + subject = 'the Void CLI helper process', +): string { + if (result.error) { + return `${subject} could not run: ${result.error}`; + } + if (result.signal) { + return `${subject} was stopped by ${result.signal}`; + } + return `${subject} exited with code ${String(result.status)}`; +} + +/** + * The cause sentence, then the child's bounded diagnostic. stdout is never shown: for + * auth.token it is the credential envelope. The spawn error is already in the cause. + */ +export function formatCloudflareChildFailure( + result: Pick, + subject?: string, +): string { + const diagnostic = cloudflareCommandDiagnostic({ + status: result.status, + stdout: '', + stderr: result.stderr, + }); + const cause = `${describeCloudflareChildFailure(result, subject)}.`; + return diagnostic ? `${cause}\n${diagnostic}` : cause; +} + +/** `cf` plus up to three leading subcommand words, so values and flags stay out of the log. */ +export function cfOperationName(args: ReadonlyArray): string { + const words: Array = []; + for (const arg of args) { + if (words.length === 3 || arg.startsWith('-')) { + break; + } + words.push(arg); + } + return ['cf', ...words].join(' '); +} + +function logCloudflareOp( + reporter: CloudflareChildReporter, + op: string, + startedAt: number, + outcome: { result: CloudflareCommandResult } | { thrown: Error }, +): void { + const ms = Date.now() - startedAt; + if ('thrown' in outcome) { + reporter.cliLog.debug('cloudflare_op', { + op, + ok: false, + ms, + diagnostic: cloudflareCommandDiagnostic({ + status: 1, + stdout: '', + stderr: outcome.thrown.message, + }), + }); + return; + } + const { result } = outcome; + const ok = isCleanCloudflareExit(result); + reporter.cliLog.debug('cloudflare_op', { + op, + ok, + status: result.status, + ...(result.signal ? { signal: result.signal } : {}), + ms, + ...(ok + ? {} + : { + diagnostic: cloudflareCommandDiagnostic({ + ...result, + // auth.token stdout is the credential envelope; never format it. + stdout: op === 'auth.token' ? '' : result.stdout, + }), + }), + }); +} + +/** + * Observe the deploy's operation-child runner: scrub every result, learn the session + * bearer from a clean auth.token, and write one debug `cloudflare_op` record per child. + * Inputs (`opts.input` carries secret values) and result data are never logged. + */ +export function observeCloudflareRunner( + run: DeployWranglerRun, + reporter: CloudflareChildReporter, +): DeployWranglerRun { + return async (operation: CloudflareOperation, opts) => { + const startedAt = Date.now(); + let result: CloudflareCommandResult; + try { + result = await run(operation, opts); + } catch (error) { + const thrown = scrubCloudflareError(error, reporter.redactor); + logCloudflareOp(reporter, operation.type, startedAt, { thrown }); + throw thrown; + } + if (operation.type === 'auth.token' && isCleanCloudflareExit(result)) { + const data = result.data as { token?: unknown } | null | undefined; + reporter.redactor.add(data?.token); + } + const scrubbed = scrubCloudflareResult(result, reporter.redactor); + logCloudflareOp(reporter, operation.type, startedAt, { result: scrubbed }); + return scrubbed; + }; +} + +/** Observe the pinned cf CLI the same way. */ +export function observeCfCommand(run: CfCommand, reporter: CloudflareChildReporter): CfCommand { + return async (args, opts) => { + const op = cfOperationName(args); + const startedAt = Date.now(); + let result: CloudflareCommandResult; + try { + result = await run(args, opts); + } catch (error) { + const thrown = scrubCloudflareError(error, reporter.redactor); + logCloudflareOp(reporter, op, startedAt, { thrown }); + throw thrown; + } + const scrubbed = scrubCloudflareResult(result, reporter.redactor); + logCloudflareOp(reporter, op, startedAt, { result: scrubbed }); + return scrubbed; + }; +} diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 85537fd7a..e3c9e0811 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -509,7 +509,7 @@ async function readApiHeaders(): Promise> { return { 'User-Agent': getVoidUserAgent(), 'X-Auth-Key': key, 'X-Auth-Email': email }; } const credential = await sessionCredential(); - if (!credential) { + if (!('token' in credential)) { throw new Error('Cloudflare session bearer is unavailable.'); } return { 'User-Agent': getVoidUserAgent(), Authorization: `Bearer ${credential.token}` }; @@ -529,21 +529,33 @@ async function withSecretFile( } } -async function sessionCredential(): Promise<{ type: 'oauth' | 'api_token'; token: string } | null> { +const NO_OAUTH_BEARER = 'Cloudflare session has no OAuth bearer.'; + +async function sessionCredential(): Promise< + { type: 'oauth' | 'api_token'; token: string } | { failure: string } +> { const token = process.env.CLOUDFLARE_API_TOKEN || process.env.CF_API_TOKEN; if (token) { return { type: 'api_token', token }; } const identity = runCf(['auth', 'whoami']); if (identity.status !== 0) { - return null; + // Forward cf's own reason; the parent scrubs and bounds it before showing it. + return { + failure: [ + `cf auth whoami exited with code ${String(identity.status)}.`, + identity.stderr.trim(), + ] + .filter(Boolean) + .join('\n'), + }; } const state = object(parsedJson(identity.stdout)); if (state.authenticated !== true || state.tokenValid !== true) { - return null; + return { failure: NO_OAUTH_BEARER }; } const credential = readCfSessionCredential(process.cwd(), state); - return credential ? { type: 'oauth', token: credential } : null; + return credential ? { type: 'oauth', token: credential } : { failure: NO_OAUTH_BEARER }; } async function auth(): Promise { @@ -586,9 +598,16 @@ async function auth(): Promise { throw new Error('Unsupported Cloudflare authentication operation.'); } const credential = await sessionCredential(); - return credential - ? result(0, '', '', credential) - : result(1, '', 'Cloudflare session has no OAuth bearer.'); + if ('token' in credential) { + return result(0, '', '', credential); + } + // A Global API Key has no bearer. Mark it, so the parent can name the next step. + return result( + 1, + '', + credential.failure, + process.env.CLOUDFLARE_API_KEY || process.env.CF_API_KEY ? { type: 'api_key' } : undefined, + ); } function listAllResourcePages(command: Array, extraFlags: Array = []): Result { diff --git a/packages/void/src/cli/deploy-cloudflare-auth.ts b/packages/void/src/cli/deploy-cloudflare-auth.ts index 55d304612..dc704ed8f 100644 --- a/packages/void/src/cli/deploy-cloudflare-auth.ts +++ b/packages/void/src/cli/deploy-cloudflare-auth.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { CloudflareWorkerInspectionError, + type CloudflareCommandResult, type ExistingCloudflareWorker, } from '@void/deploy-cloudflare'; import { isCancel, select } from '@voidzero-dev/vite-plus-prompts'; @@ -12,6 +13,10 @@ import { ensureCloudflareAuthentication, parseCloudflareAuthSession, } from './cloudflare-auth.ts'; +import { + describeCloudflareChildFailure, + formatCloudflareChildFailure, +} from './cloudflare-child-report.ts'; import { prepareExistingCloudflareDeploy } from './cloudflare-link.ts'; import { createCloudflareProcessRunner, isCleanCloudflareExit } from './cloudflare-process.ts'; import { log } from './deploy-helpers.ts'; @@ -23,6 +28,7 @@ import { SelfHostFirstDeployError, SelfHostSecretListError, } from './env-validation.ts'; +import { getErrorMessage } from './output.ts'; import { writeProvisionedWranglerConfig } from './wrangler.ts'; /** Run typed provider operations in the pinned account and API context. */ @@ -176,32 +182,73 @@ export function isWranglerNonInteractiveOrCI(): boolean { return ciInfoIsCI || !(process.stdin.isTTY && process.stdout.isTTY); } +const GLOBAL_API_KEY_SESSION = + 'the session uses a Global API Key (CLOUDFLARE_API_KEY), which this step cannot use. ' + + 'Set CLOUDFLARE_API_TOKEN, or unset CLOUDFLARE_API_KEY and run `void cloudflare login`, then retry.'; + +/** A session credential, or a failure that keeps the helper child's cause and diagnostic. */ +export type WranglerAuthTokenResult = + | { ok: true; token: string } + | { ok: false; failure: CloudflareCommandResult }; + /** * Read the current Cloudflare session bearer through the pinned deploy runner, for API calls - * Void makes itself. The token goes only to the caller; every failure resolves `null`. + * Void makes itself. The token goes only to the caller. A failure is built fresh from the + * child's status, signal and stderr: it never carries the child's errorCode, failure, errors + * or data, so an auth failure can never be read as a Cloudflare upload error. */ -export async function runWranglerAuthToken( +export async function readWranglerAuthToken( runWrangler: DeployWranglerRun, configPath: string, -): Promise { +): Promise { + const fail = ( + status: number, + signal: NodeJS.Signals | null | undefined, + stderr: string, + ): WranglerAuthTokenResult => ({ + ok: false, + // A clean status here would read as a successful upload. + failure: { + status: status === 0 ? 1 : status, + ...(signal ? { signal } : {}), + stdout: '', + stderr, + }, + }); let result: DeployWranglerRunResult; try { result = await Promise.resolve( runWrangler({ type: 'auth.token', configPath }, { capture: true }), ); - } catch { - return null; + } catch (error) { + return fail( + 1, + null, + `${describeCloudflareChildFailure({ status: 1, error: getErrorMessage(error) })}.`, + ); } + const parsed = result.data as { type?: unknown; token?: unknown } | null | undefined; if (!isCleanCloudflareExit(result)) { - return null; + // A Global API Key session has no bearer; the child marks it so the next step can be named. + if (parsed?.type === 'api_key' && !result.error && !result.signal) { + return fail(result.status, null, GLOBAL_API_KEY_SESSION); + } + return fail(result.status, result.signal, formatCloudflareChildFailure(result)); } - const parsed = result.data as { type?: unknown; token?: unknown } | null | undefined; - // An `api_key` session answers `{ key, email }` — there is no single bearer to borrow. const type = parsed?.type; const token = parsed?.token; return (type === 'oauth' || type === 'api_token') && typeof token === 'string' && token.length > 0 - ? token - : null; + ? { ok: true, token } + : fail(1, null, 'the Void CLI helper process returned no session credential.'); +} + +/** {@link readWranglerAuthToken} for callers that only need the token; every failure is `null`. */ +export async function runWranglerAuthToken( + runWrangler: DeployWranglerRun, + configPath: string, +): Promise { + const read = await readWranglerAuthToken(runWrangler, configPath); + return read.ok ? read.token : null; } /** diff --git a/packages/void/src/cli/deploy-cloudflare-helpers.ts b/packages/void/src/cli/deploy-cloudflare-helpers.ts index 681e689d5..b7ee238a8 100644 --- a/packages/void/src/cli/deploy-cloudflare-helpers.ts +++ b/packages/void/src/cli/deploy-cloudflare-helpers.ts @@ -28,6 +28,11 @@ import { recordCloudflareCandidateUpload, type CloudflareCandidateReceipt, } from './cloudflare-candidate.ts'; +import { + observeCfCommand, + observeCloudflareRunner, + type CloudflareChildReporter, +} from './cloudflare-child-report.ts'; import { assertCloudflareLinkUnchanged, completeCloudflareLink, @@ -52,7 +57,7 @@ import { import { chooseCloudflareDeployAccount, createDeployWranglerRunner, - runWranglerAuthToken, + readWranglerAuthToken, } from './deploy-cloudflare-auth.ts'; import { log } from './deploy-helpers.ts'; import { deployChildEnv, resolveDeployTransport } from './deploy-transport.ts'; @@ -163,8 +168,10 @@ export async function connectCloudflareDeployAccount(options: { persistAccount: boolean; /** `CF_ACCESS_READINESS_ORIGIN` as recorded before project code ran. */ readinessAccessOrigin: string | null | undefined; + /** Scrubs and logs every child this connection runs. */ + reporter: CloudflareChildReporter; }): Promise { - const { root, configPath, deps } = options; + const { root, configPath, deps, reporter } = options; let { rootConfig } = options; let apiBaseUrl: string; try { @@ -184,8 +191,10 @@ export async function connectCloudflareDeployAccount(options: { readWranglerTopLevelString(rootConfig, 'account_id') ?? (process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || undefined); if (!accountId) { - const accountRunner = - deps.runWrangler ?? createDeployWranglerRunner(root, undefined, apiBaseUrl); + const accountRunner = observeCloudflareRunner( + deps.runWrangler ?? createDeployWranglerRunner(root, undefined, apiBaseUrl), + reporter, + ); try { accountId = await chooseCloudflareDeployAccount({ persist: options.persistAccount, @@ -219,7 +228,10 @@ export async function connectCloudflareDeployAccount(options: { accountId, workersDevSubdomain, readinessAccessOrigin, - runWrangler: deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl), + runWrangler: observeCloudflareRunner( + deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl), + reporter, + ), rootConfig, }; } @@ -280,17 +292,22 @@ export function createCfActivationRunner(options: { runWrangler: DeployWranglerRun; cfUploaderIsPinned: () => boolean; verifyCompatibilityRunner: () => boolean; + /** The deploy's reporter; `runWrangler` is already observed by the connection. */ + reporter: CloudflareChildReporter; }): CloudflareCommandRunner | DeployWranglerRun { const { cfOutput, accountId, apiBaseUrl, configPath, runWrangler } = options; if (!cfOutput) { return runWrangler; } - const runCf = createCfCommand({ - accountId, - apiBaseUrl, - env: deployChildEnv, - verifyUploader: options.cfUploaderIsPinned, - }); + const runCf = observeCfCommand( + createCfCommand({ + accountId, + apiBaseUrl, + env: deployChildEnv, + verifyUploader: options.cfUploaderIsPinned, + }), + options.reporter, + ); return createCfVersionRunner({ output: cfOutput, workerName: options.workerName, @@ -298,7 +315,7 @@ export function createCfActivationRunner(options: { pinnedUpload: { accountId, apiBaseUrl, - getToken: () => runWranglerAuthToken(runWrangler, configPath), + getToken: () => readWranglerAuthToken(runWrangler, configPath), verifyUploader: options.cfUploaderIsPinned, }, verifyResources: createCfResourceVerifier({ @@ -475,14 +492,35 @@ export function recordCloudflareActivation(options: { }); } +/** + * Record the failed activation, then the end of the deploy, and release the log before the + * caller exits. The fields are picked from a fixed list, never spread from the result, and avoid + * key names that the log summary masks. + */ export async function reportCloudflareActivationFailure( - cliLog: CliLogger, + reporter: CloudflareChildReporter, message: string, + execution: Exclude, ): Promise { - cliLog.error('cloudflare_deploy_failed', new Error(message)); - // The default exit() terminates the process immediately, so flush the diagnostic first. + const { cliLog } = reporter; + const safeMessage = reporter.redactor.redact(message); + cliLog.error('cloudflare_deploy_failed', new Error(safeMessage), { + reason: execution.reason, + status: execution.status, + cloudflareErrorCode: execution.cloudflareErrorCode, + versionId: execution.versionId, + previousVersionId: execution.previousVersionId, + activeVersionId: execution.activeVersionId, + rollback: execution.rollback, + triggersRestored: execution.triggersRestored, + restoreSkipped: execution.restoreSkipped, + triggerRepair: execution.triggerRepair, + containersUnavailable: execution.containersUnavailable, + }); + // Written here because the log is closed below, before the caller exit(1) could record it. + cliLog.error('deploy_end', undefined, { ok: false, exitCode: 1 }); await cliLog.close().catch(() => {}); - log.error(`${message}${cliLog.path ? `\n\nDetailed log: ${cliLog.path}` : ''}`); + log.error(`${safeMessage}${cliLog.path ? `\n\nDetailed log: ${cliLog.path}` : ''}`); } export async function activateCloudflareVersionWithProgress( @@ -675,17 +713,94 @@ function formatCloudflareActivationFailure( ].join('\n'); case 'activation-failed': return `deploy: the ready Worker Version could not be activated${status}; production traffic is unchanged.`; - case 'triggers-failed': { - const rollback = - result.rollback === 'succeeded' - ? 'Traffic was restored to the previous version.' - : result.rollback === 'unavailable' - ? result.previousVersionId === null - ? 'This was the first deployment, so no previous version was available to restore.' - : 'Traffic or trigger restoration could not be completed. Inspect its routes, schedules, queues, and workflows in Cloudflare before retrying.' - : 'Automatic traffic restoration failed; inspect `void project status` immediately.'; - return `deploy: the Worker activated, but Cloudflare trigger synchronization failed${status}. ${rollback}`; - } + case 'triggers-failed': + return [ + `deploy: the Worker activated, but Cloudflare trigger synchronization failed${status}.${cloudflareCode}`, + ...formatCloudflareTriggerRecovery(result), + ].join('\n'); + } +} + +const TRIGGER_KINDS = 'routes, schedules, queues, and workflows'; +const RETRY_DEPLOY = 'Fix the cause and run `void deploy --platform cloudflare` again.'; +const INSPECT_STATUS = 'Run `void project status` and check them in Cloudflare before retrying.'; + +/** + * Say which version serves production after a trigger synchronization failure. Built only from + * the final owner and trigger state: `rollback` cannot tell "kept this deploy" from "switched + * back", so reading it once printed a restore that never happened. + */ +function formatCloudflareTriggerRecovery( + result: Exclude, +): Array { + const live = result.activeVersionId ?? null; + const previous = result.previousVersionId ?? null; + const diagnostic = result.diagnostic ? ['', result.diagnostic] : []; + if (live === null) { + return [ + 'Live now: unknown. Void could not read which version serves production.', + ...(result.triggersRestored === 'unknown' && previous + ? [ + `Void tried to switch back to the previous version ${previous} and restore its ${TRIGGER_KINDS}, but could not confirm the result.`, + ] + : []), + `Its ${TRIGGER_KINDS} may be partly updated. ${INSPECT_STATUS}`, + ...diagnostic, + ]; + } + if (live === result.versionId) { + // Only a real app is worth rolling back to. Without `restoreSkipped`, Void could not tell + // whether the previous version is an empty draft, so it offers nothing. + const rollbackHint = + previous && + (result.restoreSkipped === 'no-trigger-snapshot' || result.restoreSkipped === 'atomic') + ? [ + `To serve the previous version instead, run \`void project rollback ${previous} --platform cloudflare\`.`, + ] + : []; + return [ + `Live now: ${live} (this deploy). Its ${TRIGGER_KINDS} may be partly updated.`, + keptDeployReason(result.restoreSkipped, previous), + RETRY_DEPLOY, + ...rollbackHint, + ...diagnostic, + ]; + } + if (live === previous) { + // Without restored triggers, Void cannot tell whether its own command or another operation + // moved traffic back (a restore skipped over a foreign deployment also lands here). + return [ + result.triggersRestored === 'yes' + ? `Live now: ${live} (the previous version). Void switched traffic back to it and restored its ${TRIGGER_KINDS}.` + : `Live now: ${live} (the previous version). Void did not restore its ${TRIGGER_KINDS}, so they may be partly updated.`, + result.triggersRestored === 'yes' ? RETRY_DEPLOY : INSPECT_STATUS, + ...diagnostic, + ]; + } + return [ + `Live now: ${live}. Another Cloudflare operation changed production while Void was switching back.`, + `Its ${TRIGGER_KINDS} may not match it. ${INSPECT_STATUS}`, + ...diagnostic, + ]; +} + +/** Explain why this deploy's version still serves production. */ +function keptDeployReason( + restoreSkipped: 'placeholder' | 'no-trigger-snapshot' | 'atomic' | undefined, + previous: string | null, +): string { + if (!previous) { + return 'This was the first deployment, so there is no previous version to switch back to.'; + } + switch (restoreSkipped) { + case 'placeholder': + return `Void did not switch back to the previous version ${previous} because it is an empty placeholder or secret-only draft.`; + case 'no-trigger-snapshot': + return `Void did not switch back to the previous version ${previous} because it has no Void trigger snapshot to restore.`; + case 'atomic': + return `This deploy published at 100% directly, so Void did not switch back to the previous version ${previous}.`; + default: + return `Void could not switch back to the previous version ${previous}.`; } } diff --git a/packages/void/src/cli/deploy-cloudflare-preflight.ts b/packages/void/src/cli/deploy-cloudflare-preflight.ts index c9cab7c4c..2d1dc5abd 100644 --- a/packages/void/src/cli/deploy-cloudflare-preflight.ts +++ b/packages/void/src/cli/deploy-cloudflare-preflight.ts @@ -16,6 +16,7 @@ import { resolveBindingNames, type VoidConfig } from '../config.ts'; import { detectFramework } from '../plugin-inference.ts'; import { cloudflareQueueName } from '../queues/cloudflare-names.ts'; import { assertSandboxNamespaceConfig, resolveSandboxConfig } from '../sandbox/config.ts'; +import type { CloudflareChildReporter } from './cloudflare-child-report.ts'; import { classifyCloudflareDeployApp } from './cloudflare-deploy/classify.ts'; import { approvedCloudflareIsrBinding, chooseCloudflareIsr } from './cloudflare-isr.ts'; import { readCloudflareLink, type CloudflareLink } from './cloudflare-link.ts'; @@ -192,6 +193,7 @@ export async function preflightCloudflareWorker(input: { readonly deps: CloudflareDeployDeps; readonly exit: (code: number) => never; readonly cliLog: CliLogger; + readonly reporter: CloudflareChildReporter; readonly initialCloudflareLink: CloudflareLink | null; readonly detectedFramework: ReturnType; readonly source: CloudflareDeploySource; @@ -224,6 +226,7 @@ export async function preflightCloudflareWorker(input: { deps, exit, cliLog, + reporter, initialCloudflareLink, detectedFramework, source, @@ -260,6 +263,7 @@ export async function preflightCloudflareWorker(input: { deps, persistAccount: !(options.reuseExistingWorker || initialCloudflareLink), readinessAccessOrigin: options.readinessAccessOrigin, + reporter, }); if (!connection) { return exit(1); diff --git a/packages/void/src/cli/deploy-cloudflare-publish.ts b/packages/void/src/cli/deploy-cloudflare-publish.ts index 44a11a632..bee21376e 100644 --- a/packages/void/src/cli/deploy-cloudflare-publish.ts +++ b/packages/void/src/cli/deploy-cloudflare-publish.ts @@ -11,6 +11,7 @@ import { clearCloudflareCandidate, type CloudflareCandidateReceipt, } from './cloudflare-candidate.ts'; +import type { CloudflareChildReporter } from './cloudflare-child-report.ts'; import type { CloudflareBuild } from './deploy-cloudflare-build.ts'; import { activateCloudflareVersionWithProgress, @@ -37,6 +38,7 @@ export async function publishCloudflareWorker(input: { readonly deps: CloudflareDeployDeps; readonly exit: (code: number) => never; readonly cliLog: CliLogger; + readonly reporter: CloudflareChildReporter; readonly atomic: boolean; readonly detectedFramework: ReturnType; readonly initialCloudflareCandidate: CloudflareCandidateReceipt | null; @@ -45,7 +47,8 @@ export async function publishCloudflareWorker(input: { readonly build: CloudflareBuild; readonly migration: CloudflareMigration; }): Promise { - const { root, deps, exit, cliLog, atomic, detectedFramework, initialCloudflareCandidate } = input; + const { root, deps, exit, cliLog, reporter, atomic, detectedFramework } = input; + const { initialCloudflareCandidate } = input; const { configPath, needsSandbox, desired, analysis } = input.source; const { usesCf, @@ -93,6 +96,7 @@ export async function publishCloudflareWorker(input: { runWrangler, cfUploaderIsPinned, verifyCompatibilityRunner: assertUploaderStillPinned, + reporter, }); const pinActiveSecrets = createActiveSecretPinner({ output: cfOutput, @@ -152,10 +156,11 @@ export async function publishCloudflareWorker(input: { clearCloudflareCandidate(root); } await reportCloudflareActivationFailure( - cliLog, + reporter, needsSandbox && execution.containersUnavailable ? formatCloudflareSandboxUnavailable() : formatCloudflareActivationError(execution), + execution, ); return exit(1); } diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index 48b04e4ad..168bb8e06 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -25,6 +25,7 @@ import { selectNativeVersionMetadataBinding } from '../shared/version-metadata.t import { validateSsrEntry } from '../ssr/entry.ts'; import { prepareCfStaticBuildOutput } from './cf-build-output.ts'; import { type CloudflareCandidateReceipt } from './cloudflare-candidate.ts'; +import type { CloudflareChildReporter } from './cloudflare-child-report.ts'; import { readCloudflareLink, type CloudflareLink } from './cloudflare-link.ts'; import { deduplicateGeneratedCompatibilityFlags } from './deploy-artifact-validation.ts'; import { deployPointerPath } from './deploy-binding-snapshot.ts'; @@ -317,6 +318,7 @@ export async function runCloudflareStaticDeploy( deps: CloudflareDeployDeps, initialCloudflareLink: CloudflareLink | null, initialCloudflareCandidate: CloudflareCandidateReceipt | null, + reporter: CloudflareChildReporter, ): Promise { const exit = deps.exit ?? ((code: number): never => process.exit(code)); const configPath = discoverRootWranglerConfig(root); @@ -327,6 +329,7 @@ export async function runCloudflareStaticDeploy( deps, persistAccount: !(options.reuseExistingWorker || initialCloudflareLink), readinessAccessOrigin: options.readinessAccessOrigin, + reporter, }); if (!connection || !(await confirmCloudflareDeployAuthentication(connection, configPath))) { return exit(1); @@ -492,6 +495,7 @@ export async function runCloudflareStaticDeploy( runWrangler, cfUploaderIsPinned, verifyCompatibilityRunner: assertUploaderStillPinned, + reporter, }); const execution = await activateCloudflareVersionWithProgress( deps.activateVersion ?? activateCloudflareVersion, @@ -528,7 +532,11 @@ export async function runCloudflareStaticDeploy( }, ).finally(() => cfOutput?.dispose()); if (!execution.ok) { - await reportCloudflareActivationFailure(cliLog, formatCloudflareActivationError(execution)); + await reportCloudflareActivationFailure( + reporter, + formatCloudflareActivationError(execution), + execution, + ); return exit(1); } recordCloudflareActivation({ diff --git a/packages/void/src/cli/deploy-cloudflare.ts b/packages/void/src/cli/deploy-cloudflare.ts index 01ae19c01..7c913fc58 100644 --- a/packages/void/src/cli/deploy-cloudflare.ts +++ b/packages/void/src/cli/deploy-cloudflare.ts @@ -1,6 +1,7 @@ import { readConfig, isNodeTarget } from '../config.ts'; import { detectFramework } from '../plugin-inference.ts'; import { readCloudflareCandidate } from './cloudflare-candidate.ts'; +import { createCloudflareChildReporter } from './cloudflare-child-report.ts'; import { readCloudflareLink } from './cloudflare-link.ts'; import { withRecordedReadinessAccessOrigin } from './cloudflare-readiness.ts'; import { @@ -38,7 +39,22 @@ export async function runCloudflareDeploy( options = withRecordedReadinessAccessOrigin(options); const initialCloudflareLink = readCloudflareLink(root); const initialCloudflareCandidate = readCloudflareCandidate(root); - const exit = deps.exit ?? ((code: number): never => process.exit(code)); + const exitProcess = deps.exit ?? ((code: number): never => process.exit(code)); + // Most failures leave through exit(), which skips the deploy_end that runDeploy writes on a + // throw. Record it here. The logger keeps only the first deploy_end, so a path that already + // wrote one (a failed activation) is not recorded twice. + const exit = (code: number): never => { + if (code === 0) { + cliLog.info('deploy_end', { ok: true, exitCode: code }); + } else { + cliLog.error('deploy_end', undefined, { ok: false, exitCode: code }); + } + return exitProcess(code); + }; + // Every step below, including the static branch, reads exit from deps. + deps = { ...deps, exit }; + // One redactor for the whole deploy, so a bearer learned by one child is scrubbed everywhere. + const reporter = createCloudflareChildReporter(cliLog); const voidConfig = readConfig(root); const atomic = options.atomic === true || voidConfig.deploy?.cloudflare?.mode === 'atomic'; if (atomic && (options.reuseExistingWorker || initialCloudflareLink)) { @@ -94,6 +110,7 @@ export async function runCloudflareDeploy( deps, initialCloudflareLink, initialCloudflareCandidate, + reporter, ); } @@ -121,6 +138,7 @@ export async function runCloudflareDeploy( deps, exit, cliLog, + reporter, initialCloudflareLink, detectedFramework, source, @@ -168,6 +186,7 @@ export async function runCloudflareDeploy( deps, exit: exitAfterMigrations, cliLog, + reporter, atomic, detectedFramework, initialCloudflareCandidate, diff --git a/packages/void/src/cli/deploy-managed-events.ts b/packages/void/src/cli/deploy-managed-events.ts index 6a2372dd3..3c3740b02 100644 --- a/packages/void/src/cli/deploy-managed-events.ts +++ b/packages/void/src/cli/deploy-managed-events.ts @@ -127,10 +127,9 @@ export async function streamDeployEvents( log.info('Deployment id was not received before the interrupt.'); } cliLog?.warn('stream_interrupted', { deploymentId }); - // Best-effort flush of the JSONL log before the process exits. The - // returned promise is intentionally not awaited — `process.exit(130)` - // would race the awaiter, and the OS flushes the page cache for the - // line-delimited file regardless. + // The JSONL logger writes each record synchronously, so the warning above + // is already on disk; close() only releases the file. Nothing to await + // before `process.exit(130)`. cliLog?.close().catch(() => {}); process.exit(130); }; diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index b6ecdd7a9..2b0bc774e 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1745,11 +1745,15 @@ function getDevCommand(root: string, framework?: DetectedFramework): string { } } +// Lines for a setup-node `with:` block, indented to match the templates below. +// Without check-latest, setup-node reuses any cached release that matches a +// partial version (`lts/*`, or `24` in .node-version), which can be older than +// the CLI's minimum and then fails its runtime check. An exact pin is unaffected. function getNodeVersionLine(root: string): string { - if (existsSync(join(root, '.node-version'))) { - return 'node-version-file: .node-version'; - } - return 'node-version: lts/*'; + const version = existsSync(join(root, '.node-version')) + ? 'node-version-file: .node-version' + : 'node-version: lts/*'; + return `${version}\n check-latest: true`; } // Per-package-manager install + setup steps. `void deploy` always runs through @@ -1781,7 +1785,11 @@ function getWorkflowSetupSteps( }; case 'bun': return { - setup: ' - uses: oven-sh/setup-bun@v2', + // bunx runs the CLI with the `node` on PATH, so pin a supported Node too. + setup: ` - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v6 + with: + ${nodeVersion}`, install: ' - run: bun install --frozen-lockfile', runner: 'bunx void', }; diff --git a/packages/void/src/cli/log-file.ts b/packages/void/src/cli/log-file.ts index 79f703bd2..dbd6c4e17 100644 --- a/packages/void/src/cli/log-file.ts +++ b/packages/void/src/cli/log-file.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import { createWriteStream, mkdirSync, type WriteStream } from 'node:fs'; +import { closeSync, mkdirSync, openSync, writeSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'pathslash'; @@ -12,7 +12,7 @@ export type CliLogger = { debug(msg: string, fields?: Record): void; /** path to the file backing this logger, or null if unable to open */ readonly path: string | null; - /** Close stream and return final path. Idempotent. */ + /** Release the file. Every record is already on disk when its call returns. Idempotent. */ close(): Promise; }; @@ -134,9 +134,12 @@ export function openDeployLog(opts?: { mirrorToStderr?: boolean }): CliLogger { const startedAt = Date.now(); let path: string | null = null; - let stream: WriteStream | null = null; + let fd: number | null = null; let broken = false; let closed = false; + // The CLI exits through process.exit() on many failure paths, and more than one of them may + // try to record the end of the deploy. Only the first deploy_end counts. + let ended = false; try { const dir = join(homedir(), '.void', 'logs'); @@ -147,17 +150,15 @@ export function openDeployLog(opts?: { mirrorToStderr?: boolean }): CliLogger { // refuse to open a file that already exists. const suffix = randomBytes(3).toString('hex'); const filePath = join(dir, `deploy-${ts}-${process.pid}-${suffix}.jsonl`); - stream = createWriteStream(filePath, { flags: 'wx', autoClose: true }); - stream.on('error', () => { - // Disable subsequent writes silently. Stream errors should never - // bubble up into the deploy pipeline. - broken = true; - }); + // Synchronous writes, so a record survives a process.exit() that follows it: a stream + // would still hold it in memory. The log can carry account and project details, so only + // the owner may read it. + fd = openSync(filePath, 'wx', 0o600); path = filePath; } catch { broken = true; path = null; - stream = null; + fd = null; } function write( @@ -166,6 +167,12 @@ export function openDeployLog(opts?: { mirrorToStderr?: boolean }): CliLogger { err: unknown | undefined, fields?: Record, ): void { + if (msg === 'deploy_end') { + if (ended) { + return; + } + ended = true; + } if (mirror) { try { process.stderr.write(`${formatStderrLine(level, msg, err, fields)}\n`); @@ -173,7 +180,7 @@ export function openDeployLog(opts?: { mirrorToStderr?: boolean }): CliLogger { // Stderr write failures are not actionable. } } - if (broken || !stream || closed) { + if (broken || fd === null || closed) { return; } const now = Date.now(); @@ -209,8 +216,13 @@ export function openDeployLog(opts?: { mirrorToStderr?: boolean }): CliLogger { line = `${JSON.stringify({ ts: now, elapsedMs: now - startedAt, lvl: level, msg, err: 'unserializable fields' })}\n`; } try { - stream.write(line); + const bytes = Buffer.from(line); + let offset = 0; + while (offset < bytes.length) { + offset += writeSync(fd, bytes, offset); + } } catch { + // A full disk or a closed descriptor must never break the deploy; stop logging instead. broken = true; } } @@ -236,16 +248,15 @@ export function openDeployLog(opts?: { mirrorToStderr?: boolean }): CliLogger { return Promise.resolve(); } closed = true; - if (!stream) { - return Promise.resolve(); - } - return new Promise((resolveClose) => { + if (fd !== null) { try { - stream!.end(() => resolveClose()); + closeSync(fd); } catch { - resolveClose(); + // Nothing is buffered, so a failed close loses no record. } - }); + fd = null; + } + return Promise.resolve(); }, }; } diff --git a/packages/void/src/cli/runtime-support.ts b/packages/void/src/cli/runtime-support.ts new file mode 100644 index 000000000..9c5bb452e --- /dev/null +++ b/packages/void/src/cli/runtime-support.ts @@ -0,0 +1,130 @@ +// Checks the runtime before the CLI loads. On an older Node, a static import of a +// newer API (such as `setGlobalProxyFromEnv` from `node:http`) fails at link time +// with a SyntaxError before any code can run, and child processes reuse +// `process.execPath`, so a deploy would stop halfway. This file must therefore +// load on any Node or Bun release: it has no imports and uses no new APIs. +// +// packages/void/src/cli/runtime-support.ts and packages/create-void/src/runtime-support.ts +// are byte-identical copies, because create-void runs before `void` is installed. +// A unit test keeps the two files and both `engines.node` fields equal. + +/** Must equal `engines.node` in packages/void and packages/create-void. */ +export const NODE_ENGINES = '^24.22.0 || >=26.12.0'; + +/** + * Bun is not a documented runtime, but `bunx --bun` and Bun-only images (where + * `node` is Bun) run the CLI on it. Bun reports a Node version below 26.12, so + * it gets its own floor: 1.4.0 is the first release where Void's Cloudflare + * helper process loads. This lets working setups keep working. + */ +export const BUN_MINIMUM = '1.4.0'; + +type Version = [major: number, minor: number, patch: number]; + +export type RuntimeVersions = { readonly node?: string; readonly bun?: string }; + +function isDigits(text: string): boolean { + if (text.length === 0) { + return false; + } + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } + } + return true; +} + +/** + * Parses `1.2.3`, with an optional leading `v`. A pre-release or build tag + * (`27.0.0-nightly2026...`, `1.4.0+abc`) is ignored, so a nightly counts as its + * release line. + */ +export function parseVersion(text: string): Version | null { + let core = text.trim(); + if (core.startsWith('v')) { + core = core.slice(1); + } + for (const separator of ['-', '+']) { + const index = core.indexOf(separator); + if (index !== -1) { + core = core.slice(0, index); + } + } + const parts = core.split('.'); + if (parts.length !== 3 || !parts.every(isDigits)) { + return null; + } + return [Number(parts[0]), Number(parts[1]), Number(parts[2])]; +} + +function compareVersions(a: Version, b: Version): number { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +type Clause = { caret: boolean; version: Version }; + +/** Supports only the forms used in `engines.node`: `^x.y.z` and `>=x.y.z`, joined by `||`. */ +function parseRange(range: string): Array { + return range.split('||').map((raw) => { + const clause = raw.trim(); + const caret = clause.startsWith('^'); + const prefix = caret ? '^' : clause.startsWith('>=') ? '>=' : null; + const version = prefix === null ? null : parseVersion(clause.slice(prefix.length)); + if (!version) { + throw new Error(`runtime-support: unsupported range clause '${clause}'`); + } + return { caret, version }; + }); +} + +export function satisfiesRange(version: Version, range: string): boolean { + return parseRange(range).some( + ({ caret, version: floor }) => + compareVersions(version, floor) >= 0 && (!caret || version[0] === floor[0]), + ); +} + +/** `^24.22.0 || >=26.12.0` -> `24.22+ or 26.12+`. */ +function describeRange(range: string): string { + return parseRange(range) + .map(({ version: [major, minor, patch] }) => + patch === 0 ? `${major}.${minor}+` : `${major}.${minor}.${patch}+`, + ) + .join(' or '); +} + +/** + * Returns the error to print when this runtime cannot run the CLI, or `null`. + * The text names only the runtime version and executable path, never env or argv. + */ +export function unsupportedRuntimeMessage( + versions: RuntimeVersions, + execPath: string, + command = 'void', +): string | null { + const nodeRange = describeRange(NODE_ENGINES); + if (versions.bun !== undefined) { + const bun = parseVersion(versions.bun); + const minimum = parseVersion(BUN_MINIMUM); + if (bun && minimum && compareVersions(bun, minimum) >= 0) { + return null; + } + return ( + `${command}: Bun ${BUN_MINIMUM} or later is required. Found Bun ${versions.bun} (${execPath}).\n` + + `Update Bun, or run ${command} on Node.js ${nodeRange}.` + ); + } + const node = versions.node === undefined ? null : parseVersion(versions.node); + if (node && satisfiesRange(node, NODE_ENGINES)) { + return null; + } + const found = versions.node === undefined ? 'an unknown version' : `v${versions.node}`; + return ( + `${command}: Node.js ${nodeRange} is required. Found Node.js ${found} (${execPath}).\n` + + 'bunx, npx and package scripts use the `node` on PATH; vp/vpx use .node-version.\n' + + 'Update .node-version or the node on PATH. In GitHub Actions add actions/setup-node\n' + + 'with `node-version: lts/*` and `check-latest: true` before this step.' + ); +} diff --git a/packages/void/test/integration/cli-runtime-check.test.ts b/packages/void/test/integration/cli-runtime-check.test.ts new file mode 100644 index 000000000..31cc4f3b3 --- /dev/null +++ b/packages/void/test/integration/cli-runtime-check.test.ts @@ -0,0 +1,73 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +const PACKAGE_ROOT = join(import.meta.dirname, '..', '..'); +const CLI_ENTRY = join(PACKAGE_ROOT, 'dist', 'cli', 'cli.mjs'); +const VERSION = ( + JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { version: string } +).version; + +let root: string; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'void-cli-runtime-')); +}); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +/** Runs the built bin with `process.versions` replaced before any CLI module loads. */ +function runWithVersions(versions: Record | null, args: Array) { + const execArgv: Array = []; + if (versions) { + const preload = join(root, `versions-${Object.values(versions).join('-')}.mjs`); + writeFileSync( + preload, + `Object.defineProperty(process, 'versions', { + value: { ...process.versions, ...${JSON.stringify(versions)} }, +});\n`, + ); + execArgv.push('--import', pathToFileURL(preload).href); + } + return spawnSync(process.execPath, [...execArgv, CLI_ENTRY, ...args], { + cwd: root, + encoding: 'utf8', + env: { PATH: process.env.PATH, HOME: root, USERPROFILE: root }, + }); +} + +describe('void runtime check (built bin)', () => { + it('stops on an unsupported Node before the CLI loads', () => { + const result = runWithVersions({ node: '22.23.4' }, ['--version']); + + expect(result.status).toBe(1); + expect(result.stderr).toBe( + `void: Node.js 24.22+ or 26.12+ is required. Found Node.js v22.23.4 (${process.execPath}).\n` + + 'bunx, npx and package scripts use the `node` on PATH; vp/vpx use .node-version.\n' + + 'Update .node-version or the node on PATH. In GitHub Actions add actions/setup-node\n' + + 'with `node-version: lts/*` and `check-latest: true` before this step.\n', + ); + // `--version` output would mean the main CLI module ran. + expect(result.stdout).toBe(''); + }); + + it('stops on Bun below the minimum', () => { + const result = runWithVersions({ bun: '1.3.14', node: '24.3.0' }, ['--version']); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('void: Bun 1.4.0 or later is required. Found Bun 1.3.14'); + expect(result.stdout).toBe(''); + }); + + it('hands off to the CLI on a supported runtime', () => { + const result = runWithVersions(null, ['--version']); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(VERSION); + }); +}); diff --git a/packages/void/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index 37827626e..0638abad6 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -927,6 +927,42 @@ it( CF_BUILD_TIMEOUT_MS, ); +it.each([ + { flags: { keep_vars: true }, keep: ['plain_text', 'json', 'secret_text', 'secret_key'] }, + { flags: { keep_secrets: true }, keep: ['secret_text', 'secret_key'] }, +])('keeps existing Worker secrets with $flags', async ({ flags, keep }) => { + // A written keep_bindings replaces cf's default, so keep_vars alone must still list secrets. + const output = worker({ + name: 'void-test', + compatibility_date: '2026-09-29', + main: 'index.js', + ...flags, + }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.unsafe?.metadata?.keep_bindings).toEqual(keep); + }, + ); +}); + +it('passes a user-written keep_bindings through unchanged', async () => { + const output = worker({ + name: 'void-test', + compatibility_date: '2026-09-29', + main: 'index.js', + unsafe: { metadata: { keep_bindings: ['plain_text'] } }, + }); + await withPrepared( + prepareCfBuildOutput(output, { accountId: 'account-id' }), + async (prepared) => { + const built = await readBuildOutput(prepared.directory); + expect(built.workers.default.config.unsafe?.metadata?.keep_bindings).toEqual(['plain_text']); + }, + ); +}); + it('deploys Worker Workflow declarations through cf build output', async () => { const output = worker({ name: 'void-test', diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index a5ba77c46..508c1b8bb 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -18,7 +18,8 @@ import { OutputContainerSchema, type ParsedInputConfig, } from '@cloudflare/config'; -import { afterEach, expect, it, vi } from 'vite-plus/test'; +import type { CloudflareCommandResult } from '@void/deploy-cloudflare'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { cloudflareCommandDiagnostic } from '../../../deploy-cloudflare/src/diagnostics.ts'; import { isVoidSecretOnlyDraft } from '../../../deploy-cloudflare/src/secret-draft.ts'; import { @@ -39,6 +40,8 @@ import { createCfVersionRunner, pinActiveWorkerSecrets, } from '../../src/cli/cf-version-runner.ts'; +import { readWranglerAuthToken } from '../../src/cli/deploy-cloudflare-auth.ts'; +import { formatCloudflareActivationError } from '../../src/cli/deploy-cloudflare-helpers.ts'; const roots: Array = []; const cleanups: Array<() => unknown> = []; @@ -597,7 +600,7 @@ it.each([ pinnedUpload: { accountId: 'account-id', apiBaseUrl, - getToken: async () => 'synthetic-token', + getToken: async () => ({ ok: true, token: 'synthetic-token' }), verifyUploader: () => true, }, verifyResources: createCfResourceVerifier({ @@ -1014,6 +1017,66 @@ it.each([false, true])( REAL_CF_TIMEOUT_MS, ); +it( + 'keeps secret bindings in an unpinned cf upload when only keep_vars is set', + async () => { + const prepared = await output({ keep_vars: true }); + const uploads: Array> = []; + const apiBaseUrl = await listen( + createServer(async (request, response) => { + const chunks: Array = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const path = new URL(request.url!, 'http://localhost').pathname; + let result: unknown = {}; + if (request.method === 'POST' && path.endsWith('/workers/scripts/void-test/versions')) { + const form = await new Request('http://localhost', { + method: 'POST', + headers: { 'content-type': request.headers['content-type']! }, + body: Buffer.concat(chunks), + }).formData(); + uploads.push(JSON.parse(String(form.get('metadata')))); + result = { id: '33333333-3333-4333-8333-333333333333', number: 1 }; + } else if ( + request.method === 'POST' && + path.endsWith('/workers/workers/void-test/versions') + ) { + uploads.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + result = { id: '22222222-2222-4222-8222-222222222222', urls: [] }; + } else if (path.endsWith('/services/void-test')) { + result = { + default_environment: { + environment: 'production', + script: { id: 'void-test', last_deployed_from: 'wrangler' }, + }, + }; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true, errors: [], messages: [], result })); + }), + ); + try { + const result = await pinnedCf(apiBaseUrl)(['workers', 'versions', 'create', '--prebuilt'], { + cwd: prepared.directory, + capture: true, + timeoutMs: REAL_CF_TIMEOUT_MS, + }); + expect(uploads, `${result.stdout}\n${result.stderr}`).toHaveLength(1); + // A keep_bindings without the secret types would make Cloudflare drop the live secrets. + expect(uploads[0]!.keep_bindings).toEqual([ + 'plain_text', + 'json', + 'secret_text', + 'secret_key', + ]); + } finally { + await prepared.dispose(); + } + }, + REAL_CF_TIMEOUT_MS, +); + async function verifyPinnedUpload(atomic: boolean): Promise { const prepared = await output( { @@ -2407,7 +2470,7 @@ it.each(['settings-change', 'keys-change', 'readiness-failure'] as const)( pinnedUpload: { accountId: 'account-id', apiBaseUrl: api.apiBaseUrl, - getToken: async () => 'synthetic-token', + getToken: async () => ({ ok: true, token: 'synthetic-token' }), verifyUploader: () => true, }, }); @@ -2698,7 +2761,7 @@ it.each([false, true])( pinnedUpload: { accountId: 'account-id', apiBaseUrl, - getToken: async () => 'synthetic-token', + getToken: async () => ({ ok: true, token: 'synthetic-token' }), verifyUploader: () => true, }, }); @@ -3143,7 +3206,7 @@ it.each([ pinnedUpload: { accountId: 'account-id', apiBaseUrl, - getToken: async () => 'synthetic-token', + getToken: async () => ({ ok: true, token: 'synthetic-token' }), verifyUploader: () => true, }, }); @@ -3695,3 +3758,368 @@ it('uploads when existing R2 and Queue bindings are verified by exact identity', prepared.dispose(); } }); + +describe('session credential failures', () => { + const nodeCrash = + "file:///void/dist/cli/cloudflare-operation-process.mjs:4\nimport { setGlobalProxyFromEnv } from 'node:http';\n ^^^^^^^^^^^^^^^^^^^^^\nSyntaxError: The requested module 'node:http' does not provide an export named 'setGlobalProxyFromEnv'\n\nNode.js v22.23.4"; + /** The deploy's getToken bridge over a fake helper child. */ + const tokenFrom = + (child: CloudflareCommandResult) => (): ReturnType => + readWranglerAuthToken(() => child, '/p/wrangler.jsonc'); + const pinnedRunner = ( + prepared: Awaited>, + getToken: () => ReturnType, + runCf: Parameters[0]['runCf'] = () => { + throw new Error('No cf command is expected.'); + }, + ) => { + prepared.activeSecrets = { versionId: '11111111-1111-4111-8111-111111111111', bindings: [] }; + return createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + verifyResources: async () => ({ status: 0, stdout: '', stderr: '' }), + runCf, + pinnedUpload: { + accountId: 'account-id', + apiBaseUrl: 'http://127.0.0.1:9/client/v4', + getToken, + verifyUploader: () => true, + }, + }); + }; + + it('keeps the helper child status and crash for the upload', async () => { + const prepared = await output(); + try { + const runner = pinnedRunner( + prepared, + tokenFrom({ status: 7, stdout: '', stderr: nodeCrash }), + ); + const upload = await runner( + { type: 'versions.upload', workerName: 'void-test' }, + { capture: true }, + ); + expect(upload.status).toBe(7); + expect(upload.error).toBeUndefined(); + // version.ts formats the upload failure the same way. + const diagnostic = cloudflareCommandDiagnostic(upload)!; + expect(diagnostic).toContain( + 'Could not get the Cloudflare session credential for the upload: the Void CLI helper process exited with code 7.', + ); + expect(diagnostic).toContain( + "SyntaxError: The requested module 'node:http' does not provide an export named 'setGlobalProxyFromEnv'", + ); + expect(diagnostic).toContain('Node.js v22.23.4'); + expect(diagnostic.toLowerCase()).not.toContain('bearer [redacted]'); + } finally { + await prepared.dispose(); + } + }); + + it('keeps the header for a helper child that could not run', async () => { + const prepared = await output(); + try { + const runner = pinnedRunner( + prepared, + tokenFrom({ status: 1, stdout: '', stderr: '', error: 'spawn EACCES' }), + ); + const upload = await runner( + { type: 'versions.upload', workerName: 'void-test' }, + { capture: true }, + ); + const text = + 'Could not get the Cloudflare session credential for the upload: the Void CLI helper process could not run: spawn EACCES.'; + // version.ts runs the formatter a second time over this result. + expect(cloudflareCommandDiagnostic(upload)).toBe(text); + expect(cloudflareCommandDiagnostic({ status: 1, stdout: '', stderr: text })).toBe(text); + } finally { + await prepared.dispose(); + } + }); + + it('names the next step for a Global API Key session', async () => { + const prepared = await output(); + try { + const runner = pinnedRunner( + prepared, + tokenFrom({ + status: 1, + stdout: '', + stderr: 'Cloudflare session has no OAuth bearer.', + data: { type: 'api_key' }, + }), + ); + const upload = await runner( + { type: 'versions.upload', workerName: 'void-test' }, + { capture: true }, + ); + expect(cloudflareCommandDiagnostic(upload)).toBe( + 'Could not get the Cloudflare session credential for the upload: the session uses a Global API Key (CLOUDFLARE_API_KEY), which this step cannot use. Set CLOUDFLARE_API_TOKEN, or unset CLOUDFLARE_API_KEY and run `void cloudflare login`, then retry.', + ); + } finally { + await prepared.dispose(); + } + }); + + it('keeps the helper child status and crash for the settings publisher', async () => { + const prepared = await output(); + const triggerConfig = join(roots.at(-1)!, 'trigger.json'); + writeFileSync( + triggerConfig, + JSON.stringify({ name: 'void-test', observability: { enabled: true } }), + ); + try { + const runner = pinnedRunner( + prepared, + tokenFrom({ status: 1, stdout: '', stderr: nodeCrash, signal: 'SIGKILL' }), + () => ({ status: 0, stdout: '', stderr: '' }), + ); + const result = await runner( + { type: 'triggers.deploy', configPath: triggerConfig, workerName: 'void-test' }, + { capture: true }, + ); + expect(result).toMatchObject({ status: 1, signal: 'SIGKILL' }); + const diagnostic = cloudflareCommandDiagnostic(result)!; + expect(diagnostic).toContain( + 'Could not get the Cloudflare session credential for publishing Worker settings: the Void CLI helper process was stopped by SIGKILL.', + ); + expect(diagnostic).toContain('Node.js v22.23.4'); + expect(diagnostic.toLowerCase()).not.toContain('bearer [redacted]'); + } finally { + await prepared.dispose(); + } + }); + + it('never reads an auth failure as a missing Worker that needs a bootstrap', async () => { + const prepared = await output(); + const getToken = vi.fn(async () => ({ + ok: false as const, + failure: { + status: 1, + stdout: '', + stderr: 'helper failed', + failure: 'not-found' as const, + errorCode: 10007, + errors: [{ code: 10007, message: 'The given script does not exist.' }], + }, + })); + const missing = { + status: 1, + stdout: '', + stderr: 'The given script does not exist. [code: 10007]', + errorCode: 10007, + failure: 'not-found' as const, + }; + try { + const runner = pinnedRunner(prepared, getToken, () => missing); + const operations: Array = []; + const result = await activateCloudflareVersion({ + workerName: 'void-test', + message: 'review', + verifyUploader: () => true, + verifyInheritedSecrets: true, + allowFirstDeploymentBootstrap: true, + runWrangler: (operation, command) => { + operations.push(operation.type); + return runner(operation, command); + }, + }); + expect(result).toMatchObject({ ok: false, reason: 'upload-failed', status: 1 }); + expect(result).not.toHaveProperty('cloudflareErrorCode'); + expect(operations).not.toContain('worker.deploy'); + expect(getToken).toHaveBeenCalledTimes(1); + } finally { + await prepared.dispose(); + } + }); +}); + +describe('settings credential before activation', () => { + const current = '11111111-1111-4111-8111-111111111111'; + const next = '22222222-2222-4222-8222-222222222222'; + const triggers = { + workers_dev: true, + preview_urls: true, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + }; + const apiKeySession = (): ReturnType => + readWranglerAuthToken( + () => ({ + status: 1, + stdout: '', + stderr: 'Cloudflare session has no OAuth bearer.', + data: { type: 'api_key' }, + }), + '/p/wrangler.jsonc', + ); + /** A staged deploy over a live Worker whose fake cf records every command. */ + const stagedDeploy = async ( + triggerConfig: Parameters[1], + getToken: () => ReturnType, + ) => { + const prepared = await output(); + const commands: Array = []; + try { + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + verifyResources: async () => ({ status: 0, stdout: '', stderr: '' }), + runCf: (args) => { + const command = args.slice(0, 3).join(' '); + commands.push(command); + if (command === 'workers deployments list') { + return { + status: 0, + stderr: '', + stdout: JSON.stringify({ + deployments: [{ versions: [{ version_id: current, percentage: 100 }] }], + }), + }; + } + if (command === 'workers versions get') { + return { status: 0, stderr: '', stdout: JSON.stringify({ id: args[3], bindings: [] }) }; + } + if (command === 'workers versions create') { + return { + status: 0, + stderr: '', + stdout: JSON.stringify({ versionId: next, previewUrl: 'https://ready.example.com' }), + }; + } + // Fail the first traffic change. + return { status: 1, stdout: '', stderr: 'Test stopped here.' }; + }, + pinnedUpload: { + accountId: 'account-id', + apiBaseUrl: 'http://127.0.0.1:9/client/v4', + getToken, + verifyUploader: () => true, + }, + }); + const result = await activateCloudflareVersion({ + runWrangler: runner, + workerName: 'void-test', + verifyUploader: () => true, + readinessAttempts: 1, + message: encodeCloudflareVersionMessage([], triggerConfig), + fetch: async () => { + commands.push('readiness'); + return Response.json({ ready: true }); + }, + }); + return { result, commands }; + } finally { + await prepared.dispose(); + } + }; + + it.each([ + { label: 'observability', settings: { observability: { enabled: true } } }, + { label: 'logpush', settings: { logpush: false } }, + ])('stops before upload when the $label publisher has no credential', async ({ settings }) => { + const getToken = vi.fn(apiKeySession); + const { result, commands } = await stagedDeploy({ ...triggers, ...settings }, getToken); + expect(result).toMatchObject({ ok: false, reason: 'upload-failed', status: 1 }); + expect(commands).not.toContain('workers versions create'); + expect(commands).not.toContain('workers deployments create'); + expect(commands).not.toContain('workers triggers deploy'); + expect(commands).not.toContain('readiness'); + expect(getToken).toHaveBeenCalledTimes(1); + const message = formatCloudflareActivationError(result as Exclude); + expect(message).toContain('production traffic is unchanged'); + expect(message).toContain( + 'Could not get the Cloudflare session credential for publishing Worker settings (observability, logpush), so no version was uploaded: the session uses a Global API Key (CLOUDFLARE_API_KEY), which this step cannot use.', + ); + }); + + it.each([ + { label: 'no Worker settings', config: triggers }, + { + label: 'a preserved snapshot', + config: { ...triggers, preserve: true, observability: { enabled: true } }, + }, + ])('reads no credential for $label', async ({ config }) => { + const getToken = vi.fn(async () => { + throw new Error('A deploy without Worker settings must not read the session credential.'); + }); + const { result, commands } = await stagedDeploy(config, getToken); + expect(result.ok).toBe(false); + expect(commands).toContain('workers versions create'); + expect(getToken).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'a fresh credential', + second: { ok: true as const, token: 'refreshed-token' }, + bearer: 'Bearer refreshed-token', + }, + { + label: 'the credential read before upload when the fresh read fails', + second: { + ok: false as const, + failure: { status: 1, stdout: '', stderr: 'helper failed' }, + }, + bearer: 'Bearer synthetic-token', + }, + ])('publishes the settings with $label', async ({ second, bearer }) => { + const authorization: Array = []; + const apiBaseUrl = await listen( + createServer((request, response) => { + authorization.push(request.headers.authorization); + request.resume(); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true, errors: [], result: {} })); + }), + ); + const prepared = await output(); + const triggerConfig = join(roots.at(-1)!, 'trigger.json'); + writeFileSync( + triggerConfig, + JSON.stringify({ name: 'void-test', observability: { enabled: true } }), + ); + const getToken = vi + .fn<() => ReturnType>() + .mockResolvedValueOnce({ ok: true, token: 'synthetic-token' }) + .mockResolvedValueOnce(second); + try { + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + verifyResources: async () => ({ status: 0, stdout: '', stderr: '' }), + runCf: (args) => ({ + status: 0, + stderr: '', + stdout: args[1] === 'versions' ? JSON.stringify({ versionId: next }) : '', + }), + pinnedUpload: { accountId: 'account-id', apiBaseUrl, getToken, verifyUploader: () => true }, + }); + const upload = await runner( + { + type: 'versions.upload', + workerName: 'void-test', + message: encodeCloudflareVersionMessage([], { + ...triggers, + observability: { enabled: true }, + }), + }, + { capture: true }, + ); + expect(upload).toMatchObject({ status: 0, data: { versionId: next } }); + expect(getToken).toHaveBeenCalledTimes(1); + const published = await runner( + { type: 'triggers.deploy', configPath: triggerConfig, workerName: 'void-test' }, + { capture: true }, + ); + expect(published.status, published.stderr).toBe(0); + // The bearer read before upload may have expired while traffic moved. + expect(getToken).toHaveBeenCalledTimes(2); + expect(authorization).toEqual([bearer]); + } finally { + await prepared.dispose(); + } + }); +}); diff --git a/packages/void/test/unit/cloudflare-child-report.test.ts b/packages/void/test/unit/cloudflare-child-report.test.ts new file mode 100644 index 000000000..c49f093cd --- /dev/null +++ b/packages/void/test/unit/cloudflare-child-report.test.ts @@ -0,0 +1,196 @@ +import { cloudflareCommandDiagnostic, type CloudflareOperation } from '@void/deploy-cloudflare'; +import { describe, expect, it } from 'vite-plus/test'; +import { + cfOperationName, + createCloudflareChildReporter, + createCredentialRedactor, + describeCloudflareChildFailure, + formatCloudflareChildFailure, + observeCfCommand, + observeCloudflareRunner, + scrubCloudflareResult, +} from '../../src/cli/cloudflare-child-report.ts'; +import type { DeployWranglerRunResult } from '../../src/cli/deploy-types.ts'; +import type { CliLogger } from '../../src/cli/log-file.ts'; + +const TOKEN = 'fake-cloudflare-api-token-0123456789'; + +function recordingLog(): { log: CliLogger; calls: Array<[string, unknown]> } { + const calls: Array<[string, unknown]> = []; + const log: CliLogger = { + info: (msg, fields) => calls.push([msg, fields]), + warn: (msg, fields) => calls.push([msg, fields]), + error: (msg, err, fields) => calls.push([msg, { err, fields }]), + debug: (msg, fields) => calls.push([msg, fields]), + path: null, + close: async () => {}, + }; + return { log, calls }; +} + +describe('createCredentialRedactor', () => { + it('removes an exact token from stderr, error, errors[] and an Error stack', async () => { + const redactor = createCredentialRedactor({ CLOUDFLARE_API_TOKEN: TOKEN }); + const scrubbed = scrubCloudflareResult( + { + status: 1, + stdout: `stdout ${TOKEN}`, + stderr: `boom raw ${TOKEN}`, + error: `spawn failed ${TOKEN}`, + errors: [{ code: 10000, message: `provider ${TOKEN}` }], + }, + redactor, + ); + expect(JSON.stringify(scrubbed)).not.toContain(TOKEN); + expect(scrubbed.stderr).toBe('boom raw '); + expect(scrubbed.errors).toEqual([{ code: 10000, message: 'provider ' }]); + + const error = new Error(`thrown ${TOKEN}`); + const reporter = { ...createCloudflareChildReporter(recordingLog().log), redactor }; + const run = observeCloudflareRunner(() => { + throw error; + }, reporter); + await expect(run({ type: 'auth.whoami' }, { capture: true })).rejects.toBe(error); + expect(error.message).toBe('thrown '); + expect(error.stack).not.toContain(TOKEN); + }); + + it('scrubs before truncation, so a token across the 4096-char cap leaks no prefix', () => { + const redactor = createCredentialRedactor({ CLOUDFLARE_API_TOKEN: TOKEN }); + // The token starts 10 chars before the formatter's cut. + const stderr = `${'x'.repeat(4086)}${TOKEN}\nmore`; + const unsafe = cloudflareCommandDiagnostic({ status: 1, stdout: '', stderr })!; + expect(unsafe).toContain(TOKEN.slice(0, 10)); + const diagnostic = cloudflareCommandDiagnostic( + scrubCloudflareResult({ status: 1, stdout: '', stderr }, redactor), + )!; + expect(diagnostic).not.toContain(TOKEN.slice(0, 10)); + }); + + it('ignores short values, skips non-secret identifiers and replaces longer values first', () => { + const redactor = createCredentialRedactor({ + CF_API_KEY: 'short', + CLOUDFLARE_EMAIL: 'person@example.test', + CLOUDFLARE_ACCESS_CLIENT_ID: 'client-id-0123456789', + CLOUDFLARE_API_TOKEN: 'abcdefgh', + CF_API_TOKEN: 'abcdefgh-longer-value', + CF_ACCESS_CLIENT_SECRET: 'access-client-secret-value', + }); + expect( + redactor.redact( + 'short person@example.test client-id-0123456789 abcdefgh-longer-value abcdefgh access-client-secret-value', + ), + ).toBe('short person@example.test client-id-0123456789 '); + }); + + it('covers an OAuth bearer learned at run time through add()', () => { + const redactor = createCredentialRedactor({}); + expect(redactor.redact(`x ${TOKEN}`)).toBe(`x ${TOKEN}`); + redactor.add(TOKEN); + expect(redactor.redact(`x ${TOKEN}`)).toBe('x '); + }); +}); + +describe('observeCloudflareRunner', () => { + it('learns the auth.token bearer and never logs its stdout, data or input', async () => { + const { log, calls } = recordingLog(); + const reporter = createCloudflareChildReporter(log, {}); + const results: Record = { + 'auth.token': { + status: 0, + stdout: `{"token":"${TOKEN}"}`, + stderr: '', + data: { type: 'oauth', token: TOKEN }, + }, + 'auth.whoami': { status: 1, stdout: '', stderr: `cf echoed ${TOKEN}` }, + }; + const run = observeCloudflareRunner( + (operation) => results[operation.type] ?? { status: 0, stdout: '', stderr: '' }, + reporter, + ); + const token = await run({ type: 'auth.token', configPath: '/p/wrangler.jsonc' } as never, { + capture: true, + input: `secret-input-${TOKEN}\n`, + }); + // The caller still gets the credential. + expect(token.data).toEqual({ type: 'oauth', token: TOKEN }); + const whoami = await run({ type: 'auth.whoami' }, { capture: true }); + expect(whoami.stderr).toBe('cf echoed '); + expect(JSON.stringify(calls)).not.toContain(TOKEN); + expect(calls).toEqual([ + ['cloudflare_op', { op: 'auth.token', ok: true, status: 0, ms: expect.any(Number) }], + [ + 'cloudflare_op', + { + op: 'auth.whoami', + ok: false, + status: 1, + ms: expect.any(Number), + diagnostic: 'cf echoed ', + }, + ], + ]); + }); + + it('never formats a failed auth.token stdout', async () => { + const { log, calls } = recordingLog(); + const run = observeCloudflareRunner( + () => ({ status: 1, stdout: `[ERROR] envelope ${TOKEN}`, stderr: 'helper crashed' }), + createCloudflareChildReporter(log, {}), + ); + await run({ type: 'auth.token' } as CloudflareOperation, { capture: true }); + expect(JSON.stringify(calls)).not.toContain(TOKEN); + expect(calls[0]![1]).toMatchObject({ ok: false, diagnostic: 'helper crashed' }); + }); + + it('logs a signal and names cf commands without their values or flags', async () => { + const { log, calls } = recordingLog(); + const run = observeCfCommand( + () => ({ status: 1, stdout: '', stderr: '', signal: 'SIGKILL' }), + createCloudflareChildReporter(log, {}), + ); + await run(['workers', 'triggers', 'deploy', '--prebuilt'], { cwd: '/', capture: true }); + expect(calls).toEqual([ + [ + 'cloudflare_op', + { + op: 'cf workers triggers deploy', + ok: false, + status: 1, + signal: 'SIGKILL', + ms: expect.any(Number), + }, + ], + ]); + expect(cfOperationName(['secret', '--name', 'X'])).toBe('cf secret'); + expect(cfOperationName(['a', 'b', 'c', 'd'])).toBe('cf a b c'); + }); +}); + +describe('describeCloudflareChildFailure', () => { + it('names the exit code, the signal or the spawn error', () => { + expect(describeCloudflareChildFailure({ status: 1 })).toBe( + 'the Void CLI helper process exited with code 1', + ); + expect(describeCloudflareChildFailure({ status: 1, signal: 'SIGKILL' })).toBe( + 'the Void CLI helper process was stopped by SIGKILL', + ); + expect(describeCloudflareChildFailure({ status: 1, error: 'spawn EACCES' })).toBe( + 'the Void CLI helper process could not run: spawn EACCES', + ); + expect( + formatCloudflareChildFailure({ + status: 1, + stderr: + "SyntaxError: The requested module 'node:http' does not provide an export named 'setGlobalProxyFromEnv'\n\nNode.js v22.23.4", + }), + ).toBe( + [ + 'the Void CLI helper process exited with code 1.', + "SyntaxError: The requested module 'node:http' does not provide an export named 'setGlobalProxyFromEnv'", + '', + 'Node.js v22.23.4', + ].join('\n'), + ); + }); +}); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 28e3269f9..4234ab84e 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -1,16 +1,19 @@ import { existsSync, mkdirSync, + mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { stripVTControlCharacters } from 'node:util'; import { + cloudflareCommandDiagnostic, CloudflareWorkerInspectionError, parseCloudflareVersionMessage, parseRootWranglerConfig, @@ -18,7 +21,7 @@ import { } from '@void/deploy-cloudflare'; import { log } from '@voidzero-dev/vite-plus-prompts'; import { toSlash } from 'pathslash'; -import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { activateCloudflareVersion as activateCloudflareVersionPrimitive } from '../../../deploy-cloudflare/src/index.ts'; import { operationFixture, @@ -38,6 +41,7 @@ import { } from '../../src/cli/deploy-binding-snapshot.ts'; import { ensureCloudflareAuthSecret, + readWranglerAuthToken, runWranglerAuthToken, } from '../../src/cli/deploy-cloudflare-auth.ts'; import { @@ -77,9 +81,10 @@ import { type SelfHostEnvContext, type SelfHostSchemaFacts, } from '../../src/cli/env-validation.ts'; -import type { CliLogger } from '../../src/cli/log-file.ts'; +import { openDeployLog, type CliLogger } from '../../src/cli/log-file.ts'; import { migrateLegacyVoidConfig } from '../../src/cli/migrate.ts'; import { FRAMEWORK_PRESETS } from '../../src/cli/preset.ts'; +import { parseProjectArgs } from '../../src/cli/project-args.ts'; import { provisionCloudflareResources, type ProvisionResult } from '../../src/cli/provision.ts'; import { FRIENDLY_BINDING_NAMES, @@ -92,6 +97,12 @@ import { readConfig } from '../../src/config.ts'; // `runDeploy` calls `runCloudflareDeploy` without deps. A test that starts from `runDeploy` sets // these deps. Calls that pass their own deps are unchanged. +// Deploy-log tests point homedir() at a temp folder; every other caller keeps the real one. +const logHome = vi.hoisted(() => ({ dir: '' })); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => logHome.dir || actual.homedir() }; +}); const runDeployCloudflareDeps = vi.hoisted(() => ({ current: undefined as CloudflareDeployDeps | undefined, })); @@ -175,6 +186,102 @@ const cleanExit: DeployWranglerRunResult = { error: null, }; +// Most native deploy failures leave through exit(1), which skips the deploy_end that runDeploy +// writes on a throw. These tests read the JSONL file back from a temp home folder. +describe('Cloudflare deploy log end record', () => { + beforeEach(() => { + logHome.dir = mkdtempSync(join(tmpdir(), 'void-deploy-log-')); + }); + afterEach(() => { + rmSync(logHome.dir, { recursive: true, force: true }); + logHome.dir = ''; + }); + + function openRealLog(opts?: { mirrorToStderr?: boolean }): { + cliLog: CliLogger; + readRecords: () => Array>; + } { + const cliLog = openDeployLog(opts); + expect(cliLog.path?.startsWith(logHome.dir)).toBe(true); + return { + cliLog, + readRecords: () => + readFileSync(cliLog.path!, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line) as Record), + }; + } + + it('writes deploy_end with the exit code when an early check exits', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'node-app' }), + 'void.json': JSON.stringify({ target: 'node' }), + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123' }), + }); + const { cliLog, readRecords } = openRealLog(); + const { deps, exitCalls } = harness(); + const output = vi.spyOn(log, 'error').mockImplementation(() => {}); + try { + await expect(runCloudflareDeploy(root, {}, cliLog, deps)).rejects.toBeInstanceOf(ExitError); + // Read before close(): each record must already be on disk when exit() runs. + const ends = readRecords().filter((record) => record.msg === 'deploy_end'); + expect(exitCalls).toEqual([1]); + expect(ends).toEqual([expect.objectContaining({ lvl: 'error', ok: false, exitCode: 1 })]); + } finally { + output.mockRestore(); + await cliLog.close(); + } + }); + + it.each(['worker', 'static'] as const)( + 'writes deploy_end once when the %s activation fails and then exits', + async (kind) => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'site', dependencies: { void: '*' } }), + 'void.json': '{}', + 'routes/index.ts': 'export const GET = () => "site";', + 'public/index.html': '

Site

', + 'wrangler.jsonc': JSON.stringify({ + name: 'site', + account_id: 'personal', + compatibility_date: '2026-02-24', + }), + }); + // The --debug mirror prints every record it accepts, even after close(). + const stderrLines: Array = []; + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrLines.push(String(chunk)); + return true; + }); + const { cliLog, readRecords } = openRealLog({ mirrorToStderr: true }); + const { deps, exitCalls } = harness({ + activateVersion: async () => ({ ok: false, reason: 'upload-failed', status: 1 }), + }); + const output = vi.spyOn(log, 'error').mockImplementation(() => {}); + try { + await expect( + runCloudflareDeploy(root, kind === 'static' ? { dir: 'public' } : {}, cliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + const records = readRecords(); + expect(exitCalls).toEqual([1]); + expect(records.filter((record) => record.msg === 'deploy_end')).toHaveLength(1); + expect(records.at(-1)).toEqual( + expect.objectContaining({ msg: 'deploy_end', ok: false, exitCode: 1 }), + ); + expect(records.map((record) => record.msg)).toContain('cloudflare_deploy_failed'); + expect(stderrLines.filter((line) => line.startsWith('error deploy_end '))).toEqual([ + 'error deploy_end ok=false exitCode=1\n', + ]); + } finally { + stderr.mockRestore(); + output.mockRestore(); + await cliLog.close(); + } + }, + ); +}); + describe('Cloudflare activation diagnostics', () => { it.each(['worker', 'static'] as const)( 'flushes the %s upload diagnostic before exiting', @@ -221,11 +328,22 @@ describe('Cloudflare activation diagnostics', () => { await expect( runCloudflareDeploy(root, kind === 'static' ? { dir: 'public' } : {}, logger, deps), ).rejects.toBeInstanceOf(ExitError); - expect(order).toEqual(['error', 'close', 'exit']); + // cloudflare_deploy_failed, then deploy_end, both flushed before exit. The exit wrapper + // repeats deploy_end; the real logger drops it (see 'writes deploy_end once' below). + expect(order).toEqual(['error', 'error', 'close', 'error', 'exit']); expect(error).toHaveBeenCalledWith( 'cloudflare_deploy_failed', expect.objectContaining({ message: expect.stringContaining(diagnostic) }), + expect.objectContaining({ + reason: 'upload-failed', + status: 1, + cloudflareErrorCode: 10021, + }), ); + expect(error).toHaveBeenLastCalledWith('deploy_end', undefined, { + ok: false, + exitCode: 1, + }); expect(output).toHaveBeenCalledWith(expect.stringContaining(diagnostic)); expect(output).toHaveBeenCalledWith( expect.stringContaining('Detailed log: /tmp/deploy-diagnostic.jsonl'), @@ -236,6 +354,104 @@ describe('Cloudflare activation diagnostics', () => { }, ); + it.each(['worker', 'static'] as const)( + 'logs each %s child op and the failed upload without the Cloudflare token', + async (kind) => { + const token = 'fake-cloudflare-api-token-0123456789'; + vi.stubEnv('CLOUDFLARE_API_TOKEN', token); + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'site', dependencies: { void: '*' } }), + 'void.json': '{}', + 'routes/index.ts': 'export const GET = () => "site";', + 'public/index.html': '

Site

', + 'wrangler.jsonc': JSON.stringify({ + name: 'site', + account_id: 'personal', + compatibility_date: '2026-02-24', + }), + }); + const records: Array<{ lvl: string; msg: string; text: string; fields?: unknown }> = []; + const push = (lvl: string, msg: string, fields?: unknown, err?: unknown) => + records.push({ + lvl, + msg, + fields, + // Error objects serialize to {}, so keep their text explicitly. + text: JSON.stringify([ + msg, + fields, + err instanceof Error ? [err.message, err.stack] : err, + ]), + }); + const logger: CliLogger = { + info: (msg, fields) => push('info', msg, fields), + warn: (msg, fields) => push('warn', msg, fields), + error: (msg, err, fields) => push('error', msg, fields, err), + debug: (msg, fields) => push('debug', msg, fields), + path: null, + close: async () => {}, + }; + const output = vi.spyOn(log, 'error').mockImplementation(() => {}); + const { deps } = harness({ + runWrangler: operationFixture((args) => { + if (args[0] === 'whoami') { + return { + ...cleanExit, + stdout: '{"loggedIn":true,"accounts":[]}', + stderr: `using ${token}`, + }; + } + if (args[0] === 'deploy') { + return { ...cleanExit, status: 1, stderr: `upload rejected for ${token}` }; + } + return cleanExit; + }), + activateVersion: async (options) => { + const upload = await Promise.resolve( + options.runWrangler(operationFixtureRequest(['deploy']), { capture: true }), + ); + return { + ok: false, + reason: 'upload-failed', + status: upload.status, + diagnostic: cloudflareCommandDiagnostic(upload), + }; + }, + }); + try { + await expect( + runCloudflareDeploy(root, kind === 'static' ? { dir: 'public' } : {}, logger, deps), + ).rejects.toBeInstanceOf(ExitError); + for (const record of records) { + expect(record.text).not.toContain(token); + } + expect(JSON.stringify(output.mock.calls)).not.toContain(token); + expect(records).toContainEqual( + expect.objectContaining({ + lvl: 'debug', + msg: 'cloudflare_op', + fields: expect.objectContaining({ + op: 'worker.deploy', + ok: false, + status: 1, + diagnostic: 'upload rejected for ', + }), + }), + ); + const failed = records.find((record) => record.msg === 'cloudflare_deploy_failed')!; + expect(failed.fields).toMatchObject({ reason: 'upload-failed', status: 1 }); + expect(failed.text).toContain('upload rejected for '); + expect(records.at(-1)).toMatchObject({ + lvl: 'error', + msg: 'deploy_end', + fields: { ok: false, exitCode: 1 }, + }); + } finally { + output.mockRestore(); + } + }, + ); + it.each(['upload-failed', 'bootstrap-failed', 'readiness-staging-failed'] as const)( 'prints the underlying diagnostic for %s', (reason) => { @@ -421,7 +637,7 @@ describe('Cloudflare activation diagnostics', () => { ).toContain('did not invoke the changed Wrangler binary'); }); - it.each(['readiness-failed', 'triggers-failed', 'uploader-changed'] as const)( + it.each(['readiness-failed', 'uploader-changed'] as const)( 'says when a forced %s restore put the old secret values back', (reason) => { const forced = formatCloudflareActivationError({ @@ -516,25 +732,138 @@ describe('Cloudflare activation diagnostics', () => { }, ); - it('distinguishes opaque trigger recovery from a first deployment', () => { - const opaque = formatCloudflareActivationError({ - ok: false, - reason: 'triggers-failed', - rollback: 'unavailable', - previousVersionId: '11111111-1111-4111-8111-111111111111', - }); - expect(opaque).toContain('Traffic or trigger restoration could not be completed'); - expect(opaque).not.toContain('Traffic was restored'); - expect(opaque).not.toContain('first deployment'); - - expect( + describe('trigger synchronization failure message', () => { + const N = '22222222-2222-4222-8222-222222222222'; + const P = '11111111-1111-4111-8111-111111111111'; + const X = '33333333-3333-4333-8333-333333333333'; + const reason = 'Queue "jobs" does not exist. [code: 11000]'; + const rollbackHint = `\`void project rollback ${P} --platform cloudflare\``; + const failure = (fields: Partial[0]>) => formatCloudflareActivationError({ ok: false, reason: 'triggers-failed', + status: 1, + versionId: N, + previousVersionId: P, + diagnostic: reason, + ...fields, + }); + // The hint must name a command the CLI accepts, not only a string that looks right. + const expectRunnableStatusHint = (message: string) => { + const start = message.indexOf('`void project status'); + expect(start).toBeGreaterThanOrEqual(0); + const command = message.slice(start + 1, message.indexOf('`', start + 1)); + expect(command).toBe('void project status'); + expect(parseProjectArgs(command.split(' ').slice(2))).toMatchObject({ subcommand: 'status' }); + }; + + it.each([ + ['an empty placeholder', 'placeholder', 'is an empty placeholder or secret-only draft'], + [ + 'an uninspectable version', + undefined, + `Void could not switch back to the previous version ${P}.`, + ], + ] as const)('keeps this deploy live after %s and offers no rollback', (_label, skip, why) => { + const message = failure({ + activeVersionId: N, + triggersRestored: 'no', + rollback: 'unavailable', + ...(skip ? { restoreSkipped: skip } : {}), + }); + expect(message).toContain(`Live now: ${N} (this deploy).`); + expect(message).toContain('may be partly updated'); + expect(message).toContain(why); + expect(message).toContain('`void deploy --platform cloudflare` again'); + expect(message).toContain(reason); + expect(message).not.toContain('void project rollback'); + expect(message).not.toContain('switched traffic back'); + }); + + it.each([ + ['no-trigger-snapshot', 'has no Void trigger snapshot to restore'], + ['atomic', 'published at 100% directly'], + ] as const)('offers a rollback to a real previous app kept inactive (%s)', (skip, why) => { + const message = failure({ + activeVersionId: N, + triggersRestored: 'no', rollback: 'unavailable', + restoreSkipped: skip, + }); + expect(message).toContain(`Live now: ${N} (this deploy).`); + expect(message).toContain(why); + expect(message).toContain(rollbackHint); + expect(message).toContain(reason); + }); + + it('says a first deployment has nothing to switch back to', () => { + const message = failure({ previousVersionId: null, - }), - ).toContain('This was the first deployment'); + activeVersionId: N, + triggersRestored: 'no', + rollback: 'unavailable', + }); + expect(message).toContain(`Live now: ${N} (this deploy).`); + expect(message).toContain('This was the first deployment'); + expect(message).not.toContain('void project rollback'); + }); + + it('says the previous version is live with its triggers restored', () => { + const message = failure({ + activeVersionId: P, + triggersRestored: 'yes', + rollback: 'succeeded', + }); + expect(message).toContain( + `Live now: ${P} (the previous version). Void switched traffic back to it and restored its routes, schedules, queues, and workflows.`, + ); + expect(message).toContain(reason); + expect(message).not.toContain('void project rollback'); + }); + + it.each(['failed', 'unsafe'] as const)( + 'says the previous version is live without its triggers (rollback %s)', + (rollback) => { + // 'unsafe' includes a restore skipped because another operation already moved traffic. + const message = failure({ activeVersionId: P, triggersRestored: 'no', rollback }); + expect(message).toContain(`Live now: ${P} (the previous version).`); + expect(message).toContain('so they may be partly updated'); + expectRunnableStatusHint(message); + expect(message).not.toContain('restored its routes'); + expect(message).not.toContain('switched traffic back'); + expect(message).toContain(reason); + }, + ); + + it('names a version another operation made live during recovery', () => { + const message = failure({ activeVersionId: X, triggersRestored: 'yes', rollback: 'failed' }); + expect(message).toContain(`Live now: ${X}. Another Cloudflare operation changed production`); + expectRunnableStatusHint(message); + expect(message).not.toContain('restored its routes'); + }); + + it.each([ + ['no', 'unsafe'], + ['unknown', 'failed'], + ] as const)('says the live version is unknown (triggers %s)', (triggersRestored, rollback) => { + const message = failure({ activeVersionId: null, triggersRestored, rollback }); + expect(message).toContain('Live now: unknown.'); + expectRunnableStatusHint(message); + expect(message).toContain(reason); + expect(message).not.toContain('void project rollback'); + expect(message.includes('could not confirm the result')).toBe(triggersRestored === 'unknown'); + }); + + it('keeps the older-secrets note after a forced switch back', () => { + const message = failure({ + activeVersionId: P, + triggersRestored: 'yes', + rollback: 'succeeded', + restoredOlderSecrets: true, + }); + expect(message).toContain(`Live now: ${P} (the previous version).`); + expect(message).toContain('The previous version runs with its old secret values'); + }); }); it('reports uncertain status after publication without claiming nothing was uploaded', () => { @@ -11616,6 +11945,77 @@ describe('runWranglerAuthToken — the session-bearer bridge, on its own', () => }); }); +describe('readWranglerAuthToken — keeps the helper child failure', () => { + const read = (result: DeployWranglerRunResult) => + readWranglerAuthToken( + operationFixture(() => result), + '/p/wrangler.jsonc', + ); + + it('returns the token, or a fresh failure with the real status and crash line', async () => { + expect(await read({ ...cleanExit, stdout: '{"type":"api_token","token":"abc"}' })).toEqual({ + ok: true, + token: 'abc', + }); + const crashed = await read({ + ...cleanExit, + status: 1, + stderr: + "SyntaxError: The requested module 'node:http' does not provide an export named 'setGlobalProxyFromEnv'\n\nNode.js v22.23.4", + errorCode: 10007, + failure: 'not-found', + }); + expect(crashed).toEqual({ + ok: false, + failure: { + status: 1, + stdout: '', + stderr: [ + 'the Void CLI helper process exited with code 1.', + "SyntaxError: The requested module 'node:http' does not provide an export named 'setGlobalProxyFromEnv'", + '', + 'Node.js v22.23.4', + ].join('\n'), + }, + }); + }); + + it('never reports a clean status for a missing credential', async () => { + expect(await read({ ...cleanExit, stdout: '{"type":"oauth"}' })).toEqual({ + ok: false, + failure: { + status: 1, + stdout: '', + stderr: 'the Void CLI helper process returned no session credential.', + }, + }); + expect(await read({ ...cleanExit, signal: 'SIGKILL' })).toMatchObject({ + ok: false, + failure: { + status: 1, + signal: 'SIGKILL', + stderr: 'the Void CLI helper process was stopped by SIGKILL.', + }, + }); + }); +}); + +describe('Void-written credential failure texts', () => { + it('pass through the shared Cloudflare diagnostic formatter unchanged', () => { + for (const text of [ + 'Could not get the Cloudflare session credential for the upload: the Void CLI helper process exited with code 1.', + 'Could not get the Cloudflare session credential for publishing Worker settings: the Void CLI helper process was stopped by SIGKILL.', + 'Could not get the Cloudflare session credential for the upload: the Void CLI helper process could not run: spawn EACCES.', + 'Could not get the Cloudflare session credential for the upload: the Void CLI helper process returned no session credential.', + 'Could not get the Cloudflare session credential for the upload: the session uses a Global API Key (CLOUDFLARE_API_KEY), which this step cannot use. Set CLOUDFLARE_API_TOKEN, or unset CLOUDFLARE_API_KEY and run `void cloudflare login`, then retry.', + 'Cloudflare session has no OAuth bearer.', + 'cf auth whoami exited with code 1.', + ]) { + expect(cloudflareCommandDiagnostic({ status: 1, stdout: '', stderr: text })).toBe(text); + } + }); +}); + describe('runCloudflareDeploy — `addresses` is whole-object pinned across the build', () => { function writeEmittedDeployArtifact(root: string, generated: Record): void { mkdirSync(join(root, '.wrangler', 'deploy'), { recursive: true }); diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index cc92d0797..212f51e95 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -485,6 +485,43 @@ it('reports an absent cf session as authentication failure', async () => { expect(output.stderr).toContain('authentication is required'); }, 30_000); +it.each([false, true])( + 'forwards the cf auth whoami failure from auth.token (Global API Key=%s)', + async (apiKey) => { + const directory = temporaryDirectory('void-cf-token-whoami-'); + const preload = join(directory, 'mock-cf.mjs'); + writeFileSync( + preload, + `import child from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +child.spawnSync = () => ({ status: 1, stdout: '', stderr: 'Not logged in. [code: 10000]\\n' }); +syncBuiltinESMExports(); +`, + ); + const output = await runOperation( + directory, + { type: 'auth.token' }, + { + ...systemEnv, + CI: 'true', + HOME: directory, + USERPROFILE: directory, + ...(apiKey + ? { CLOUDFLARE_API_KEY: 'synthetic-key', CLOUDFLARE_EMAIL: 'a@example.test' } + : {}), + }, + preload, + ); + expect(output.status, output.stderr).toBe(1); + const decoded = decodeCloudflareOperationResult(output); + expect(decoded.stderr).toContain( + 'cf auth whoami exited with code 1.\nNot logged in. [code: 10000]', + ); + expect(decoded.data).toEqual(apiKey ? { type: 'api_key' } : undefined); + }, + 30_000, +); + it('discovers KV namespaces beyond the first cf API page', async () => { const directory = temporaryDirectory('void-cf-kv-pages-'); const requestedPages: Array = []; diff --git a/packages/void/test/unit/deploy-events.test.ts b/packages/void/test/unit/deploy-events.test.ts index d81ab2b10..cdb215190 100644 --- a/packages/void/test/unit/deploy-events.test.ts +++ b/packages/void/test/unit/deploy-events.test.ts @@ -1,5 +1,15 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it, expect, vi } from 'vite-plus/test'; +// Deploy-log tests point homedir() at a temp folder; every other caller keeps the real one. +const logHome = vi.hoisted(() => ({ dir: '' })); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => logHome.dir || actual.homedir() }; +}); + vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ intro: vi.fn(), outro: vi.fn(), @@ -18,7 +28,7 @@ import { warnIgnoredNotFoundForFramework, warnUnpublishedRouting, } from '../../src/cli/deploy-managed-events.ts'; -import type { CliLogger } from '../../src/cli/log-file.ts'; +import { openDeployLog, type CliLogger } from '../../src/cli/log-file.ts'; import type { DeployEvent } from '../../src/cli/types.ts'; type DoneEvent = Extract; @@ -260,6 +270,54 @@ it('explains stalled deployment progress once and reports recovery without claim }); describe('streamDeployEvents', () => { + // Ctrl-C during a managed deploy exits with 130 without awaiting anything, so the records + // must already be on disk. The log goes to a temp home folder. + it('has every managed deploy record on disk when Ctrl-C exits', async () => { + logHome.dir = mkdtempSync(join(tmpdir(), 'void-deploy-log-')); + const { spinner } = createMockSpinner(); + const cliLog = openDeployLog(); + expect(cliLog.path?.startsWith(logHome.dir)).toBe(true); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const before = new Set(process.listeners('SIGINT')); + let reachedWait!: () => void; + const waiting = new Promise((resolve) => { + reachedWait = resolve; + }); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + async function* events(): AsyncGenerator { + yield { event: 'start', deploymentId: 'dpl_1' }; + reachedWait(); + await released; + } + try { + const stream = streamDeployEvents('proj_1', events(), spinner, cliLog); + await waiting; + const onSigint = process.listeners('SIGINT').find((listener) => !before.has(listener)); + expect(onSigint).toBeTypeOf('function'); + // Call the handler directly: emitting SIGINT would reach the test runner's own handlers. + (onSigint as () => void)(); + expect(exit).toHaveBeenCalledWith(130); + + const lines = readFileSync(cliLog.path!, 'utf8').trimEnd().split('\n'); + expect(lines.map((line) => (JSON.parse(line) as { msg: string }).msg)).toEqual([ + 'stream_start', + 'stream_deployment_id', + 'stream_interrupted', + ]); + + release(); + await expect(stream).rejects.toThrow(); + } finally { + exit.mockRestore(); + await cliLog.close(); + rmSync(logHome.dir, { recursive: true, force: true }); + logHome.dir = ''; + } + }); + it('logs deploy_success and returns the done event on a published deploy', async () => { const { spinner } = createMockSpinner(); const { logger, calls } = createMockCliLog(); diff --git a/packages/void/test/unit/deploy-log-file.test.ts b/packages/void/test/unit/deploy-log-file.test.ts index 49e752372..26017ea19 100644 --- a/packages/void/test/unit/deploy-log-file.test.ts +++ b/packages/void/test/unit/deploy-log-file.test.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -48,4 +49,81 @@ describe('openDeployLog', () => { expect(secondText).toContain('second_only'); expect(secondText).not.toContain('first_only'); }); + + // Many CLI failure paths call process.exit() right after logging. A buffered stream would + // lose every record still in memory at that point, including deploy_end. + it('keeps every record on disk when the process exits right after writing', () => { + // A file URL, not a path: import() reads a Windows drive path as a 'c:' URL scheme. + const logFile = new URL('../../src/cli/log-file.ts', import.meta.url).href; + const script = [ + `const { openDeployLog } = await import(${JSON.stringify(logFile)});`, + 'const log = openDeployLog();', + "for (let i = 0; i < 100; i++) log.info('record', { i });", + "log.error('deploy_end', undefined, { ok: false, exitCode: 1 });", + 'process.exit(1);', + ].join('\n'); + // The child resolves homedir() from these, so its log lands in the test's home. + const child = spawnSync(process.execPath, ['--input-type=module', '--eval', script], { + env: { ...process.env, HOME: home.dir, USERPROFILE: home.dir }, + encoding: 'utf8', + }); + expect(child.stderr).toBe(''); + expect(child.status).toBe(1); + + const logsDir = join(home.dir, '.void', 'logs'); + const files = readdirSync(logsDir); + expect(files).toHaveLength(1); + const lines = readFileSync(join(logsDir, files[0]), 'utf8').trimEnd().split('\n'); + expect(lines).toHaveLength(101); + expect(JSON.parse(lines[99])).toMatchObject({ msg: 'record', i: 99 }); + expect(JSON.parse(lines[100])).toMatchObject({ msg: 'deploy_end', exitCode: 1 }); + }); + + it('records deploy_end once, in the file and in the stderr mirror', async () => { + const mirrored: Array = []; + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + mirrored.push(String(chunk)); + return true; + }); + try { + const log = openDeployLog({ mirrorToStderr: true }); + log.error('deploy_end', undefined, { ok: false, exitCode: 1 }); + log.error('deploy_end', new Error('later'), { ok: false }); + log.info('after_end'); + await log.close(); + + const records = readFileSync(log.path!, 'utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect(records.map((record) => record.msg)).toEqual(['deploy_end', 'after_end']); + expect(records[0]).toMatchObject({ ok: false, exitCode: 1 }); + expect(records[0]).not.toHaveProperty('err'); + expect(mirrored.filter((line) => line.includes('deploy_end'))).toHaveLength(1); + } finally { + stderr.mockRestore(); + } + }); + + it.skipIf(process.platform === 'win32')( + 'creates the file readable by its owner only', + async () => { + const log = openDeployLog(); + await log.close(); + expect(statSync(log.path!).mode & 0o777).toBe(0o600); + }, + ); + + it('keeps working without a file when the log directory cannot be created', () => { + // A file where the directory should be makes mkdir fail. + rmSync(home.dir, { recursive: true, force: true }); + writeFileSync(home.dir, ''); + try { + const log = openDeployLog(); + expect(log.path).toBeNull(); + expect(() => log.info('ignored')).not.toThrow(); + } finally { + rmSync(home.dir, { force: true }); + } + }); }); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 9c92a67f8..b05c80a01 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -239,6 +239,64 @@ describe('GitHub Actions deployment workflows', () => { expect(yaml).not.toContain('VOID_TOKEN'); }); + it('sets up a supported Node before bunx runs the CLI in Bun workflows', () => { + // bunx runs the CLI with the `node` on PATH, which is the runner's default Node. + const yaml = getWorkflowYaml('/project', 'bun', null, 'cloudflare'); + expect(yaml).toContain( + ' - uses: oven-sh/setup-bun@v2\n' + + ' - uses: actions/setup-node@v6\n' + + ' with:\n' + + ' node-version: lts/*\n' + + ' check-latest: true\n' + + ' - run: bun install --frozen-lockfile\n', + ); + expect(yaml.indexOf('actions/setup-node@v6')).toBeLessThan( + yaml.indexOf('bunx void deploy --platform cloudflare'), + ); + + const managed = getWorkflowYaml( + '/project', + 'bun', + 'my-app', + 'void', + 'https://api.company.example', + ); + expect(managed.indexOf('actions/setup-node@v6')).toBeGreaterThan(-1); + expect(managed.indexOf('actions/setup-node@v6')).toBeLessThan( + managed.indexOf('bunx void connect'), + ); + }); + + it.each(['24.22.1', '24'])( + 'uses .node-version %s for the Bun workflow Node without a stale cached release', + (pin) => { + const root = mkdtempSync(join(tmpdir(), 'void-init-bun-node-')); + try { + writeFileSync(join(root, '.node-version'), `${pin}\n`); + const yaml = getWorkflowYaml(root, 'bun', null, 'cloudflare'); + expect(yaml).toContain( + ' - uses: actions/setup-node@v6\n' + + ' with:\n' + + ' node-version-file: .node-version\n' + + ' check-latest: true\n' + + ' - run: bun install --frozen-lockfile\n', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it('asks setup-node for the latest LTS release instead of a cached one', () => { + const yaml = getWorkflowYaml('/project', 'pnpm', null, 'cloudflare'); + expect(yaml).toContain( + ' with:\n' + + ' node-version: lts/*\n' + + ' check-latest: true\n' + + ' cache: pnpm\n', + ); + }); + it('retains the GitHub OIDC workflow for managed deployments', () => { const yaml = getWorkflowYaml( '/project', diff --git a/packages/void/test/unit/runtime-support.test.ts b/packages/void/test/unit/runtime-support.test.ts new file mode 100644 index 000000000..b52e316e3 --- /dev/null +++ b/packages/void/test/unit/runtime-support.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vite-plus/test'; +import { + BUN_MINIMUM, + NODE_ENGINES, + parseVersion, + unsupportedRuntimeMessage, +} from '../../src/cli/runtime-support.ts'; + +const PACKAGES = join(import.meta.dirname, '..', '..', '..'); +const EXEC_PATH = '/usr/local/bin/node'; + +function readEnginesNode(pkg: string): string { + const manifest = JSON.parse(readFileSync(join(PACKAGES, pkg, 'package.json'), 'utf8')) as { + engines: { node: string }; + }; + return manifest.engines.node; +} + +describe('runtime support check', () => { + it.each(['20.20.2', '22.23.4', '24.13.0', '24.21.0', '25.4.0', '26.11.9'])( + 'rejects Node %s', + (node) => { + const message = unsupportedRuntimeMessage({ node }, EXEC_PATH); + expect(message).toBe( + `void: Node.js 24.22+ or 26.12+ is required. Found Node.js v${node} (${EXEC_PATH}).\n` + + 'bunx, npx and package scripts use the `node` on PATH; vp/vpx use .node-version.\n' + + 'Update .node-version or the node on PATH. In GitHub Actions add actions/setup-node\n' + + 'with `node-version: lts/*` and `check-latest: true` before this step.', + ); + }, + ); + + it.each(['24.22.0', '24.22.1', '24.30.0', '26.12.0', '27.0.0', '27.0.0-nightly20261104abcdef'])( + 'accepts Node %s', + (node) => { + expect(unsupportedRuntimeMessage({ node }, EXEC_PATH)).toBeNull(); + }, + ); + + it('rejects a missing or malformed Node version', () => { + expect(unsupportedRuntimeMessage({}, EXEC_PATH)).toContain( + 'Found Node.js an unknown version (/usr/local/bin/node).', + ); + expect(unsupportedRuntimeMessage({ node: '24.x' }, EXEC_PATH)).toContain('Found Node.js v24.x'); + }); + + // Bun reports Node v26.3.0 (1.4.x) or v24.3.0 (1.3.x), so it is checked on its own version. + it('rejects Bun below the minimum even though it reports a Node version', () => { + expect(unsupportedRuntimeMessage({ bun: '1.3.14', node: '24.3.0' }, '/usr/local/bin/bun')).toBe( + 'void: Bun 1.4.0 or later is required. Found Bun 1.3.14 (/usr/local/bin/bun).\n' + + 'Update Bun, or run void on Node.js 24.22+ or 26.12+.', + ); + }); + + it.each(['1.4.0', '1.4.3'])('lets Bun %s through', (bun) => { + expect(unsupportedRuntimeMessage({ bun, node: '26.3.0' }, '/usr/local/bin/bun')).toBeNull(); + }); + + it('names the calling command in the message', () => { + const message = unsupportedRuntimeMessage({ node: '22.23.4' }, EXEC_PATH, 'create-void'); + expect(message?.split('\n')[0]).toBe( + `create-void: Node.js 24.22+ or 26.12+ is required. Found Node.js v22.23.4 (${EXEC_PATH}).`, + ); + }); + + it('parses versions', () => { + expect(parseVersion('v24.22.1')).toEqual([24, 22, 1]); + expect(parseVersion('1.4.0+build.5')).toEqual([1, 4, 0]); + expect(parseVersion('27.0.0-nightly2026')).toEqual([27, 0, 0]); + expect(parseVersion('24.22')).toBeNull(); + expect(parseVersion('24.22.x')).toBeNull(); + expect(parseVersion('')).toBeNull(); + expect(parseVersion(BUN_MINIMUM)).toEqual([1, 4, 0]); + }); + + it('keeps NODE_ENGINES equal to engines.node of void and create-void', () => { + expect(readEnginesNode('void')).toBe(NODE_ENGINES); + expect(readEnginesNode('create-void')).toBe(NODE_ENGINES); + }); + + it('keeps the create-void copy identical to the CLI copy', () => { + expect(readFileSync(join(PACKAGES, 'create-void', 'src', 'runtime-support.ts'), 'utf8')).toBe( + readFileSync(join(PACKAGES, 'void', 'src', 'cli', 'runtime-support.ts'), 'utf8'), + ); + }); +}); diff --git a/test/integration-timings.ts b/test/integration-timings.ts index ca05e614c..5b4402acd 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -11,6 +11,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/analog.test.ts': 38000, 'packages/void/test/integration/astro.test.ts': 26000, 'packages/void/test/integration/cli-help.test.ts': 1718, + 'packages/void/test/integration/cli-runtime-check.test.ts': 900, 'packages/void/test/integration/cloudflare-compatibility-build.test.ts': 5000, 'packages/void/test/integration/cloudflare-deploy-build.test.ts': 6700, 'packages/void/test/integration/cloudflare-isr-policy.test.ts': 15391,