From 55d0b4e7d54cabaad6d0afeca94f7b98f71ccd33 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 13 Oct 2026 21:33:25 +0100 Subject: [PATCH 01/15] Support temporary accounts with --temporary-account and --event-code --- .changeset/temporary-accounts.md | 15 + AGENTS.md | 30 +- packages/cli/AGENTS.md | 8 +- packages/cli/generator/plugin/args.ts | 6 +- .../commands/auth/device-login.test.ts | 1 + .../generator/plugin/arg-derivation.test.ts | 6 + .../cli/src/__tests__/lib/auth-token.test.ts | 6 +- .../cli/src/__tests__/lib/context.test.ts | 22 + .../src/__tests__/lib/deploy-input.test.ts | 2 + packages/cli/src/__tests__/lib/errors.test.ts | 34 +- .../src/__tests__/lib/schema-flags.test.ts | 25 + .../lib/telemetry/sanitization.test.ts | 15 + .../src/__tests__/temporary-accounts.test.ts | 375 +++++++++++++ packages/cli/src/commands/auth/logout.ts | 7 +- packages/cli/src/commands/auth/whoami.ts | 8 + packages/cli/src/index.ts | 51 +- packages/cli/src/lib/auth-token.ts | 35 +- packages/cli/src/lib/auth.ts | 12 + packages/cli/src/lib/cli-types.ts | 2 + packages/cli/src/lib/context.ts | 48 +- packages/cli/src/lib/errors.ts | 28 + packages/cli/src/lib/oauth/index.ts | 82 +++ packages/cli/src/lib/schema-flags.ts | 7 +- .../cli/src/lib/telemetry/sanitization.ts | 7 +- packages/wrangler-tests/MANIFEST.md | 44 +- packages/wrangler-tests/README.md | 2 +- .../scripts/generate-upstream-stubs.mjs | 20 - .../src/__tests__/d1/migrate.test.ts | 31 +- .../src/__tests__/deploy/core.test.ts | 511 ++++++++++++++++++ .../helpers/mock-temporary-account.ts | 114 ++++ .../src/__tests__/index.test.ts | 25 +- .../src/__tests__/logout.test.ts | 20 + .../__tests__/upstream/unported-01.test.ts | 1 - .../__tests__/upstream/unported-05.test.ts | 16 - .../__tests__/upstream/unported-07.test.ts | 22 - .../__tests__/upstream/unported-11.test.ts | 12 - .../wrangler-tests/src/__tests__/user.test.ts | 6 + .../versions/versions.upload.test.ts | 67 ++- .../src/__tests__/whoami.test.ts | 12 + 39 files changed, 1582 insertions(+), 153 deletions(-) create mode 100644 .changeset/temporary-accounts.md create mode 100644 packages/cli/src/__tests__/temporary-accounts.test.ts create mode 100644 packages/wrangler-tests/src/__tests__/deploy/core.test.ts create mode 100644 packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts diff --git a/.changeset/temporary-accounts.md b/.changeset/temporary-accounts.md new file mode 100644 index 0000000000..22a493c294 --- /dev/null +++ b/.changeset/temporary-accounts.md @@ -0,0 +1,15 @@ +--- +"cf": minor +--- + +Support temporary accounts with `--temporary-account` + +When no Cloudflare credentials are available, commands can now create or reuse +a short-lived account. cf prints the account's claim URL and uses that +account's ID and scoped API token for the command. + +When a command fails because no credentials exist, cf suggests rerunning it +with `--temporary-account`. + +Pass `--event-code` with `--temporary-account` to redeem an event code when cf +creates the account. cf does not cache, print, or record the code in telemetry. diff --git a/AGENTS.md b/AGENTS.md index 850c5afb99..c30498a15b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -657,24 +657,26 @@ These have stabilised this repo's voice; preserve them. Live globals (`packages/cli/src/index.ts:buildCli`): -| Flag | Alias | Purpose | -| -------------- | ----- | --------------------------------------------------------------- | -| `--help` | `-h` | Show help | -| `--version` | `-v` | Show plain version on stdout (branded banner on stderr) | -| `--quiet` | `-q` | Suppress non-essential output | -| `--zone` | `-z` | Zone ID or domain (overrides `CLOUDFLARE_ZONE_ID`) | -| `--profile` | — | Use a specific auth profile | -| `--mode` | `-m` | Mode used to evaluate `cloudflare.config.ts` | -| `--local` | — | Run against cf's global local Miniflare state | -| `--persist-to` | — | Override state directory (default `~/.config/cloudflare/state`) | - -There is no single global resolution order. Zone uses positional value → `--zone` → `CLOUDFLARE_ZONE_ID`; account uses `CLOUDFLARE_ACCOUNT_ID` → project settings → cached/prompted workers-auth selection; compliance region uses its environment variable → project settings; and the token uses `CLOUDFLARE_API_TOKEN` → stored OAuth credentials. There are no `--account-id`, `--api-token`, or `--compliance-region` flags. +| Flag | Alias | Purpose | +| --------------------- | ----- | --------------------------------------------------------------- | +| `--help` | `-h` | Show help | +| `--version` | `-v` | Show plain version on stdout (branded banner on stderr) | +| `--quiet` | `-q` | Suppress non-essential output | +| `--zone` | `-z` | Zone ID or domain (overrides `CLOUDFLARE_ZONE_ID`) | +| `--profile` | — | Use a specific auth profile | +| `--mode` | `-m` | Mode used to evaluate `cloudflare.config.ts` | +| `--temporary-account` | — | Use a temporary account when unauthenticated (hidden from help) | +| `--event-code` | — | Event code for a new temporary account (hidden from help) | +| `--local` | — | Run against cf's global local Miniflare state | +| `--persist-to` | — | Override state directory (default `~/.config/cloudflare/state`) | + +There is no single global resolution order. Zone uses positional value → `--zone` → `CLOUDFLARE_ZONE_ID`; account uses `--temporary-account` → `CLOUDFLARE_ACCOUNT_ID` → project settings → cached/prompted workers-auth selection; compliance region uses its environment variable → project settings; and the token uses `--temporary-account` → `CLOUDFLARE_API_TOKEN` → stored OAuth credentials. `--temporary-account` is rejected when credentials already exist or with `--local`. `--event-code` requires `--temporary-account`, is sent only when a new account is created, and is never cached or printed. There are no `--account-id`, `--api-token`, or `--compliance-region` flags. `--mode` is supplied through the CLI and is available as `ctx.mode` when function-form configuration exports are evaluated. Generated commands additionally get (per-command, not global): `--dry-run` on every command (mutating and read — reads omit the body preview), `--body` on almost every body-accepting operation, explicit `--file` on eligible upload shapes, and `--force` / `-f` on destructive operations. String-shaped body flags accept the general `@path` ingestion convention unless Forge opts out. -API fields that would emit an exact `--mode` flag are temporarily omitted until their upstream schemas stop using the reserved name. Body fields can still be supplied through `--body`; query, path, and header fields are temporarily unavailable. +API fields that would emit an exact `--mode`, `--temporary-account`, or `--event-code` flag are omitted to avoid shadowing global flags. Body fields can still be supplied through `--body`; query, path, and header fields are temporarily unavailable. Structured API output is JSON; generated `binary`/`text` endpoints write their payload verbatim. There is no global output-format `--format`, `--json`, `--ndjson`, or `--fields` switch. Some generated leaves do expose `format` or `fields` as endpoint-specific API parameters. Callers needing ndjson pipe structured output through `jq -c '.[]'`. @@ -693,7 +695,7 @@ Structured API output is JSON; generated `binary`/`text` endpoints write their p | `--ndjson` | — | Output-format toggle | | `--pretty` | — | Output-format toggle | -`--mode` is registered globally and exact colliding API fields are temporarily omitted. `--remote` exists on a generated Pay Per Crawl command. The other exact names in this table are not registered as root/global options today and are normally rejected outside a leaf that defines them. The in-source design list is the `Reserved (future)` comment in `src/index.ts`; it does not itself register or reject options. +`--mode`, `--temporary-account`, and `--event-code` are registered globally and exact colliding API fields are omitted. `--remote` exists on a generated Pay Per Crawl command. The other exact names in this table are not registered as root/global options today and are normally rejected outside a leaf that defines them. The in-source design list is the `Reserved (future)` comment in `src/index.ts`; it does not itself register or reject options. ## Forge package and OpenAPI pin diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 97ecdaf68e..363e325997 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -111,7 +111,7 @@ or alias is added. ## Global Flags -`--zone` (`-z`), `--profile`, `--mode` (`-m`), `--quiet` (`-q`), `--help` (`-h`), `--version` (`-v`), `--local`, and `--persist-to`. `--mode` is available as `ctx.mode` when function-form exports in `cloudflare.config.ts` are evaluated. `--persist-to` is valid only with `--local`; local mode starts and disposes a Miniflare instance over cf's state without requiring a running dev server. Hand-written root commands reject `--local`, and generated endpoints without an explorer equivalent return a specific error. Every generated command gets `--dry-run` (mutating and read alike — reads show URL/method/path params without a body preview). Almost every body operation gets `--body`; explicit `--file` is limited to eligible binary, multipart, and other non-JSON upload shapes. Destructive commands get `--force` (`-f`) at runtime. API fields that would emit an exact `--mode` flag are temporarily omitted until their upstream schemas stop using the reserved name. Body fields can still be supplied through `--body`; query, path, and header fields are temporarily unavailable. +`--zone` (`-z`), `--profile`, `--mode` (`-m`), `--temporary-account` (hidden), `--event-code` (hidden), `--quiet` (`-q`), `--help` (`-h`), `--version` (`-v`), `--local`, and `--persist-to`. `--mode` is available as `ctx.mode` when function-form exports in `cloudflare.config.ts` are evaluated. `--temporary-account` creates or reuses a temporary account when no credentials exist, and missing-credential errors recommend it. `--event-code` requires `--temporary-account` and is sent only when a new account is created; it is never cached, printed, or recorded in telemetry. `--persist-to` is valid only with `--local`; local mode starts and disposes a Miniflare instance over cf's state without requiring a running dev server. Hand-written root commands reject `--local`, and generated endpoints without an explorer equivalent return a specific error. Every generated command gets `--dry-run` (mutating and read alike — reads show URL/method/path params without a body preview). Almost every body operation gets `--body`; explicit `--file` is limited to eligible binary, multipart, and other non-JSON upload shapes. Destructive commands get `--force` (`-f`) at runtime. API fields that would emit an exact `--mode`, `--temporary-account`, or `--event-code` flag are omitted to avoid shadowing global flags. Body fields can still be supplied through `--body`; query, path, and header fields are temporarily unavailable. Normal structured API results are pretty-printed JSON. Null mutation results leave stdout empty and may print a TTY-only success marker on stderr. Endpoints declared as raw bytes or raw text write their payload directly to stdout, and some raw-byte commands accept `--text` to decode as UTF-8. Callers needing newline-delimited JSON can pipe structured results through `jq -c '.[]'` or similar; there is no global output-format `--format` / `--ndjson` flag, though some leaves use those words for endpoint-specific API parameters. @@ -136,12 +136,12 @@ binding, with the default profile as fallback. as a namespace. - `--json`, `--ndjson`, `--pretty` — potential output-format toggles. Normal structured results are JSON today, while raw response endpoints remain raw; these names are claimed so future formatting choices do not collide. -`--remote` currently appears on a generated Pay Per Crawl command. Exact API fields named `mode` are temporarily omitted because `--mode` is global. The remaining exact names are not root options and are normally rejected outside a leaf that defines them. See the `Reserved (future)` comment block in `src/index.ts`'s `buildCli` for the design list; the comment does not itself register or reject options. +`--remote` currently appears on a generated Pay Per Crawl command. Exact API fields named `mode`, `temporary_account`, or `event_code` are omitted because those flags are global. The remaining exact names are not root options and are normally rejected outside a leaf that defines them. See the `Reserved (future)` comment block in `src/index.ts`'s `buildCli` for the design list; the comment does not itself register or reject options. ## Runtime Value Priority (highest → lowest) -- Account: `CLOUDFLARE_ACCOUNT_ID` → project `cloudflare.config.ts` → - workers-auth selection. +- Account: `--temporary-account` → `CLOUDFLARE_ACCOUNT_ID` → project + `cloudflare.config.ts` → workers-auth selection. - Compliance region: `CLOUDFLARE_COMPLIANCE_REGION` → project `cloudflare.config.ts` → `public`. - Zone: positional value → `--zone` → `CLOUDFLARE_ZONE_ID`. diff --git a/packages/cli/generator/plugin/args.ts b/packages/cli/generator/plugin/args.ts index 96273308ee..dd4b8b0831 100644 --- a/packages/cli/generator/plugin/args.ts +++ b/packages/cli/generator/plugin/args.ts @@ -260,7 +260,11 @@ function flagName(name: string): string { function cliFlagName(name: string): string | undefined { const flag = flagName(name); - return flag.toLowerCase() === "mode" ? undefined : flag; + return ["mode", "temporary-account", "event-code"].includes( + flag.toLowerCase() + ) + ? undefined + : flag; } function parameterFlagName( diff --git a/packages/cli/src/__tests__/commands/auth/device-login.test.ts b/packages/cli/src/__tests__/commands/auth/device-login.test.ts index e015cbec82..23d306391c 100644 --- a/packages/cli/src/__tests__/commands/auth/device-login.test.ts +++ b/packages/cli/src/__tests__/commands/auth/device-login.test.ts @@ -19,6 +19,7 @@ const telemetryEvents = vi.hoisted( vi.mock("@cloudflare/workers-auth/cf", async (importOriginal) => ({ ...(await importOriginal>()), createCfAuth: () => ({ + setTemporaryAllowed: vi.fn(), setProfile: (profile: string) => { sharedAuth.activeProfile = profile; }, diff --git a/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts b/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts index 63e3825cd0..213893e306 100644 --- a/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts +++ b/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts @@ -114,6 +114,12 @@ describe("deriveArgsFromOp query flags", () => { }); }); + it("omits a query param that would shadow --temporary-account", () => { + expect( + derived(search(), "example", queryOperation("temporary_account")).args + ).toEqual([]); + }); + it("classifies by the wire name", () => { const d = derived( search(), diff --git a/packages/cli/src/__tests__/lib/auth-token.test.ts b/packages/cli/src/__tests__/lib/auth-token.test.ts index 8c5c93daca..9099861bef 100644 --- a/packages/cli/src/__tests__/lib/auth-token.test.ts +++ b/packages/cli/src/__tests__/lib/auth-token.test.ts @@ -3,7 +3,11 @@ import { getAuthToken } from "../../lib/auth-token.js"; import { CliExit } from "../../lib/cli-exit.js"; const { getOAuthToken } = vi.hoisted(() => ({ getOAuthToken: vi.fn() })); -vi.mock("../../lib/oauth/index.js", () => ({ getValidToken: getOAuthToken })); +vi.mock("../../lib/oauth/index.js", () => ({ + getValidToken: getOAuthToken, + isOAuthLoggedIn: () => false, + isTemporaryAccountAllowed: () => false, +})); beforeEach(() => { for (const key of [ diff --git a/packages/cli/src/__tests__/lib/context.test.ts b/packages/cli/src/__tests__/lib/context.test.ts index f4ef120f3b..d518136f20 100644 --- a/packages/cli/src/__tests__/lib/context.test.ts +++ b/packages/cli/src/__tests__/lib/context.test.ts @@ -9,16 +9,20 @@ import { getZoneId, resolveAccountIdSilent, } from "../../lib/context.js"; +import { setTemporaryAccountAllowed } from "../../lib/oauth/index.js"; import { clearLoadedProjectSettings } from "../../lib/project-settings.js"; import type { ParsedInputSettingsConfig } from "@cloudflare/config"; const getOrSelectAccountIdMock = vi.hoisted(() => vi.fn()); +const requireAuthMock = vi.hoisted(() => vi.fn()); const getActiveAccountIdMock = vi.hoisted(() => vi.fn()); const fetchAllAccountsMock = vi.hoisted(() => vi.fn()); vi.mock("@cloudflare/workers-auth/cf", () => ({ createCfAuth: () => ({ getOrSelectAccountId: getOrSelectAccountIdMock, + requireAuth: requireAuthMock, + setTemporaryAllowed: vi.fn(), getActiveAccountId: getActiveAccountIdMock, fetchAllAccounts: fetchAllAccountsMock, }), @@ -39,6 +43,8 @@ describe("context", () => { beforeEach(() => { vi.restoreAllMocks(); getOrSelectAccountIdMock.mockReset(); + requireAuthMock.mockReset(); + setTemporaryAccountAllowed(false); getActiveAccountIdMock.mockReset(); fetchAllAccountsMock.mockReset(); fetchAllAccountsMock.mockResolvedValue([]); @@ -70,6 +76,22 @@ describe("context", () => { }); }); + it("uses a temporary account instead of configured accounts", async () => { + writeProjectSettings({ accountId: "settings-acct" }); + setTemporaryAccountAllowed(true); + requireAuthMock.mockResolvedValue("temporary-account"); + + await expect(resolveAccountIdSilent()).resolves.toBeUndefined(); + await expect(getAccountId()).resolves.toBe("temporary-account"); + await expect(resolveAccountIdSilent()).resolves.toBe("temporary-account"); + await expect(getAccountId()).resolves.toBe("temporary-account"); + expect(requireAuthMock).toHaveBeenCalledOnce(); + expect(requireAuthMock).toHaveBeenCalledWith({ + compliance_region: "public", + }); + expect(getOrSelectAccountIdMock).not.toHaveBeenCalled(); + }); + it("uses an explicit region without reading project settings", async () => { writeFileSync( join(process.cwd(), "cloudflare.config.ts"), diff --git a/packages/cli/src/__tests__/lib/deploy-input.test.ts b/packages/cli/src/__tests__/lib/deploy-input.test.ts index 58999d8a17..7902434314 100644 --- a/packages/cli/src/__tests__/lib/deploy-input.test.ts +++ b/packages/cli/src/__tests__/lib/deploy-input.test.ts @@ -300,6 +300,8 @@ describe("transforms Build Output Specification into deploy input", () => { "secrets-file": undefined, prebuilt: false, quiet: false, + "temporary-account": false, + "event-code": undefined, local: false, "persist-to": undefined, zone: undefined, diff --git a/packages/cli/src/__tests__/lib/errors.test.ts b/packages/cli/src/__tests__/lib/errors.test.ts index 4346ac121f..68f7f96d02 100644 --- a/packages/cli/src/__tests__/lib/errors.test.ts +++ b/packages/cli/src/__tests__/lib/errors.test.ts @@ -1,4 +1,7 @@ -import { APIError as WorkersUtilsAPIError } from "@cloudflare/workers-utils"; +import { + UserError, + APIError as WorkersUtilsAPIError, +} from "@cloudflare/workers-utils"; import { afterEach, beforeEach, @@ -7,7 +10,7 @@ import { it, vi, } from "vite-plus/test"; -import { handleError } from "../../lib/errors.js"; +import { handleError, MissingCredentialsError } from "../../lib/errors.js"; import { ApiError } from "#api"; /** @@ -51,6 +54,33 @@ describe("handleError", () => { expect(handleError(err)).toBe(err); }); + it("recommends a temporary account only when credentials are missing", () => { + handleError(new MissingCredentialsError("No authentication token found.")); + expect(stderr()).toContain("--temporary-account"); + + errSpy.mockClear(); + handleError( + new UserError( + "Not logged in. Could not authenticate because no credentials were found and the environment is non-interactive.", + { telemetryMessage: "cfetch auth login required" } + ) + ); + expect(stderr()).toContain("--temporary-account"); + + errSpy.mockClear(); + handleError( + new UserError( + "Not logged in. Your auth token has expired and could not be refreshed.", + { telemetryMessage: "cfetch auth login required" } + ) + ); + expect(stderr()).not.toContain("--temporary-account"); + + errSpy.mockClear(); + handleError(new Error("Request failed")); + expect(stderr()).not.toContain("--temporary-account"); + }); + describe("APIError", () => { it("renders code, message, status text, and a path-only request line", () => { const err = new ApiError({ diff --git a/packages/cli/src/__tests__/lib/schema-flags.test.ts b/packages/cli/src/__tests__/lib/schema-flags.test.ts index 35c844a7fd..a523485076 100644 --- a/packages/cli/src/__tests__/lib/schema-flags.test.ts +++ b/packages/cli/src/__tests__/lib/schema-flags.test.ts @@ -11,6 +11,7 @@ import { renderPromptQuestion, sanitizeTerminalText, schemaToFlags, + unknownFlagNames, validateFlagValue, validateJsonSchema, validateSupportedJsonSchema, @@ -18,6 +19,30 @@ import { import type { JsonSchema } from "../../lib/schema-flags.js"; describe("schema flags", () => { + it("omits the top-level temporary account flag from runtime schemas", () => { + const descriptors = schemaToFlags({ + type: "object", + properties: { + temporary_account: { type: "boolean" }, + settings: { + type: "object", + properties: { temporary_account: { type: "boolean" } }, + }, + }, + }); + + expect(descriptors.map(({ name }) => name)).toEqual([ + "settings-temporary-account", + ]); + expect( + unknownFlagNames({ + "temporary-account": false, + temporaryAccount: false, + "unknown-field": "value", + }) + ).toEqual(["unknown-field"]); + }); + it("omits the reserved top-level mode but keeps scoped mode fields", () => { const descriptors = schemaToFlags({ type: "object", diff --git a/packages/cli/src/__tests__/lib/telemetry/sanitization.test.ts b/packages/cli/src/__tests__/lib/telemetry/sanitization.test.ts index fbb70198ae..e92086a50b 100644 --- a/packages/cli/src/__tests__/lib/telemetry/sanitization.test.ts +++ b/packages/cli/src/__tests__/lib/telemetry/sanitization.test.ts @@ -69,6 +69,21 @@ describe("sanitizeArgs", () => { }); }); + it("records temporary-account use but omits event codes", () => { + const eventCode = "ABCD-EFGH-JKMN"; + const result = sanitizeArgs( + { temporaryAccount: true, eventCode }, + ["deploy", "--temporary-account", "--event-code", eventCode], + { safeFlags: [] } + ); + + expect(result.sanitizedArgs).toEqual({ + "temporary-account": true, + "event-code": REDACTED, + }); + expect(JSON.stringify(result)).not.toContain(eventCode); + }); + it("canonicalizes short flag aliases", () => { const result = sanitizeArgs( { force: true, quiet: true, zone: "example.com" }, diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts new file mode 100644 index 0000000000..f545978d2c --- /dev/null +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -0,0 +1,375 @@ +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { getCfConfigPath } from "@cloudflare/workers-auth/cf"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { getAccountId, getAuthToken } from "../lib/auth.js"; +import { setTemporaryAccountAllowed } from "../lib/oauth/index.js"; +import { server, setupMsw, TEST_BASE_URL } from "./helpers/msw.js"; +import { runCf } from "./helpers/run-cf.js"; + +describe("--temporary-account", () => { + runInTempDir(); + setupMsw(); + + const accountId = "temporary-account-id"; + const apiToken = "temporary-account-token"; + const env = { + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_API_TOKEN: undefined, + CLOUDFLARE_API_BASE_URL: TEST_BASE_URL, + }; + + beforeEach(() => { + mkdirSync(getCfConfigPath(), { recursive: true }); + writeFileSync( + join(getCfConfigPath(), "cloudflare-temporary-account.json"), + JSON.stringify({ + account: { + id: accountId, + name: "Temporary Account", + apiToken, + expiresAt: "2999-01-01T00:00:00.000Z", + }, + claim: { + url: "https://dash.cloudflare.com/claim-preview", + expiresAt: "2999-01-02T00:00:00.000Z", + }, + }) + ); + }); + + const cachePath = () => + join(getCfConfigPath(), "cloudflare-temporary-account.json"); + + /** Mock account provisioning, returning each creation request body. */ + function mockProvisioning(): Array<{ eventCode?: string }> { + rmSync(cachePath()); + const creationRequests: Array<{ eventCode?: string }> = []; + server.use( + http.post(`${TEST_BASE_URL}/provisioning/previews/challenge`, () => + HttpResponse.json({ + success: true, + result: { + challengeToken: "challenge-token", + seed: Buffer.alloc(32, 1).toString("base64url"), + k: 2, + g: 2, + }, + }) + ), + http.post( + `${TEST_BASE_URL}/provisioning/previews`, + async ({ request }) => { + const body = (await request.json()) as { eventCode?: string }; + creationRequests.push(body); + return HttpResponse.json({ + success: true, + result: { + account: { + id: accountId, + name: "Temporary Account", + apiToken, + expiresAt: "2999-01-01T00:00:00.000Z", + }, + claim: { + url: "https://dash.cloudflare.com/claim-preview", + expiresAt: "2999-01-02T00:00:00.000Z", + }, + ...(body.eventCode ? { eventCodeAccepted: true } : {}), + }, + }); + } + ) + ); + return creationRequests; + } + + function mockNamespaceList() { + server.use( + http.get( + `${TEST_BASE_URL}/accounts/${accountId}/storage/kv/namespaces`, + () => HttpResponse.json({ success: true, result: [] }) + ) + ); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + } + + function captureStderr() { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + return () => errorLog.mock.calls.flat().join("\n"); + } + + it("uses the temporary account ID and token for generated commands", async () => { + const stderr = captureStderr(); + let authorization: string | null = null; + server.use( + http.get( + `${TEST_BASE_URL}/accounts/${accountId}/storage/kv/namespaces`, + ({ request }) => { + authorization = request.headers.get("authorization"); + return HttpResponse.json({ success: true, result: [] }); + } + ) + ); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + const result = await runCf( + ["kv", "namespaces", "list", "--temporary-account"], + { ...env, CLOUDFLARE_ACCOUNT_ID: "configured-account-id" } + ); + + expect(result.exitCode).toBe(0); + expect(authorization).toBe(`Bearer ${apiToken}`); + expect(stderr()).toContain("https://dash.cloudflare.com/claim-preview"); + }); + + it("uses the temporary token for raw-response commands", async () => { + captureStderr(); + let authorization: string | null = null; + server.use( + http.get( + `${TEST_BASE_URL}/accounts/${accountId}/storage/kv/namespaces/ns/values/key`, + ({ request }) => { + authorization = request.headers.get("authorization"); + return new HttpResponse("value", { + headers: { "content-type": "application/octet-stream" }, + }); + } + ) + ); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + const result = await runCf( + [ + "kv", + "keys", + "get", + "key", + "--namespace-id", + "ns", + "--temporary-account", + ], + env + ); + + expect(result.exitCode).toBe(0); + expect(authorization).toBe(`Bearer ${apiToken}`); + }); + + it("activates the account when the token is requested first", async () => { + captureStderr(); + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "configured-account-id"); + vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); + setTemporaryAccountAllowed(true); + try { + await expect(getAuthToken()).resolves.toBe(apiToken); + await expect(getAccountId()).resolves.toBe(accountId); + } finally { + setTemporaryAccountAllowed(false); + } + }); + + it("keeps runtime-schema help on the temporary account", async () => { + captureStderr(); + const model = "@cf/test/model"; + let authorization: string | null = null; + server.use( + http.get( + `${TEST_BASE_URL}/accounts/${accountId}/ai/models/schema`, + ({ request }) => { + authorization = request.headers.get("authorization"); + expect(new URL(request.url).searchParams.get("model")).toBe(model); + return HttpResponse.json({ + success: true, + errors: [], + result: { + input: { + type: "object", + properties: { prompt: { type: "string" } }, + }, + output: { type: "object" }, + }, + }); + } + ) + ); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + const result = await runCf( + ["ai", "run", model, "--help", "--temporary-account"], + { ...env, CLOUDFLARE_ACCOUNT_ID: "configured-account-id" } + ); + + expect(result.exitCode).toBe(0); + expect(authorization).toBe(`Bearer ${apiToken}`); + expect(log.mock.calls.flat().join("\n")).toContain( + `Input flags for ${model}` + ); + }); + + it("rejects existing credentials using cf's flag name", async () => { + captureStderr(); + await expect( + runCf(["kv", "namespaces", "list", "--temporary-account"], { + ...env, + CLOUDFLARE_API_TOKEN: "existing-token", + }) + ).rejects.toThrow(/remove `--temporary-account`/); + }); + + it("rejects --local", async () => { + await expect( + runCf(["kv", "namespaces", "list", "--temporary-account", "--local"]) + ).rejects.toThrow(/--temporary-account cannot be used with --local/); + }); + + it("recommends the flag when a command has no credentials", async () => { + const stderr = captureStderr(); + await expect( + runCf(["kv", "namespaces", "list"], { + ...env, + CLOUDFLARE_ACCOUNT_ID: "configured-account-id", + }) + ).rejects.toThrow("No authentication token found"); + expect(stderr()).toContain("rerun this command with --temporary-account"); + }); + + it("creates an event account without persisting or printing the code", async () => { + const stderr = captureStderr(); + const creationRequests = mockProvisioning(); + mockNamespaceList(); + const eventCode = "ABCD-EFGH-JKMN"; + + const result = await runCf( + [ + "kv", + "namespaces", + "list", + "--temporary-account", + "--event-code", + ` ${eventCode} `, + ], + env + ); + + expect(result.exitCode).toBe(0); + expect(creationRequests).toEqual([expect.objectContaining({ eventCode })]); + expect(readFileSync(cachePath(), "utf8")).not.toContain(eventCode); + expect(stderr()).not.toContain(eventCode); + expect(stderr()).toContain("Temporary account ready:"); + }); + + it("rejects an event code when a temporary account is already cached", async () => { + captureStderr(); + const creationRequests = mockProvisioning(); + mockNamespaceList(); + + await runCf(["kv", "namespaces", "list", "--temporary-account"], env); + await expect( + runCf( + [ + "kv", + "namespaces", + "list", + "--temporary-account", + "--event-code", + "ABCD-EFGH-JKMN", + ], + env + ) + ).rejects.toThrow( + "A temporary account is already cached. Rerun without --event-code to reuse it" + ); + expect(creationRequests).toEqual([ + expect.not.objectContaining({ eventCode: expect.anything() }), + ]); + }); + + it("requires --temporary-account when --event-code is passed", async () => { + await expect( + runCf(["kv", "namespaces", "list", "--event-code", "ABCD-EFGH-JKMN"], env) + ).rejects.toThrow("--event-code requires --temporary-account."); + }); + + it("rejects a blank event code", async () => { + await expect( + runCf( + [ + "kv", + "namespaces", + "list", + "--temporary-account", + "--event-code", + " ", + ], + env + ) + ).rejects.toThrow("--event-code cannot be empty."); + }); + + it("rejects repeated event codes without exposing their values", async () => { + const stderr = captureStderr(); + const error = await runCf( + [ + "kv", + "namespaces", + "list", + "--temporary-account", + "--event-code", + "SECRET-CODE-ONE", + "--event-code", + "SECRET-CODE-TWO", + ], + env + ).catch((cause: unknown) => cause); + + expect(String(error)).toContain("--event-code expects a single value."); + expect(String(error)).not.toContain("SECRET-CODE"); + expect(stderr()).not.toContain("SECRET-CODE"); + }); + + it("clears a cached account on logout without OAuth credentials", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCf(["auth", "logout"], env); + + expect(existsSync(cachePath())).toBe(false); + expect(log.mock.calls.flat().join("\n")).toContain( + "Cleared temporary preview account." + ); + }); + + it("suggests a temporary account from whoami when unauthenticated", async () => { + const stderr = captureStderr(); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCf(["auth", "whoami"], env); + + expect(stderr()).toContain("cf deploy --temporary-account"); + }); + + it("rejects the Wrangler flag spelling", async () => { + await expect( + runCf(["kv", "namespaces", "list", "--temporary"], env) + ).rejects.toThrow(/Unknown argument: temporary/); + }); + + it("does not expose the flag in help", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCf(["kv", "namespaces", "list", "--help"]); + + const help = log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(help).toContain("--profile"); + expect(help).not.toContain("--temporary-account"); + }); +}); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 2514fc06d6..43ef606ef8 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -1,4 +1,5 @@ import { + clearTemporaryAccount, getActiveProfile, getConfigPath, isOAuthLoggedIn, @@ -38,6 +39,10 @@ const logoutCommand: CommandModule = { const hasEnvToken = !!process.env.CLOUDFLARE_API_TOKEN; if (!hasOAuthTokens) { + const clearedTemporaryAccount = clearTemporaryAccount(); + if (clearedTemporaryAccount) { + console.log(info("Cleared temporary preview account.")); + } if (hasEnvToken) { console.log( info( @@ -45,7 +50,7 @@ const logoutCommand: CommandModule = { ) ); console.log(info("Unset it with: unset CLOUDFLARE_API_TOKEN")); - } else { + } else if (!clearedTemporaryAccount) { console.log(info("You are not currently logged in.")); console.log( hint(`Run ${theme.code("cf auth login")} to authenticate.`) diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 52c1ce3253..2c9ac1ca5d 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -5,6 +5,7 @@ import { readAuthCredentials, } from "../../lib/oauth/index.js"; import { formatOutput } from "../../lib/output.js"; +import { hint } from "../../lib/ui/index.js"; import type { ArgumentsCamelCase, Argv, CommandModule } from "yargs"; interface WhoamiArgs {} @@ -37,6 +38,13 @@ const whoamiCommand: CommandModule = { token = await getAuthToken(); } catch { formatOutput({ authenticated: false, error: "Not logged in" }); + if (authSource === "none") { + console.error( + hint( + "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary account." + ) + ); + } return; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 51499a2858..e143fe7820 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -74,6 +74,32 @@ const GLOBAL_OPTIONS = { return mode; }, }, + "temporary-account": { + type: "boolean", + description: + "Create or reuse a temporary account when no Cloudflare credentials are available", + global: true, + default: false, + hidden: true, + }, + // Errors never include the event code: it is a shared secret for an event. + "event-code": { + type: "string", + description: "Create a temporary account for an event", + requiresArg: true, + global: true, + hidden: true, + coerce: (eventCode: string | string[]) => { + if (Array.isArray(eventCode)) { + throw new Error("--event-code expects a single value."); + } + const trimmed = eventCode.trim(); + if (!trimmed) { + throw new Error("--event-code cannot be empty."); + } + return trimmed; + }, + }, // `--local` runs the command against cf's on-disk Miniflare state // instead of the production API: cf spawns its own Miniflare over // `--persist-to` (default `~/.config/cloudflare/state/v3`), dispatches to the @@ -497,6 +523,8 @@ async function prepareUpdateCheck(): Promise { * --zone, -z Zone ID or domain * --profile Use a specific auth profile * --mode, -m Mode used to evaluate project configuration + * --temporary-account Use a temporary preview account (hidden) + * --event-code Create the temporary account for an event (hidden) * --local Route via a cf-spawned Miniflare session * --persist-to Directory holding the local persisted state * @@ -620,6 +648,16 @@ export function buildCli(rawArgs: string[], options: BuildCliOptions = {}) { throw new Error("--persist-to can only be used with --local."); } + const temporaryAccount = argv["temporary-account"] === true; + if (temporaryAccount && argv.local) { + throw new Error("--temporary-account cannot be used with --local."); + } + const eventCode = argv["event-code"]; + // Never silently ignore an event code when real credentials apply. + if (eventCode !== undefined && !temporaryAccount) { + throw new Error("--event-code requires --temporary-account."); + } + if ( resolvedCommand !== undefined && shouldApplyCloudflareDotEnv(resolvedCommand, argv.local) @@ -630,9 +668,18 @@ export function buildCli(rawArgs: string[], options: BuildCliOptions = {}) { // Local mode bypasses profile credentials, so resolving and displaying // an active profile would be misleading. if (!argv.local) { - const { resolveProfile } = await import("./lib/oauth/index.js"); + const { resolveProfile, setTemporaryAccountAllowed } = + await import("./lib/oauth/index.js"); + setTemporaryAccountAllowed( + temporaryAccount, + eventCode === undefined ? undefined : { eventCode } + ); + // Temporary accounts still check the resolved profile for existing + // credentials, but they don't use that profile. const profile = resolveProfile(argv.profile); - printActiveProfileLine(profile); + if (!temporaryAccount) { + printActiveProfileLine(profile); + } } }) .demandCommand(1, "You need to specify a command") diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index 68b572ec9e..d601fe6ed4 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -1,17 +1,35 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; -import { getValidToken as getOAuthToken } from "./oauth/index.js"; +import { MissingCredentialsError } from "./errors.js"; +import { + activateTemporaryAccount, + getValidToken as getOAuthToken, + getTemporaryAccountApiToken, + isOAuthLoggedIn, + isTemporaryAccountAllowed, +} from "./oauth/index.js"; /** * Get the authentication token for the Cloudflare API. * * Resolution order: - * 1. `CLOUDFLARE_API_TOKEN` environment variable (cf does not support the + * 1. The temporary preview account requested with `--temporary-account`, + * provisioned or reused together with its account ID. + * 2. `CLOUDFLARE_API_TOKEN` environment variable (cf does not support the * global API key + email pair, so `allowGlobalAuthKey` is `false`). - * 2. cf's stored OAuth token (refreshed if expired). + * 3. cf's stored OAuth token (refreshed if expired). * * @throws Error if no authentication token is found. */ export async function getAuthToken(): Promise { + if (isTemporaryAccountAllowed()) { + await activateTemporaryAccount(); + const temporaryToken = getTemporaryAccountApiToken(); + if (!temporaryToken) { + throw new Error("The temporary account has no API token."); + } + return temporaryToken; + } + // Environment credential (scoped API token only). const envAuth = getAuthFromEnv({ allowGlobalAuthKey: false }); if (envAuth && "apiToken" in envAuth) { @@ -24,13 +42,16 @@ export async function getAuthToken(): Promise { return cfToken; } - throw new Error( - `No authentication token found. + const message = `No authentication token found. Please set one of the following: 1. Set the CLOUDFLARE_API_TOKEN environment variable 2. Run 'cf auth login' to authenticate via OAuth -For API tokens, visit: https://dash.cloudflare.com/profile/api-tokens` - ); +For API tokens, visit: https://dash.cloudflare.com/profile/api-tokens`; + // A stored OAuth token that could not refresh still counts as existing + // credentials, so a temporary account would be rejected. + throw isOAuthLoggedIn() + ? new Error(message) + : new MissingCredentialsError(message); } diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 8a98ec5842..ac9569136a 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -3,6 +3,10 @@ import { API_TIMEOUT_MS } from "./api-constants.js"; import { isUploadBody } from "./api-upload.js"; import { getAuthToken } from "./auth-token.js"; import { getComplianceRegion, resolveAccountIdSilent } from "./context.js"; +import { + activateTemporaryAccount, + isTemporaryAccountAllowed, +} from "./oauth/index.js"; import { getDefaultHeaders } from "./request-headers.js"; import { ApiError, Client, type ClientOptions } from "#api"; import listAccounts from "#operations/accounts/list.js"; @@ -285,6 +289,14 @@ export function createCloudflareClientWithToken( export async function resolveAccountIdForHelp( deadline: number ): Promise { + if (isTemporaryAccountAllowed()) { + try { + return await activateTemporaryAccount(); + } catch { + return undefined; + } + } + const stored = await resolveAccountIdSilent(); if (stored) { return stored; diff --git a/packages/cli/src/lib/cli-types.ts b/packages/cli/src/lib/cli-types.ts index bc47831174..bbf083b86e 100644 --- a/packages/cli/src/lib/cli-types.ts +++ b/packages/cli/src/lib/cli-types.ts @@ -30,6 +30,8 @@ export interface CommonYargsOptions { zone: string | undefined; profile: string | undefined; mode: string | undefined; + "temporary-account": boolean; + "event-code": string | undefined; local: boolean; "persist-to": string | undefined; } diff --git a/packages/cli/src/lib/context.ts b/packages/cli/src/lib/context.ts index 487bcd0c46..b13584a774 100644 --- a/packages/cli/src/lib/context.ts +++ b/packages/cli/src/lib/context.ts @@ -2,13 +2,14 @@ * Runtime context resolution. * * Account ID priority: - * 1. CLOUDFLARE_ACCOUNT_ID environment variable - * 2. account settings from cloudflare.config.ts's default export - * 3. workers-auth account cache - * 4. Interactive account picker / single-account auto-select + * 1. Temporary preview account when --temporary-account is requested + * 2. CLOUDFLARE_ACCOUNT_ID environment variable + * 3. account settings from cloudflare.config.ts's default export + * 4. workers-auth account cache + * 5. Interactive account picker / single-account auto-select * - * Steps 1–3 are offline (`resolveAccountIdSilent`); only `getAccountId` - * can enumerate accounts or ask. + * Configured sources 2–4 are offline (`resolveAccountIdSilent`); only + * `getAccountId` can provision, enumerate accounts, or ask. * * Zone priority: * 1. --zone / -z CLI flag @@ -83,14 +84,23 @@ async function resolveAccountId( * or going looking for accounts when none of those is set. */ export async function resolveAccountIdSilent(): Promise { + // Lazily imported for the same dependency-cycle reason as getAccountId. + const { + getActiveAccountId, + getActiveTemporaryAccountId, + isTemporaryAccountAllowed, + } = await import("./oauth/index.js"); + if (isTemporaryAccountAllowed()) { + // Configured accounts don't apply to a temporary account invocation, + // and provisioning one is not silent. + return getActiveTemporaryAccountId(); + } const result = await resolveAccountId(); if (result) { return result.value; } - // The profile's cached account is priority 3 and costs nothing to read: - // no request, no prompt, no announcement. Lazily imported for the same - // dependency-cycle reason as getAccountId. - const { getActiveAccountId } = await import("./oauth/index.js"); + // The profile's cached account costs nothing to read: no request, no + // prompt, no announcement. try { return getActiveAccountId(); } catch { @@ -99,8 +109,8 @@ export async function resolveAccountIdSilent(): Promise { } /** - * Get account ID with priority resolution (env → settings → interactive picker - * / single-account auto-select). + * Get account ID with priority resolution (temporary account → env → settings + * → interactive picker / single-account auto-select). * * @throws Error if no account ID is found */ @@ -109,15 +119,23 @@ export async function getAccountId(options?: { skipProjectSettings?: boolean; complianceRegion?: ComplianceRegion; }): Promise { + // oauth/index also consumes compliance context. Import it lazily after this + // module has initialised to avoid a static context ↔ OAuth dependency cycle. + const { + activateTemporaryAccount, + getOrSelectAccountId, + isTemporaryAccountAllowed, + } = await import("./oauth/index.js"); + if (isTemporaryAccountAllowed()) { + return activateTemporaryAccount(options); + } + const result = await resolveAccountId(options); if (result) { announceAccount(result.value, getSourceDescription(result, "accountId")); } - // oauth/index also consumes compliance context. Import it lazily after this - // module has initialised to avoid a static context ↔ OAuth dependency cycle. - const { getOrSelectAccountId } = await import("./oauth/index.js"); return getOrSelectAccountId(result?.value, options); } diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index dd4ff5f2cf..9bc3e8e80d 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -6,6 +6,26 @@ import { hint } from "./ui/format.js"; import { theme } from "./ui/theme.js"; import { ApiError } from "#api"; +/** No API token, OAuth login, or temporary account is available. */ +export class MissingCredentialsError extends Error {} + +/** + * Whether an error means no credentials exist, rather than that existing + * credentials expired. Only unauthenticated users can use a temporary account. + */ +function isMissingCredentialsError(error: Error): boolean { + if (error instanceof MissingCredentialsError) { + return true; + } + // workers-auth's account lookup error. Its package may resolve a separate + // workers-utils copy, so compare its telemetry identity instead of class. + return ( + "telemetryMessage" in error && + error.telemetryMessage === "cfetch auth login required" && + error.message.includes("no credentials were found") + ); +} + /** * Drop the protocol+host (and the `/client/v4` API-version prefix) * from a Cloudflare API URL so the path-only form fits inside the @@ -199,6 +219,14 @@ export function handleError(error: unknown): unknown { const details = process.env.DEBUG && error.stack ? sanitize(error.stack) : undefined; console.error(errorBlock(error.message, details)); + if (isMissingCredentialsError(error)) { + console.error( + "\n" + + hint( + "To continue without logging in, rerun this command with --temporary-account. cf will use a temporary account and print a URL for claiming it." + ) + ); + } return error; } diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index 8b2c4781bf..2e926e50ce 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -5,9 +5,11 @@ * storage, refresh, and future keyring support. This module only supplies cf's * logger and prompt primitives, then preserves the façade consumed by commands. */ +import { existsSync, rmSync } from "node:fs"; import { isCancel } from "@clack/core"; import * as clack from "@clack/prompts"; import { + CF_CLI, createCfAuth, createCfProfileStore, validateScopeKeys, @@ -20,6 +22,7 @@ import { getComplianceRegion, type ComplianceRegion } from "../context.js"; import { isNonInteractiveOrCI } from "../interactive.js"; import { openSession } from "../session.js"; import { printAccountConfigHint } from "./account-hint.js"; +import type { TemporaryAccountRequest } from "@cloudflare/workers-auth"; const logger: AuthContext["logger"] = { debug: (...args) => { @@ -84,6 +87,85 @@ const auth = createCfAuth({ error instanceof NonInteractiveSelectionError, }); +let temporaryAccountAllowed = false; +let activeTemporaryAccountId: string | undefined; + +/** + * Mark whether this invocation may provision a temporary preview account, + * optionally for an event. + */ +export function setTemporaryAccountAllowed( + allowed: boolean, + request?: TemporaryAccountRequest +): void { + temporaryAccountAllowed = allowed; + activeTemporaryAccountId = undefined; + auth.setTemporaryAllowed(allowed, request); +} + +/** + * Delete the cached temporary preview account. + * + * @returns Whether a cached account existed. + */ +export function clearTemporaryAccount(): boolean { + const path = CF_CLI.getTemporaryAccountConfigPath(); + const existed = existsSync(path); + rmSync(path, { force: true }); + return existed; +} + +/** Whether this invocation requested a temporary preview account. */ +export function isTemporaryAccountAllowed(): boolean { + return temporaryAccountAllowed; +} + +/** The temporary account already activated for this invocation. */ +export function getActiveTemporaryAccountId(): string | undefined { + return activeTemporaryAccountId; +} + +/** The API token for the temporary account activated this invocation. */ +export function getTemporaryAccountApiToken(): string | undefined { + if (activeTemporaryAccountId === undefined) { + return undefined; + } + const credentials = auth.getAPIToken(); + return credentials && "apiToken" in credentials + ? credentials.apiToken + : undefined; +} + +/** + * Provision or reuse this invocation's temporary preview account and return + * its ID. workers-auth rejects the request when credentials already exist. + */ +export async function activateTemporaryAccount(options?: { + isPreview?: boolean; + complianceRegion?: ComplianceRegion; +}): Promise { + if (activeTemporaryAccountId !== undefined) { + return activeTemporaryAccountId; + } + try { + activeTemporaryAccountId = await auth.requireAuth({ + compliance_region: + options?.complianceRegion ?? (await getComplianceRegion(options)), + }); + return activeTemporaryAccountId; + } catch (error) { + // workers-auth's shared temporary-account errors name Wrangler's + // `--temporary` flag. Keep cf's public spelling in those messages. + if (error instanceof Error) { + error.message = error.message.replaceAll( + /--temporary(?!-account)/g, + "--temporary-account" + ); + } + throw error; + } +} + /** Resolve a configured, cached, or newly selected account ID. */ export async function getOrSelectAccountId( accountId?: string, diff --git a/packages/cli/src/lib/schema-flags.ts b/packages/cli/src/lib/schema-flags.ts index 43b5420c2b..4c4a54b527 100644 --- a/packages/cli/src/lib/schema-flags.ts +++ b/packages/cli/src/lib/schema-flags.ts @@ -715,9 +715,8 @@ function describeLeaf( .map(({ value, label }) => [String(value), label as string]) ); const flagName = path.map(fieldToFlag).join("-"); - // `--mode` belongs to project configuration. Runtime API schemas may still - // contain a top-level mode until the upstream schemas reserve the name. - if (flagName === "mode") { + // These global flags belong to cf, not the runtime API schema. + if (["mode", "temporary-account", "event-code"].includes(flagName)) { return undefined; } return { @@ -1150,7 +1149,7 @@ export function renderFlagHelp( /** Flags cf itself owns; never read as request-body input. */ const CF_FLAGS = - "_ $0 -- body dry-run dryRun help h local m mode persist-to persistTo profile quiet q version v zone z".split( + "_ $0 -- body dry-run dryRun event-code eventCode help h local m mode persist-to persistTo profile quiet q temporary-account temporaryAccount version v zone z".split( " " ); diff --git a/packages/cli/src/lib/telemetry/sanitization.ts b/packages/cli/src/lib/telemetry/sanitization.ts index 3b51e1e571..db18d1e30d 100644 --- a/packages/cli/src/lib/telemetry/sanitization.ts +++ b/packages/cli/src/lib/telemetry/sanitization.ts @@ -83,7 +83,12 @@ export function sanitizeArgs>( rawArgv: string[], classification: ArgClassification> ): SanitizedArgs { - const safe = new Set([...classification.safeFlags, "quiet", "local"]); + const safe = new Set([ + ...classification.safeFlags, + "quiet", + "local", + "temporary-account", + ]); const shortFlagAliases = { ...GLOBAL_SHORT_FLAG_ALIASES, ...classification.shortFlagAliases, diff --git a/packages/wrangler-tests/MANIFEST.md b/packages/wrangler-tests/MANIFEST.md index 0835edb6e7..a6d0d3dbe7 100644 --- a/packages/wrangler-tests/MANIFEST.md +++ b/packages/wrangler-tests/MANIFEST.md @@ -6,9 +6,9 @@ their representation in cf: `passing`, `todo`, or `skip`. | Status | Count | | --------- | -------: | -| passing | 605 | +| passing | 624 | | todo | 1498 | -| skip | 3373 | +| skip | 3354 | | **total** | **5476** | | Wrangler file | Wrangler test | cf status | @@ -976,7 +976,7 @@ their representation in cf: `passing`, `todo`, or `skip`. | hyperdrive.test.ts | hyperdrive commands › should handle updating a MySQL hyperdrive config's SSL settings without re-specifying origin (VERIFY_IDENTITY) | skip | | hyperdrive.test.ts | hyperdrive commands › should handle updating a MySQL hyperdrive config's SSL settings without re-specifying origin (REQUIRED) | skip | | index.test.ts | wrangler › no command › should display a list of available commands | skip | -| index.test.ts | wrangler › no command › should not require temporary terms acceptance before root help | skip | +| index.test.ts | wrangler › no command › should not require temporary terms acceptance before root help | passing | | index.test.ts | wrangler › --temporary › is rejected on commands that don't opt in | skip | | index.test.ts | wrangler › invalid command › should display an error | skip | | index.test.ts | wrangler › invalid command › should display an error even with --help flag | skip | @@ -1063,7 +1063,7 @@ their representation in cf: `passing`, `todo`, or `skip`. | logger.test.ts | shouldLogToDisk › should return false when WRANGLER_WRITE_LOGS=0 | skip | | logger.test.ts | shouldLogToDisk › should return true for any other value | skip | | logout.test.ts | logout › should exit with a message stating the user is not logged in | passing | -| logout.test.ts | logout › should clear a cached temporary preview account when not logged in via OAuth | skip | +| logout.test.ts | logout › should clear a cached temporary preview account when not logged in via OAuth | passing | | logout.test.ts | logout › should exit with a message stating the user logged in via API token | passing | | logout.test.ts | logout › should logout user that has been properly logged in | passing | | logout.test.ts | logout › should not display warnings from wrangler configuration parsing when logging out | skip | @@ -2028,7 +2028,7 @@ their representation in cf: `passing`, `todo`, or `skip`. | update-config-file.test.ts | createdResourceConfig() › logs correct binding type | skip | | update-config-file.test.ts | createdResourceConfig() › defaults › no prompts if all defaults provided | skip | | user.test.ts | User › login › should login a user when `wrangler login` is run | todo | -| user.test.ts | User › login › should clear the cached temporary preview account when logging in | skip | +| user.test.ts | User › login › should clear the cached temporary preview account when logging in | todo | | user.test.ts | User › login › should login a user when `wrangler login` is run with an ip address for custom callback-host | skip | | user.test.ts | User › login › should login a user when `wrangler login` is run with a domain name for custom callback-host | skip | | user.test.ts | User › login › should login a user when `wrangler login` is run with custom callbackPort param | skip | @@ -2208,7 +2208,7 @@ their representation in cf: `passing`, `todo`, or `skip`. | whoami.test.ts | whoami › should not redact in non-interactive mode | skip | | whoami.test.ts | whoami › should output JSON with user info when --json flag is used and authenticated | passing | | whoami.test.ts | whoami › should output JSON with loggedIn:false and exit with non-zero when --json flag is used and not authenticated | passing | -| whoami.test.ts | whoami › should suggest a temporary preview account when not authenticated | skip | +| whoami.test.ts | whoami › should suggest a temporary preview account when not authenticated | passing | | whoami.test.ts | whoami › should output JSON with API token auth type | passing | | whoami.test.ts | whoami › should display membership error on authentication error 10000 | skip | | whoami.test.ts | whoami › should fail when /memberships fails with a non-tolerated error | todo | @@ -3073,7 +3073,7 @@ their representation in cf: `passing`, `todo`, or `skip`. | d1/migrate.test.ts | migrate › apply › multiple accounts: should let the user apply migrations with an account_id in config | passing | | d1/migrate.test.ts | migrate › apply › should throw a clear error when executeSql returns null (execution cancelled) | skip | | d1/migrate.test.ts | migrate › apply › `apply` records each migration's name in `d1_migrations` as a path relative to `migrations_dir` | passing | -| d1/migrate.test.ts | migrate › apply › with a temporary preview account › should apply migrations against the account it minted | todo | +| d1/migrate.test.ts | migrate › apply › with a temporary preview account › should apply migrations against the account it minted | passing | | d1/migrate.test.ts | migrate › list › should not attempt to login in local mode | passing | | d1/migrate.test.ts | migrate › list › should error when no config file is present | skip | | d1/migrate.test.ts | migrate › list › should use the custom migrations folder when provided | passing | @@ -3502,28 +3502,28 @@ their representation in cf: `passing`, `todo`, or `skip`. | deploy/core.test.ts | deploy › should not run autoconfig when --config is explicitly provided | skip | | deploy/core.test.ts | deploy › output additional script information › for first party workers, it should print worker information at log level | skip | | deploy/core.test.ts | deploy › authentication › drops a user into the login flow if they're unauthenticated | todo | -| deploy/core.test.ts | deploy › authentication › creates a temporary preview account in interactive mode when --temporary is passed | skip | -| deploy/core.test.ts | deploy › authentication › requires --temporary when --event-code is passed | skip | -| deploy/core.test.ts | deploy › authentication › rejects a blank event code | skip | +| deploy/core.test.ts | deploy › authentication › creates a temporary preview account in interactive mode when --temporary is passed | passing | +| deploy/core.test.ts | deploy › authentication › requires --temporary when --event-code is passed | passing | +| deploy/core.test.ts | deploy › authentication › rejects a blank event code | passing | | deploy/core.test.ts | deploy › authentication › does not accept --event-code on versions upload | skip | -| deploy/core.test.ts | deploy › authentication › rejects repeated event codes without exposing their values | skip | -| deploy/core.test.ts | deploy › authentication › provisions an event account before deploying | skip | -| deploy/core.test.ts | deploy › authentication › aborts in interactive mode when the terms are not accepted | skip | +| deploy/core.test.ts | deploy › authentication › rejects repeated event codes without exposing their values | passing | +| deploy/core.test.ts | deploy › authentication › provisions an event account before deploying | passing | +| deploy/core.test.ts | deploy › authentication › aborts in interactive mode when the terms are not accepted | passing | | deploy/core.test.ts | deploy › authentication › with an alternative auth domain › drops a user into the login flow if they're unauthenticated | todo | | deploy/core.test.ts | deploy › authentication › warns a user when they're authenticated with an API token in wrangler config file | skip | -| deploy/core.test.ts | deploy › authentication › throws when the user is already authenticated and requests a temporary account | skip | -| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › creates a temporary preview account in non-interactive mode after printing terms notice | skip | -| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › throws when an OAuth token is on disk, even if it is expired and cannot refresh | skip | -| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › provisions the preview account against the staging API and caches it per-environment | skip | -| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › reuses a cached temporary preview account for later temporary deploys | skip | -| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › treats a malformed temporary preview account cache as a miss and refetches | skip | +| deploy/core.test.ts | deploy › authentication › throws when the user is already authenticated and requests a temporary account | passing | +| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › creates a temporary preview account in non-interactive mode after printing terms notice | passing | +| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › throws when an OAuth token is on disk, even if it is expired and cannot refresh | passing | +| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › provisions the preview account against the staging API and caches it per-environment | passing | +| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › reuses a cached temporary preview account for later temporary deploys | passing | +| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › treats a malformed temporary preview account cache as a miss and refetches | passing | | deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should not throw an error in non-TTY if 'CLOUDFLARE_API_TOKEN' & 'account_id' are in scope | todo | | deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should not throw an error if 'CLOUDFLARE_ACCOUNT_ID' & 'CLOUDFLARE_API_TOKEN' are in scope | todo | | deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should throw an error in non-TTY & there is more than one account associated with API token | todo | | deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should redact account names in CI even when non-interactive | todo | | deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should throw error in non-TTY if 'CLOUDFLARE_API_TOKEN' is missing | todo | -| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should fail clearly if the temporary preview account request fails | skip | -| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › fails the deploy when the proof-of-work challenge can't be obtained | skip | +| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should fail clearly if the temporary preview account request fails | passing | +| deploy/core.test.ts | deploy › authentication › with temporary preview accounts › fails the deploy when the proof-of-work challenge can't be obtained | passing | | deploy/core.test.ts | deploy › authentication › with temporary preview accounts › should throw error with no account ID provided and no members retrieved | todo | | deploy/core.test.ts | deploy › warnings › should warn user when worker was last deployed from api | todo | | deploy/core.test.ts | deploy › warnings › should warn user when additional properties are passed to a services config | skip | @@ -5147,7 +5147,7 @@ their representation in cf: `passing`, `todo`, or `skip`. | versions/versions.list.test.ts | versions list › without wrangler.toml › prints the 10 most recent versions to stdout as valid json | passing | | versions/versions.list.test.ts | versions list › with wrangler.toml › prints versions to stdout | skip | | versions/versions.list.test.ts | versions list › with wrangler.toml › prints versions as valid json | skip | -| versions/versions.upload.test.ts | versions upload › with --temporary › should create a temporary account in non-interactive mode after printing terms notice | skip | +| versions/versions.upload.test.ts | versions upload › with --temporary › should create a temporary account in non-interactive mode after printing terms notice | passing | | versions/versions.upload.test.ts | versions upload › should print bindings & startup time on versions upload | skip | | versions/versions.upload.test.ts | versions upload › should render config vars literally and --var as hidden | skip | | versions/versions.upload.test.ts | versions upload › should accept script as a positional arg | skip | diff --git a/packages/wrangler-tests/README.md b/packages/wrangler-tests/README.md index ad680e7ff5..1c96c66bab 100644 --- a/packages/wrangler-tests/README.md +++ b/packages/wrangler-tests/README.md @@ -17,7 +17,7 @@ Each upstream case has exactly one outcome: - `it.skip()` when the situation is tied to Wrangler internals or conflicts with `cf`'s design. -[MANIFEST.md](./MANIFEST.md) records every canonical upstream identity, including full `describe()` ancestry where leaf names repeat. It currently contains 5,476 cases: 605 passing, 1,498 todo, and 3,373 skip. +[MANIFEST.md](./MANIFEST.md) records every canonical upstream identity, including full `describe()` ancestry where leaf names repeat. It currently contains 5,476 cases: 624 passing, 1,498 todo, and 3,354 skip. The package has 109 test files: 97 ported/adapted files and 12 generated fallback shards under `src/__tests__/upstream/`. File count and canonical-case count are different units. diff --git a/packages/wrangler-tests/scripts/generate-upstream-stubs.mjs b/packages/wrangler-tests/scripts/generate-upstream-stubs.mjs index 38dd62a76d..aeff4bb818 100644 --- a/packages/wrangler-tests/scripts/generate-upstream-stubs.mjs +++ b/packages/wrangler-tests/scripts/generate-upstream-stubs.mjs @@ -229,19 +229,6 @@ const skippedCases = new Map([ "rejects migrations_pattern set without an explicit migrations_dir, with an actionable hint", ]), ], - [ - "index.test.ts", - new Set([ - "should not require temporary terms acceptance before root help", - "is rejected on commands that don't opt in", - ]), - ], - [ - "logout.test.ts", - new Set([ - "should clear a cached temporary preview account when not logged in via OAuth", - ]), - ], [ "pages/deployment-delete.test.ts", new Set(["should prefer CLOUDFLARE_ACCOUNT_ID over cached account id"]), @@ -292,7 +279,6 @@ const skippedCases = new Map([ [ "user.test.ts", new Set([ - "should clear the cached temporary preview account when logging in", "should send the `login user` metric when `--scopes` is provided", "--use-keyring stores credentials in an encrypted file with a key in the OS keyring", "--no-use-keyring scrubs encrypted credentials without writing them to plaintext, then the fresh login uses the file store", @@ -321,12 +307,6 @@ const skippedCases = new Map([ "should prefer config.account_id over env var and cache", ]), ], - [ - "whoami.test.ts", - new Set([ - "should suggest a temporary preview account when not authenticated", - ]), - ], [ "workflows.test.ts", new Set([ diff --git a/packages/wrangler-tests/src/__tests__/d1/migrate.test.ts b/packages/wrangler-tests/src/__tests__/d1/migrate.test.ts index c56d42fcfd..636af4aa5a 100644 --- a/packages/wrangler-tests/src/__tests__/d1/migrate.test.ts +++ b/packages/wrangler-tests/src/__tests__/d1/migrate.test.ts @@ -3,6 +3,7 @@ import { http, HttpResponse } from "msw/http"; import { describe, it, vi } from "vite-plus/test"; import { mockConsoleMethods } from "../helpers/mock-console"; import { useMockIsTTY } from "../helpers/mock-istty"; +import { mockTemporaryPreviewAccount } from "../helpers/mock-temporary-account"; import { createFetchResult, msw } from "../helpers/msw"; import { runInTempDir } from "../helpers/run-in-tmp"; import { runWrangler } from "../helpers/run-wrangler"; @@ -248,8 +249,34 @@ describe("migrate", () => { }); describe("with a temporary preview account", () => { - // cf does not expose Wrangler's preview-database selection flow. - it.todo("should apply migrations against the account it minted"); + // `d1 migrations apply` sends one query per statement. The account + // from the first authentication must also satisfy the later ones. + it("should apply migrations against the account it minted", async ({ + expect, + }) => { + setIsTTY(false); + writeMigration(); + const accountIds: string[] = []; + const queries = mockRemoteQueries([], (accountId) => + accountIds.push(accountId) + ); + const creationRequests = mockTemporaryPreviewAccount(); + + await runWrangler(`d1 migrations apply ${DB} --temporary-account`, { + CLOUDFLARE_API_TOKEN: undefined, + CLOUDFLARE_ACCOUNT_ID: undefined, + }); + + // cf writes temporary-account notices to stderr, so stdout keeps + // only the migration output. + expect(std.err).toContain("Temporary account ready:"); + expect(std.out).toContain("0001_test.sql"); + // More than one query proves that the command authenticated more + // than one time and did not stop at the first authentication. + expect(queries.length).toBeGreaterThan(1); + expect(new Set(accountIds)).toEqual(new Set(["preview-account-id"])); + expect(creationRequests).toHaveLength(1); + }); }); }); diff --git a/packages/wrangler-tests/src/__tests__/deploy/core.test.ts b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts new file mode 100644 index 0000000000..6f305084da --- /dev/null +++ b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts @@ -0,0 +1,511 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getGlobalConfigPath } from "@cloudflare/workers-utils"; +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { http, HttpResponse } from "msw/http"; +import { describe, it } from "vite-plus/test"; +import { mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { mockPrompt } from "../helpers/mock-dialogs"; +import { useMockIsTTY } from "../helpers/mock-istty"; +import { + mockTemporaryPreviewAccount, + mockTemporaryPreviewChallenge, + TEMPORARY_TERMS_NOTICE, + TEMPORARY_TERMS_PROMPT, + temporaryAccountCachePath, + writeTemporaryAccountCache, +} from "../helpers/mock-temporary-account"; +import { createFetchResult, msw } from "../helpers/msw"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; + +// cf deploys Build Output rather than bundling an entrypoint, so these cases +// seed `.cloudflare/output` and run `cf deploy --prebuilt`. cf spells +// Wrangler's `--temporary` flag `--temporary-account`, accepts it (and +// `--event-code`) on every command, and writes temporary-account notices to +// stderr so stdout stays reserved for command output. +describe("deploy", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + describe("authentication", () => { + mockApiToken({ apiToken: null }); + + it("creates a temporary preview account in interactive mode when --temporary is passed", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + await seedBuildOutput(); + const deployRequests = mockDeploy(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).resolves.toBeUndefined(); + + expect(creationRequests).toHaveLength(1); + expect(creationRequests[0]?.contentType).toBe("application/json"); + expectPreviewAccountRequests(expect, deployRequests); + expect(std.out).not.toContain("Attempting to login via OAuth..."); + expect(std.err).toContain("Temporary account ready:"); + }); + + it("requires --temporary when --event-code is passed", async ({ + expect, + }) => { + await expect( + runWrangler("deploy --prebuilt --event-code ABCD-EFGH-JKMN") + ).rejects.toThrow("--event-code requires --temporary-account"); + }); + + it("rejects a blank event code", async ({ expect }) => { + await expect( + runWrangler('deploy --prebuilt --temporary-account --event-code " "') + ).rejects.toThrow("--event-code cannot be empty"); + }); + + // cf registers --event-code globally with --temporary-account, so + // `workers versions create` accepts it as well. + it.skip("does not accept --event-code on versions upload"); + + it("rejects repeated event codes without exposing their values", async ({ + expect, + }) => { + const firstCode = "SECRET-CODE-ONE"; + const secondCode = "SECRET-CODE-TWO"; + const error = await runWrangler( + `deploy --prebuilt --temporary-account --event-code ${firstCode} --event-code ${secondCode}` + ).catch((cause: unknown) => cause); + + expect(String(error)).toContain("--event-code expects a single value"); + expect(String(error)).not.toContain(firstCode); + expect(String(error)).not.toContain(secondCode); + expect(std.out).not.toContain(firstCode); + expect(std.err).not.toContain(firstCode); + }); + + it("provisions an event account before deploying", async ({ expect }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + await seedBuildOutput(); + const deployRequests = mockDeploy(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler( + 'deploy --prebuilt --temporary-account --event-code " ABCD-EFGH-JKMN "' + ) + ).resolves.toBeUndefined(); + + expect(creationRequests.map(({ body }) => body)).toEqual([ + expect.objectContaining({ eventCode: "ABCD-EFGH-JKMN" }), + ]); + expectPreviewAccountRequests(expect, deployRequests); + expect(readFileSync(temporaryAccountCachePath(), "utf8")).not.toContain( + "ABCD-EFGH-JKMN" + ); + expect(std.out).not.toContain("ABCD-EFGH-JKMN"); + expect(std.err).not.toContain("ABCD-EFGH-JKMN"); + }); + + it("aborts in interactive mode when the terms are not accepted", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "no" }); + await seedBuildOutput(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).rejects.toThrow( + /You must accept Cloudflare's Terms of Service .* to use --temporary-account\./ + ); + + expect(creationRequests).toHaveLength(0); + }); + + it("throws when the user is already authenticated and requests a temporary account", async ({ + expect, + }) => { + setIsTTY(false); + await seedBuildOutput(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account", { + CLOUDFLARE_API_TOKEN: "cached-api-token", + }) + ).rejects.toThrow( + /You're already authenticated with Cloudflare, so `--temporary-account` can't be used\./ + ); + + expect(creationRequests).toHaveLength(0); + expect(std.err).not.toContain("Temporary account ready:"); + }); + + describe("with temporary preview accounts", () => { + it("creates a temporary preview account in non-interactive mode after printing terms notice", async ({ + expect, + }) => { + setIsTTY(false); + await seedBuildOutput(); + const deployRequests = mockDeploy(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).resolves.toBeUndefined(); + + expect(creationRequests).toHaveLength(1); + expect(creationRequests[0]?.contentType).toBe("application/json"); + expect(creationRequests[0]?.body).toEqual({ + termsOfService: "https://www.cloudflare.com/terms/", + privacyPolicy: "https://www.cloudflare.com/privacypolicy/", + acceptTermsOfService: "yes", + challengeToken: "challenge-token", + solution: { checkpoints: expect.any(String) }, + }); + const { solution } = (creationRequests[0]?.body ?? {}) as { + solution: { checkpoints: string }; + }; + // k+1 checkpoints of 32 bytes each (the challenge mock uses k=2). + expect(Buffer.from(solution.checkpoints, "base64").length).toBe( + (2 + 1) * 32 + ); + expectPreviewAccountRequests(expect, deployRequests); + expect(std.out).not.toContain("Attempting to login via OAuth..."); + expect(std.err).toContain(TEMPORARY_TERMS_NOTICE); + expect(std.err).toContain("Temporary account ready:"); + expect(std.err).toContain("Account: Preview Account Alpha (created)"); + expect(std.err).toContain("Claim within:"); + + const cachePath = temporaryAccountCachePath(); + expect(existsSync(cachePath)).toBe(true); + expect(existsSync(join(process.cwd(), ".wrangler"))).toBe(false); + if (process.platform !== "win32") { + const { statSync } = await import("node:fs"); + expect(statSync(cachePath).mode & 0o777).toBe(0o600); + } + expect(JSON.parse(readFileSync(cachePath, "utf8"))).toMatchObject({ + account: { + id: "preview-account-id", + apiToken: "preview-account-token", + }, + claim: { + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + }, + }); + }); + + it("throws when an OAuth token is on disk, even if it is expired and cannot refresh", async ({ + expect, + }) => { + setIsTTY(true); + // A stored OAuth token counts as being authenticated even when + // expired, so cf refuses rather than provisioning an account + // alongside it. + writeOAuthCredentials({ + oauth_token: "expired-token", + refresh_token: "expired-refresh-token", + expiration_time: new Date(Date.now() - 1000).toISOString(), + }); + await seedBuildOutput(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).rejects.toThrow( + /You're already authenticated with Cloudflare, so `--temporary-account` can't be used\./ + ); + + expect(creationRequests).toHaveLength(0); + expect(std.err).not.toContain("Temporary account ready:"); + }); + + it("provisions the preview account against the staging API and caches it per-environment", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + await seedBuildOutput(); + const deployRequests = mockDeploy(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account", { + WRANGLER_API_ENVIRONMENT: "staging", + }) + ).resolves.toBeUndefined(); + + expect(creationRequests.map(({ url }) => new URL(url).host)).toEqual([ + "api.staging.cloudflare.com", + ]); + expectPreviewAccountRequests(expect, deployRequests); + expect( + deployRequests.every( + ({ url }) => new URL(url).host === "api.staging.cloudflare.com" + ) + ).toBe(true); + expect(std.err).toContain("Temporary account ready:"); + expect(std.err).toContain("Account: Preview Account Alpha (created)"); + expect(existsSync(temporaryAccountCachePath("staging"))).toBe(true); + expect(existsSync(temporaryAccountCachePath())).toBe(false); + }); + + it("reuses a cached temporary preview account for later temporary deploys", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + await seedBuildOutput(); + const deployRequests = mockDeploy(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).resolves.toBeUndefined(); + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).resolves.toBeUndefined(); + + expect(creationRequests).toHaveLength(1); + expectPreviewAccountRequests(expect, deployRequests); + expect(std.err).toContain("Temporary account ready:"); + expect(std.err).toContain("Account: Preview Account Alpha (reused)"); + }); + + it("treats a malformed temporary preview account cache as a miss and refetches", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + await seedBuildOutput(); + // The cache passes a top-level shape check but lacks the nested + // account and claim fields. + writeTemporaryAccountCache({ account: {}, claim: {} }); + const deployRequests = mockDeploy(); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).resolves.toBeUndefined(); + + expect(creationRequests).toHaveLength(1); + expectPreviewAccountRequests(expect, deployRequests); + expect(std.err).toContain("Account: Preview Account Alpha (created)"); + expect( + JSON.parse(readFileSync(temporaryAccountCachePath(), "utf8")) + ).toMatchObject({ + account: { id: "preview-account-id" }, + claim: { + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + }, + }); + }); + + it("should fail clearly if the temporary preview account request fails", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + await seedBuildOutput(); + mockTemporaryPreviewChallenge(); + msw.use( + http.post( + "*/provisioning/previews", + () => + new HttpResponse(null, { + status: 500, + statusText: "Internal Server Error", + }) + ) + ); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).rejects.toThrow( + "Failed to create a temporary preview account (500 Internal Server Error)." + ); + }); + + it("fails the deploy when the proof-of-work challenge can't be obtained", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + await seedBuildOutput(); + let previewRequests = 0; + msw.use( + http.post( + "*/provisioning/previews/challenge", + () => + new HttpResponse(null, { + status: 500, + statusText: "Internal Server Error", + }) + ), + http.post("*/provisioning/previews", () => { + previewRequests++; + return HttpResponse.json({}); + }) + ); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).rejects.toThrow( + "Failed to request a proof-of-work challenge (500 Internal Server Error)." + ); + expect(previewRequests).toBe(0); + }); + }); + }); +}); + +async function seedBuildOutput(): Promise { + await seed({ + ".cloudflare/output/v0/config.json": JSON.stringify({ + buildContext: { isPreview: false }, + }), + ".cloudflare/output/v0/workers/default/worker.config.json": JSON.stringify({ + name: "test-name", + compatibilityDate: "2025-01-01", + manifest: { + type: "complete", + mainModule: "index.js", + modules: { "index.js": { type: "esm" } }, + }, + }), + ".cloudflare/output/v0/workers/default/bundle/index.js": + "export default { fetch() { return new Response('ok'); } }", + }); +} + +function writeOAuthCredentials(credentials: Record): void { + const authFile = join( + getGlobalConfigPath({ + appName: "cloudflare", + leadingDot: false, + useLegacyHomeDir: false, + }), + "config", + "default.json" + ); + mkdirSync(dirname(authFile), { recursive: true }); + writeFileSync(authFile, JSON.stringify(credentials)); +} + +interface DeployRequest { + accountId: string; + authorization: string | null; + url: string; +} + +function expectPreviewAccountRequests( + expect: (value: unknown) => { + toBeGreaterThan(value: number): void; + toEqual(value: unknown): void; + }, + requests: DeployRequest[] +): void { + expect(requests.length).toBeGreaterThan(0); + expect(new Set(requests.map(({ accountId }) => accountId))).toEqual( + new Set(["preview-account-id"]) + ); + expect(new Set(requests.map(({ authorization }) => authorization))).toEqual( + new Set(["Bearer preview-account-token"]) + ); +} + +/** + * Mock the account-scoped requests cf makes to deploy a new Worker. + * + * @returns Each request's account, authorization, and URL as it arrives. + */ +function mockDeploy(): DeployRequest[] { + const requests: DeployRequest[] = []; + const record = (request: Request, accountId: unknown) => { + requests.push({ + accountId: String(accountId), + authorization: request.headers.get("Authorization"), + url: request.url, + }); + }; + msw.use( + http.get( + "*/accounts/:accountId/workers/services/:scriptName", + ({ request, params }) => { + record(request, params.accountId); + return HttpResponse.json( + createFetchResult(null, false, [ + { code: 10007, message: "workers.api.error.not_found" }, + ]), + { status: 404 } + ); + } + ), + http.put( + "*/accounts/:accountId/workers/scripts/:scriptName", + ({ request, params }) => { + record(request, params.accountId); + return HttpResponse.json( + createFetchResult({ + id: "test-script-id", + etag: "test-etag", + deployment_id: "test-version-id", + default_environment: { + script: { last_deployed_from: "wrangler", tag: "test-tag" }, + }, + }) + ); + } + ), + http.get( + "*/accounts/:accountId/workers/subdomain", + ({ request, params }) => { + record(request, params.accountId); + return HttpResponse.json( + createFetchResult({ subdomain: "test-sub-domain" }) + ); + } + ), + http.get( + "*/accounts/:accountId/workers/workers/:scriptName", + ({ request, params }) => { + record(request, params.accountId); + return HttpResponse.json( + createFetchResult({ + subdomain: { enabled: false, previews_enabled: false }, + }) + ); + } + ), + http.post( + "*/accounts/:accountId/workers/scripts/:scriptName/subdomain", + ({ request, params }) => { + record(request, params.accountId); + return HttpResponse.json( + createFetchResult({ enabled: true, previews_enabled: true }) + ); + } + ), + http.put( + "*/accounts/:accountId/workers/scripts/:scriptName/schedules", + ({ request, params }) => { + record(request, params.accountId); + return HttpResponse.json(createFetchResult({ schedules: [] })); + } + ), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/deployments", + ({ request, params }) => { + record(request, params.accountId); + return HttpResponse.json(createFetchResult({ deployments: [] })); + } + ) + ); + return requests; +} diff --git a/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts b/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts new file mode 100644 index 0000000000..5d389ce59a --- /dev/null +++ b/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts @@ -0,0 +1,114 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getGlobalConfigPath } from "@cloudflare/workers-utils"; +import { http, HttpResponse } from "msw/http"; +import { createFetchResult, msw } from "./msw"; + +const temporaryPreviewAccountUrl = "*/provisioning/previews"; + +const termsOfService = "https://www.cloudflare.com/terms/"; +const privacyPolicy = "https://www.cloudflare.com/privacypolicy/"; + +/** The terms notice workers-auth prints instead of prompting in CI. */ +export const TEMPORARY_TERMS_NOTICE = `Continuing means you accept Cloudflare's Terms of Service (${termsOfService}) and Privacy Policy (${privacyPolicy}).`; + +/** The terms prompt workers-auth asks interactively. */ +export const TEMPORARY_TERMS_PROMPT = `You must accept Cloudflare's Terms of Service (${termsOfService}) and Privacy Policy (${privacyPolicy}) in order to continue. By typing "yes", you agree to these terms. Type "yes" to continue.`; + +/** + * cf's temporary-account cache. Computed lazily so `runInTempDir()`'s HOME and + * XDG_CONFIG_HOME stubs are honoured. + * + * @param environment The Cloudflare API environment the cache belongs to. + * @returns The absolute cache path. + */ +export function temporaryAccountCachePath(environment = "production"): string { + return join( + getGlobalConfigPath({ + appName: "cloudflare", + leadingDot: false, + useLegacyHomeDir: false, + }), + environment === "production" + ? "cloudflare-temporary-account.json" + : `cloudflare-temporary-account.${environment}.json` + ); +} + +/** + * Write raw contents to cf's temporary-account cache. + * + * @param contents The cache file contents. + */ +export function writeTemporaryAccountCache(contents: unknown): void { + const path = temporaryAccountCachePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(contents)); +} + +/** Mocks the proof-of-work challenge with a difficulty that solves instantly. */ +export function mockTemporaryPreviewChallenge(): void { + msw.use( + http.post(`${temporaryPreviewAccountUrl}/challenge`, () => + HttpResponse.json( + createFetchResult({ + challengeToken: "challenge-token", + seed: Buffer.alloc(32, 1).toString("base64url"), + k: 2, + g: 2, + s: 16, + expiresAt: 9999999999, + }) + ) + ) + ); +} + +/** + * Mocks the temporary preview-account challenge and creation endpoints. The + * created account is `preview-account-id` with token `preview-account-token`, + * and event codes are acknowledged. + * + * @returns The creation request bodies and headers, appended as requests arrive. + */ +export function mockTemporaryPreviewAccount(): Array<{ + body: { eventCode?: string }; + contentType: string | null; + url: string; +}> { + const creationRequests: Array<{ + body: { eventCode?: string }; + contentType: string | null; + url: string; + }> = []; + mockTemporaryPreviewChallenge(); + msw.use( + http.post(temporaryPreviewAccountUrl, async ({ request }) => { + const body = (await request.json()) as { eventCode?: string }; + creationRequests.push({ + body, + contentType: request.headers.get("Content-Type"), + url: request.url, + }); + return HttpResponse.json( + createFetchResult({ + account: { + id: "preview-account-id", + name: "Preview Account Alpha", + type: "standard", + apiToken: "preview-account-token", + tokenId: "preview-token-id", + expiresAt: "2999-01-01T00:00:00.000Z", + }, + claim: { + token: "claim-token", + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + expiresAt: "2999-01-02T00:00:00.000Z", + }, + ...(body.eventCode ? { eventCodeAccepted: true } : {}), + }) + ); + }) + ); + return creationRequests; +} diff --git a/packages/wrangler-tests/src/__tests__/index.test.ts b/packages/wrangler-tests/src/__tests__/index.test.ts index 51a4bed5fc..46cf658232 100644 --- a/packages/wrangler-tests/src/__tests__/index.test.ts +++ b/packages/wrangler-tests/src/__tests__/index.test.ts @@ -1,4 +1,7 @@ import { describe, it } from "vite-plus/test"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; // This file is the wrangler-CLI bootstrap test suite: it asserts the exact // shape of `wrangler --help`, the wrangler error formatting (`[ERROR]` chrome, @@ -8,10 +11,30 @@ import { describe, it } from "vite-plus/test"; // wrangler) but with brand-orange section headers + a different banner + // a different command catalogue (one chunk per generated product) + // version channel — none of these snapshots apply. Per AGENTS.md "src/ is -// product-agnostic" + "UX Conventions". Whole suite skipped. +// product-agnostic" + "UX Conventions". Only the global-flag situations that +// cf shares are ported. describe("wrangler", () => { + runInTempDir(); + const std = mockConsoleMethods(); + describe("no command", () => { it.skip("should display a list of available commands"); + + it("should not require temporary terms acceptance before root help", async ({ + expect, + }) => { + await runWrangler("--help --temporary-account"); + + expect(std.out).toContain("cf"); + expect(std.out).toContain("Commands"); + expect(std.err).toBe(""); + }); + }); + + describe("--temporary", () => { + // cf accepts --temporary-account on every command and lets the API + // report operations the temporary account's token cannot perform. + it.skip("is rejected on commands that don't opt in"); }); describe("invalid command", () => { diff --git a/packages/wrangler-tests/src/__tests__/logout.test.ts b/packages/wrangler-tests/src/__tests__/logout.test.ts index 2772b5f8b2..c1520ef733 100644 --- a/packages/wrangler-tests/src/__tests__/logout.test.ts +++ b/packages/wrangler-tests/src/__tests__/logout.test.ts @@ -4,6 +4,10 @@ import { getGlobalConfigPath } from "@cloudflare/workers-utils"; import { http, HttpResponse } from "msw/http"; import { describe, it } from "vite-plus/test"; import { mockConsoleMethods } from "./helpers/mock-console"; +import { + temporaryAccountCachePath, + writeTemporaryAccountCache, +} from "./helpers/mock-temporary-account"; import { msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; @@ -38,6 +42,18 @@ describe("logout", () => { expect(std.out).toContain("You are not currently logged in."); }); + it("should clear a cached temporary preview account when not logged in via OAuth", async ({ + expect, + }) => { + writeTemporaryAccountCache({ account: {}, claim: {} }); + + await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); + + expect(std.out).toContain("Cleared temporary preview account."); + expect(std.out).not.toContain("You are not currently logged in."); + expect(existsSync(temporaryAccountCachePath())).toBe(false); + }); + // Env `CLOUDFLARE_API_TOKEN` set with no on-disk credentials in the // stubbed HOME: cf prints the env-var hint and exits cleanly. it("should exit with a message stating the user logged in via API token", async ({ @@ -84,7 +100,10 @@ describe("logout", () => { }) ); + writeTemporaryAccountCache({ account: {}, claim: {} }); + expect(existsSync(authFile)).toBe(true); + expect(existsSync(temporaryAccountCachePath())).toBe(true); await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); @@ -94,6 +113,7 @@ describe("logout", () => { expect(revoked).toBe(true); // And the on-disk token file was removed. expect(existsSync(authFile)).toBe(false); + expect(existsSync(temporaryAccountCachePath())).toBe(false); }); // Wrangler-internal: asserted that `wrangler.jsonc` parsing warnings diff --git a/packages/wrangler-tests/src/__tests__/upstream/unported-01.test.ts b/packages/wrangler-tests/src/__tests__/upstream/unported-01.test.ts index b4467065b8..fe95a85ab1 100644 --- a/packages/wrangler-tests/src/__tests__/upstream/unported-01.test.ts +++ b/packages/wrangler-tests/src/__tests__/upstream/unported-01.test.ts @@ -780,7 +780,6 @@ const groups = [ ancestors: ["User", "login"], status: "skip", names: [ - "should clear the cached temporary preview account when logging in", "should send the `login user` metric when `--scopes` is provided", "--use-keyring stores credentials in an encrypted file with a key in the OS keyring", "--no-use-keyring scrubs encrypted credentials without writing them to plaintext, then the fresh login uses the file store", diff --git a/packages/wrangler-tests/src/__tests__/upstream/unported-05.test.ts b/packages/wrangler-tests/src/__tests__/upstream/unported-05.test.ts index 8827bf36d8..8bb3a0a092 100644 --- a/packages/wrangler-tests/src/__tests__/upstream/unported-05.test.ts +++ b/packages/wrangler-tests/src/__tests__/upstream/unported-05.test.ts @@ -719,14 +719,6 @@ const groups = [ "should register .mjs and .js files in workers/ as esm type", ], }, - { - file: "logout.test.ts", - ancestors: ["logout"], - status: "skip", - names: [ - "should clear a cached temporary preview account when not logged in via OAuth", - ], - }, { file: "logout.test.ts", ancestors: ["logout"], @@ -998,14 +990,6 @@ const groups = [ "cancel() is a no-op when nothing is pending", ], }, - { - file: "whoami.test.ts", - ancestors: ["whoami"], - status: "skip", - names: [ - "should suggest a temporary preview account when not authenticated", - ], - }, { file: "whoami.test.ts", ancestors: ["whoami"], diff --git a/packages/wrangler-tests/src/__tests__/upstream/unported-07.test.ts b/packages/wrangler-tests/src/__tests__/upstream/unported-07.test.ts index 0cb8a1d80d..40e5043650 100644 --- a/packages/wrangler-tests/src/__tests__/upstream/unported-07.test.ts +++ b/packages/wrangler-tests/src/__tests__/upstream/unported-07.test.ts @@ -223,15 +223,7 @@ const groups = [ ancestors: ["deploy", "authentication"], status: "skip", names: [ - "creates a temporary preview account in interactive mode when --temporary is passed", - "requires --temporary when --event-code is passed", - "rejects a blank event code", - "does not accept --event-code on versions upload", - "rejects repeated event codes without exposing their values", - "provisions an event account before deploying", - "aborts in interactive mode when the terms are not accepted", "warns a user when they're authenticated with an API token in wrangler config file", - "throws when the user is already authenticated and requests a temporary account", ], }, { @@ -240,20 +232,6 @@ const groups = [ status: "todo", names: ["drops a user into the login flow if they're unauthenticated"], }, - { - file: "deploy/core.test.ts", - ancestors: ["deploy", "authentication", "with temporary preview accounts"], - status: "skip", - names: [ - "creates a temporary preview account in non-interactive mode after printing terms notice", - "throws when an OAuth token is on disk, even if it is expired and cannot refresh", - "provisions the preview account against the staging API and caches it per-environment", - "reuses a cached temporary preview account for later temporary deploys", - "treats a malformed temporary preview account cache as a miss and refetches", - "should fail clearly if the temporary preview account request fails", - "fails the deploy when the proof-of-work challenge can't be obtained", - ], - }, { file: "deploy/core.test.ts", ancestors: ["deploy", "authentication", "with temporary preview accounts"], diff --git a/packages/wrangler-tests/src/__tests__/upstream/unported-11.test.ts b/packages/wrangler-tests/src/__tests__/upstream/unported-11.test.ts index ce144761f8..bad7c4ceee 100644 --- a/packages/wrangler-tests/src/__tests__/upstream/unported-11.test.ts +++ b/packages/wrangler-tests/src/__tests__/upstream/unported-11.test.ts @@ -361,18 +361,6 @@ const groups = [ "does not print the Local Explorer API hint when the caller has not opted in", ], }, - { - file: "index.test.ts", - ancestors: ["wrangler", "no command"], - status: "skip", - names: ["should not require temporary terms acceptance before root help"], - }, - { - file: "index.test.ts", - ancestors: ["wrangler", "--temporary"], - status: "skip", - names: ["is rejected on commands that don't opt in"], - }, { file: "index.test.ts", ancestors: ["wrangler", "command typo suggestions"], diff --git a/packages/wrangler-tests/src/__tests__/user.test.ts b/packages/wrangler-tests/src/__tests__/user.test.ts index 000fafc6b7..4d5dd54368 100644 --- a/packages/wrangler-tests/src/__tests__/user.test.ts +++ b/packages/wrangler-tests/src/__tests__/user.test.ts @@ -44,6 +44,12 @@ describe("User", () => { it.skip("login works in a different environment"); it.todo('should error if the compliance region is not "public"'); + + // workers-auth clears cf's temporary-account cache after a successful + // login. This needs the same browser OAuth harness as the login case. + it.todo( + "should clear the cached temporary preview account when logging in" + ); }); it.todo( diff --git a/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts index ad7048e3a7..2d35a25bd6 100644 --- a/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts @@ -1,6 +1,14 @@ import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { http, HttpResponse } from "msw/http"; import { describe, it, test } from "vite-plus/test"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { useMockIsTTY } from "../helpers/mock-istty"; +import { + mockTemporaryPreviewAccount, + TEMPORARY_TERMS_NOTICE, +} from "../helpers/mock-temporary-account"; +import { createFetchResult, msw } from "../helpers/msw"; import { runInTempDir } from "../helpers/run-in-tmp"; import { runWrangler } from "../helpers/run-wrangler"; @@ -8,11 +16,62 @@ describe("versions upload", () => { mockAccountId(); mockApiToken(); runInTempDir(); + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); - // Temporary preview-account creation belongs to Wrangler's upload UX. - test.skip( - "should create a temporary account in non-interactive mode after printing terms notice" - ); + describe("with --temporary", () => { + mockAccountId({ accountId: null }); + mockApiToken({ apiToken: null }); + + // cf writes temporary-account notices to stderr. + test("should create a temporary account in non-interactive mode after printing terms notice", async ({ + expect, + }) => { + setIsTTY(false); + await seedBuildOutput({ name: "test-name" }); + const creationRequests = mockTemporaryPreviewAccount(); + const accountIds: string[] = []; + msw.use( + http.get( + "*/accounts/:accountId/workers/services/:scriptName", + ({ params }) => { + accountIds.push(String(params.accountId)); + return HttpResponse.json( + createFetchResult({ + default_environment: { + script: { last_deployed_from: "wrangler" }, + }, + }) + ); + } + ), + http.post( + "*/accounts/:accountId/workers/scripts/:scriptName/versions", + ({ params }) => { + accountIds.push(String(params.accountId)); + return HttpResponse.json( + createFetchResult({ + id: "test-version-id", + startup_time_ms: 0, + metadata: { has_preview: false }, + resources: { script: { etag: "test-etag" } }, + }) + ); + } + ) + ); + + await expect( + runWrangler("workers versions create --prebuilt --temporary-account") + ).resolves.toBeUndefined(); + + expect(creationRequests).toHaveLength(1); + expect(new Set(accountIds)).toEqual(new Set(["preview-account-id"])); + expect(std.err).toContain(TEMPORARY_TERMS_NOTICE); + expect(std.err).toContain("Temporary account ready:"); + expect(std.err).toContain("Account: Preview Account Alpha (created)"); + }); + }); // cf's hand-written `workers versions create` builds and reads Build Output, // then delegates the upload. These cases assert Wrangler-specific source, diff --git a/packages/wrangler-tests/src/__tests__/whoami.test.ts b/packages/wrangler-tests/src/__tests__/whoami.test.ts index 50b29b3d28..7682e0dc7a 100644 --- a/packages/wrangler-tests/src/__tests__/whoami.test.ts +++ b/packages/wrangler-tests/src/__tests__/whoami.test.ts @@ -83,6 +83,18 @@ describe("whoami", () => { }); }); + it("should suggest a temporary preview account when not authenticated", async ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); + await runWrangler("auth whoami"); + expect(JSON.parse(std.out)).toMatchObject({ authenticated: false }); + // The suggestion goes to stderr so stdout stays parseable JSON. + expect(std.err).toContain( + "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary account." + ); + }); + it("should output JSON with API token auth type", async ({ expect }) => { vi.stubEnv("CLOUDFLARE_API_TOKEN", "123456789"); await runWrangler("auth whoami"); From e1ca145464e9ff971b8d4be38aa9e3ff99b11e69 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 13 Oct 2026 21:51:14 +0100 Subject: [PATCH 02/15] Simplify temporary accounts and use shared logout messaging --- .changeset/temporary-accounts.md | 4 ++ .../__tests__/commands/auth/profiles.test.ts | 5 +- .../cli/src/__tests__/lib/context.test.ts | 4 +- .../src/__tests__/temporary-accounts.test.ts | 6 +- packages/cli/src/commands/auth/logout.ts | 55 +------------------ packages/cli/src/index.ts | 6 +- packages/cli/src/lib/auth-token.ts | 10 +--- packages/cli/src/lib/auth.ts | 11 ++-- packages/cli/src/lib/context.ts | 17 +++--- packages/cli/src/lib/oauth/index.ts | 53 ++++-------------- .../src/__tests__/logout.test.ts | 21 +++---- 11 files changed, 45 insertions(+), 147 deletions(-) diff --git a/.changeset/temporary-accounts.md b/.changeset/temporary-accounts.md index 22a493c294..aeebb349c6 100644 --- a/.changeset/temporary-accounts.md +++ b/.changeset/temporary-accounts.md @@ -13,3 +13,7 @@ with `--temporary-account`. Pass `--event-code` with `--temporary-account` to redeem an event code when cf creates the account. cf does not cache, print, or record the code in telemetry. + +`cf auth logout` now uses the same shared messages as Wrangler and also clears a +cached temporary account. When `CLOUDFLARE_API_TOKEN` is set, it asks you to +unset the variable and leaves any stored OAuth login in place, as Wrangler does. diff --git a/packages/cli/src/__tests__/commands/auth/profiles.test.ts b/packages/cli/src/__tests__/commands/auth/profiles.test.ts index 0e7c8b97c4..811997274a 100644 --- a/packages/cli/src/__tests__/commands/auth/profiles.test.ts +++ b/packages/cli/src/__tests__/commands/auth/profiles.test.ts @@ -193,9 +193,8 @@ describe("cf auth profiles", () => { await runCf(["auth", "logout"]); expect(normalizeProfileOutput(std.getAndClearOut())).toMatchInlineSnapshot(` "⚠ This directory has profile "work" active. \`cf auth logout\` removes the default profile's token, not "work". - To delete "work", run \`cf auth delete work\`. - ℹ Logging out... - Removed: OAuth tokens from /home/.config/cloudflare/config/default.json" + To delete "work", run \`cf auth delete work\`." `); + expect(std.err).toContain("Successfully logged out."); }); }); diff --git a/packages/cli/src/__tests__/lib/context.test.ts b/packages/cli/src/__tests__/lib/context.test.ts index d518136f20..0e6e35e237 100644 --- a/packages/cli/src/__tests__/lib/context.test.ts +++ b/packages/cli/src/__tests__/lib/context.test.ts @@ -23,6 +23,7 @@ vi.mock("@cloudflare/workers-auth/cf", () => ({ getOrSelectAccountId: getOrSelectAccountIdMock, requireAuth: requireAuthMock, setTemporaryAllowed: vi.fn(), + requireApiToken: () => ({ apiToken: "temporary-token" }), getActiveAccountId: getActiveAccountIdMock, fetchAllAccounts: fetchAllAccountsMock, }), @@ -83,9 +84,6 @@ describe("context", () => { await expect(resolveAccountIdSilent()).resolves.toBeUndefined(); await expect(getAccountId()).resolves.toBe("temporary-account"); - await expect(resolveAccountIdSilent()).resolves.toBe("temporary-account"); - await expect(getAccountId()).resolves.toBe("temporary-account"); - expect(requireAuthMock).toHaveBeenCalledOnce(); expect(requireAuthMock).toHaveBeenCalledWith({ compliance_region: "public", }); diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index f545978d2c..afd79522ff 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -337,14 +337,12 @@ describe("--temporary-account", () => { }); it("clears a cached account on logout without OAuth credentials", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const stderr = captureStderr(); await runCf(["auth", "logout"], env); expect(existsSync(cachePath())).toBe(false); - expect(log.mock.calls.flat().join("\n")).toContain( - "Cleared temporary preview account." - ); + expect(stderr()).toContain("Cleared temporary preview account."); }); it("suggests a temporary account from whoami when unauthenticated", async () => { diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 43ef606ef8..01b5e117e9 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -1,12 +1,5 @@ -import { - clearTemporaryAccount, - getActiveProfile, - getConfigPath, - isOAuthLoggedIn, - logout, - setProfile, -} from "../../lib/oauth/index.js"; -import { hint, info, theme, warning } from "../../lib/ui/index.js"; +import { getActiveProfile, logout } from "../../lib/oauth/index.js"; +import { warning } from "../../lib/ui/index.js"; import { assertNoProfileFlag } from "./profiles.js"; import type { CommonYargsOptions } from "../../lib/cli-types.js"; import type { CommandModule } from "yargs"; @@ -33,51 +26,7 @@ const logoutCommand: CommandModule = { ); } - setProfile("default"); - - const hasOAuthTokens = isOAuthLoggedIn(); - const hasEnvToken = !!process.env.CLOUDFLARE_API_TOKEN; - - if (!hasOAuthTokens) { - const clearedTemporaryAccount = clearTemporaryAccount(); - if (clearedTemporaryAccount) { - console.log(info("Cleared temporary preview account.")); - } - if (hasEnvToken) { - console.log( - info( - "No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN environment variable is set." - ) - ); - console.log(info("Unset it with: unset CLOUDFLARE_API_TOKEN")); - } else if (!clearedTemporaryAccount) { - console.log(info("You are not currently logged in.")); - console.log( - hint(`Run ${theme.code("cf auth login")} to authenticate.`) - ); - } - return; - } - - console.log(info("Logging out...")); - await logout("default"); - - console.log( - `${theme.info("Removed:")} OAuth tokens from ${theme.muted(getConfigPath())}` - ); - - if (hasEnvToken) { - console.log(""); - console.log( - warning("CLOUDFLARE_API_TOKEN environment variable is still set.") - ); - console.log( - info( - "You will remain authenticated via the env var. Unset it with: unset CLOUDFLARE_API_TOKEN" - ) - ); - } }, }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e143fe7820..cd1ef88b82 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -674,12 +674,8 @@ export function buildCli(rawArgs: string[], options: BuildCliOptions = {}) { temporaryAccount, eventCode === undefined ? undefined : { eventCode } ); - // Temporary accounts still check the resolved profile for existing - // credentials, but they don't use that profile. const profile = resolveProfile(argv.profile); - if (!temporaryAccount) { - printActiveProfileLine(profile); - } + printActiveProfileLine(profile); } }) .demandCommand(1, "You need to specify a command") diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index d601fe6ed4..08448ede65 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -1,11 +1,10 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { MissingCredentialsError } from "./errors.js"; import { - activateTemporaryAccount, getValidToken as getOAuthToken, - getTemporaryAccountApiToken, isOAuthLoggedIn, isTemporaryAccountAllowed, + requireTemporaryAccount, } from "./oauth/index.js"; /** @@ -22,12 +21,7 @@ import { */ export async function getAuthToken(): Promise { if (isTemporaryAccountAllowed()) { - await activateTemporaryAccount(); - const temporaryToken = getTemporaryAccountApiToken(); - if (!temporaryToken) { - throw new Error("The temporary account has no API token."); - } - return temporaryToken; + return (await requireTemporaryAccount()).apiToken; } // Environment credential (scoped API token only). diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index ac9569136a..4ba2cd8f92 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -4,8 +4,8 @@ import { isUploadBody } from "./api-upload.js"; import { getAuthToken } from "./auth-token.js"; import { getComplianceRegion, resolveAccountIdSilent } from "./context.js"; import { - activateTemporaryAccount, isTemporaryAccountAllowed, + requireTemporaryAccount, } from "./oauth/index.js"; import { getDefaultHeaders } from "./request-headers.js"; import { ApiError, Client, type ClientOptions } from "#api"; @@ -290,11 +290,10 @@ export async function resolveAccountIdForHelp( deadline: number ): Promise { if (isTemporaryAccountAllowed()) { - try { - return await activateTemporaryAccount(); - } catch { - return undefined; - } + return requireTemporaryAccount().then( + ({ accountId }) => accountId, + () => undefined + ); } const stored = await resolveAccountIdSilent(); diff --git a/packages/cli/src/lib/context.ts b/packages/cli/src/lib/context.ts index b13584a774..d4b85e29cc 100644 --- a/packages/cli/src/lib/context.ts +++ b/packages/cli/src/lib/context.ts @@ -85,15 +85,12 @@ async function resolveAccountId( */ export async function resolveAccountIdSilent(): Promise { // Lazily imported for the same dependency-cycle reason as getAccountId. - const { - getActiveAccountId, - getActiveTemporaryAccountId, - isTemporaryAccountAllowed, - } = await import("./oauth/index.js"); + const { getActiveAccountId, isTemporaryAccountAllowed } = + await import("./oauth/index.js"); + // Configured accounts don't apply to a temporary account, and provisioning + // one is not silent. if (isTemporaryAccountAllowed()) { - // Configured accounts don't apply to a temporary account invocation, - // and provisioning one is not silent. - return getActiveTemporaryAccountId(); + return undefined; } const result = await resolveAccountId(); if (result) { @@ -122,12 +119,12 @@ export async function getAccountId(options?: { // oauth/index also consumes compliance context. Import it lazily after this // module has initialised to avoid a static context ↔ OAuth dependency cycle. const { - activateTemporaryAccount, getOrSelectAccountId, isTemporaryAccountAllowed, + requireTemporaryAccount, } = await import("./oauth/index.js"); if (isTemporaryAccountAllowed()) { - return activateTemporaryAccount(options); + return (await requireTemporaryAccount(options)).accountId; } const result = await resolveAccountId(options); diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index 2e926e50ce..ce2c1023d1 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -5,11 +5,9 @@ * storage, refresh, and future keyring support. This module only supplies cf's * logger and prompt primitives, then preserves the façade consumed by commands. */ -import { existsSync, rmSync } from "node:fs"; import { isCancel } from "@clack/core"; import * as clack from "@clack/prompts"; import { - CF_CLI, createCfAuth, createCfProfileStore, validateScopeKeys, @@ -88,7 +86,6 @@ const auth = createCfAuth({ }); let temporaryAccountAllowed = false; -let activeTemporaryAccountId: string | undefined; /** * Mark whether this invocation may provision a temporary preview account, @@ -99,60 +96,29 @@ export function setTemporaryAccountAllowed( request?: TemporaryAccountRequest ): void { temporaryAccountAllowed = allowed; - activeTemporaryAccountId = undefined; auth.setTemporaryAllowed(allowed, request); } -/** - * Delete the cached temporary preview account. - * - * @returns Whether a cached account existed. - */ -export function clearTemporaryAccount(): boolean { - const path = CF_CLI.getTemporaryAccountConfigPath(); - const existed = existsSync(path); - rmSync(path, { force: true }); - return existed; -} - /** Whether this invocation requested a temporary preview account. */ export function isTemporaryAccountAllowed(): boolean { return temporaryAccountAllowed; } -/** The temporary account already activated for this invocation. */ -export function getActiveTemporaryAccountId(): string | undefined { - return activeTemporaryAccountId; -} - -/** The API token for the temporary account activated this invocation. */ -export function getTemporaryAccountApiToken(): string | undefined { - if (activeTemporaryAccountId === undefined) { - return undefined; - } - const credentials = auth.getAPIToken(); - return credentials && "apiToken" in credentials - ? credentials.apiToken - : undefined; -} - /** - * Provision or reuse this invocation's temporary preview account and return - * its ID. workers-auth rejects the request when credentials already exist. + * Provision or reuse this invocation's temporary preview account. workers-auth + * keeps it active for later calls and rejects it when credentials exist. */ -export async function activateTemporaryAccount(options?: { +export async function requireTemporaryAccount(options?: { isPreview?: boolean; complianceRegion?: ComplianceRegion; -}): Promise { - if (activeTemporaryAccountId !== undefined) { - return activeTemporaryAccountId; - } +}): Promise<{ accountId: string; apiToken: string }> { try { - activeTemporaryAccountId = await auth.requireAuth({ + const accountId = await auth.requireAuth({ compliance_region: options?.complianceRegion ?? (await getComplianceRegion(options)), }); - return activeTemporaryAccountId; + const { apiToken } = auth.requireApiToken() as { apiToken: string }; + return { accountId, apiToken }; } catch (error) { // workers-auth's shared temporary-account errors name Wrangler's // `--temporary` flag. Keep cf's public spelling in those messages. @@ -224,7 +190,10 @@ export async function login(opts?: { ); } -/** Revoke the stored refresh token and delete cf's token file. */ +/** + * Clear any cached temporary account, then revoke the stored refresh token and + * delete cf's token file. Prints workers-auth's shared logout messages. + */ export function logout(profile?: string): Promise { return auth.logout(profile); } diff --git a/packages/wrangler-tests/src/__tests__/logout.test.ts b/packages/wrangler-tests/src/__tests__/logout.test.ts index c1520ef733..142b9d8566 100644 --- a/packages/wrangler-tests/src/__tests__/logout.test.ts +++ b/packages/wrangler-tests/src/__tests__/logout.test.ts @@ -32,14 +32,13 @@ describe("logout", () => { runInTempDir(); const std = mockConsoleMethods(); - // cf's `loadTokens()` / `loadApiToken()` now resolve their paths via - // `homedir()` at call time, so `runInTempDir()`'s HOME stub correctly - // isolates token reads to the empty temp directory. + // cf shares Wrangler's workers-auth logout messages, but its auth logger + // writes them to stderr. it("should exit with a message stating the user is not logged in", async ({ expect, }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); - expect(std.out).toContain("You are not currently logged in."); + expect(std.err).toContain("Not logged in, exiting..."); }); it("should clear a cached temporary preview account when not logged in via OAuth", async ({ @@ -49,21 +48,18 @@ describe("logout", () => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); - expect(std.out).toContain("Cleared temporary preview account."); - expect(std.out).not.toContain("You are not currently logged in."); + expect(std.err).toContain("Cleared temporary preview account."); + expect(std.err).not.toContain("Not logged in, exiting..."); expect(existsSync(temporaryAccountCachePath())).toBe(false); }); - // Env `CLOUDFLARE_API_TOKEN` set with no on-disk credentials in the - // stubbed HOME: cf prints the env-var hint and exits cleanly. it("should exit with a message stating the user logged in via API token", async ({ expect, }) => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: "DUMMY_TOKEN" }); - expect(std.out).toContain( - "No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN environment variable is set." + expect(std.err).toContain( + "You are logged in with an API Token. Unset the CLOUDFLARE_API_TOKEN in the environment to log out." ); - expect(std.out).toContain("Unset it with: unset CLOUDFLARE_API_TOKEN"); }); // Ported now that token paths resolve via `homedir()` at call time @@ -107,8 +103,7 @@ describe("logout", () => { await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); - // cf reports the credential kind and path that it removed. - expect(std.out).toContain("Removed: OAuth tokens from"); + expect(std.err).toContain("Successfully logged out."); // The refresh token was sent to the revocation endpoint. expect(revoked).toBe(true); // And the on-disk token file was removed. From a0773a00acc4b63abc64952741af9006a3eaba2c Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 13 Oct 2026 21:56:56 +0100 Subject: [PATCH 03/15] Match Wrangler's temporary-account hints --- packages/cli/src/__tests__/lib/auth-token.test.ts | 1 - packages/cli/src/__tests__/lib/errors.test.ts | 13 +++++++++++-- .../cli/src/__tests__/temporary-accounts.test.ts | 2 +- packages/cli/src/commands/auth/whoami.ts | 2 +- packages/cli/src/lib/auth-token.ts | 10 ++-------- packages/cli/src/lib/errors.ts | 8 ++++---- .../wrangler-tests/src/__tests__/whoami.test.ts | 2 +- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/__tests__/lib/auth-token.test.ts b/packages/cli/src/__tests__/lib/auth-token.test.ts index 9099861bef..4700c38be9 100644 --- a/packages/cli/src/__tests__/lib/auth-token.test.ts +++ b/packages/cli/src/__tests__/lib/auth-token.test.ts @@ -5,7 +5,6 @@ import { CliExit } from "../../lib/cli-exit.js"; const { getOAuthToken } = vi.hoisted(() => ({ getOAuthToken: vi.fn() })); vi.mock("../../lib/oauth/index.js", () => ({ getValidToken: getOAuthToken, - isOAuthLoggedIn: () => false, isTemporaryAccountAllowed: () => false, })); diff --git a/packages/cli/src/__tests__/lib/errors.test.ts b/packages/cli/src/__tests__/lib/errors.test.ts index 68f7f96d02..946094c2ff 100644 --- a/packages/cli/src/__tests__/lib/errors.test.ts +++ b/packages/cli/src/__tests__/lib/errors.test.ts @@ -54,7 +54,7 @@ describe("handleError", () => { expect(handleError(err)).toBe(err); }); - it("recommends a temporary account only when credentials are missing", () => { + it("recommends a temporary account when non-interactive auth fails", () => { handleError(new MissingCredentialsError("No authentication token found.")); expect(stderr()).toContain("--temporary-account"); @@ -70,7 +70,16 @@ describe("handleError", () => { errSpy.mockClear(); handleError( new UserError( - "Not logged in. Your auth token has expired and could not be refreshed.", + "Not logged in. Your auth token has expired and could not be refreshed, and the environment is non-interactive.", + { telemetryMessage: "cfetch auth login required" } + ) + ); + expect(stderr()).toContain("--temporary-account"); + + errSpy.mockClear(); + handleError( + new UserError( + "Not logged in. Your auth token has expired and could not be refreshed because the Cloudflare auth server could not be reached.", { telemetryMessage: "cfetch auth login required" } ) ); diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index afd79522ff..bc00628d5d 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -239,7 +239,7 @@ describe("--temporary-account", () => { CLOUDFLARE_ACCOUNT_ID: "configured-account-id", }) ).rejects.toThrow("No authentication token found"); - expect(stderr()).toContain("rerun this command with --temporary-account"); + expect(stderr()).toContain("rerun this command with `--temporary-account`"); }); it("creates an event account without persisting or printing the code", async () => { diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 2c9ac1ca5d..49951e7a30 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -41,7 +41,7 @@ const whoamiCommand: CommandModule = { if (authSource === "none") { console.error( hint( - "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary account." + "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary preview account." ) ); } diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index 08448ede65..dfd481a019 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -2,7 +2,6 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { MissingCredentialsError } from "./errors.js"; import { getValidToken as getOAuthToken, - isOAuthLoggedIn, isTemporaryAccountAllowed, requireTemporaryAccount, } from "./oauth/index.js"; @@ -36,16 +35,11 @@ export async function getAuthToken(): Promise { return cfToken; } - const message = `No authentication token found. + throw new MissingCredentialsError(`No authentication token found. Please set one of the following: 1. Set the CLOUDFLARE_API_TOKEN environment variable 2. Run 'cf auth login' to authenticate via OAuth -For API tokens, visit: https://dash.cloudflare.com/profile/api-tokens`; - // A stored OAuth token that could not refresh still counts as existing - // credentials, so a temporary account would be rejected. - throw isOAuthLoggedIn() - ? new Error(message) - : new MissingCredentialsError(message); +For API tokens, visit: https://dash.cloudflare.com/profile/api-tokens`); } diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 9bc3e8e80d..89ff1f43d1 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -10,8 +10,8 @@ import { ApiError } from "#api"; export class MissingCredentialsError extends Error {} /** - * Whether an error means no credentials exist, rather than that existing - * credentials expired. Only unauthenticated users can use a temporary account. + * Whether to suggest a temporary account. Like Wrangler, this covers missing or + * expired credentials in a non-interactive environment. */ function isMissingCredentialsError(error: Error): boolean { if (error instanceof MissingCredentialsError) { @@ -22,7 +22,7 @@ function isMissingCredentialsError(error: Error): boolean { return ( "telemetryMessage" in error && error.telemetryMessage === "cfetch auth login required" && - error.message.includes("no credentials were found") + error.message.includes("the environment is non-interactive") ); } @@ -223,7 +223,7 @@ export function handleError(error: unknown): unknown { console.error( "\n" + hint( - "To continue without logging in, rerun this command with --temporary-account. cf will use a temporary account and print a URL for claiming it." + "To continue without logging in, rerun this command with `--temporary-account`. cf will use a temporary account and print a claim URL." ) ); } diff --git a/packages/wrangler-tests/src/__tests__/whoami.test.ts b/packages/wrangler-tests/src/__tests__/whoami.test.ts index 7682e0dc7a..39b7a3c608 100644 --- a/packages/wrangler-tests/src/__tests__/whoami.test.ts +++ b/packages/wrangler-tests/src/__tests__/whoami.test.ts @@ -91,7 +91,7 @@ describe("whoami", () => { expect(JSON.parse(std.out)).toMatchObject({ authenticated: false }); // The suggestion goes to stderr so stdout stays parseable JSON. expect(std.err).toContain( - "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary account." + "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary preview account." ); }); From a4c65f3450b8e61d607544ffa439dee11f8de1fa Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 13 Oct 2026 22:15:17 +0100 Subject: [PATCH 04/15] Prefer temporary accounts over Build Output accounts and fix CI prompt detection --- .../commands/previews-deploy.test.ts | 6 +++-- .../cli/src/__tests__/lib/context.test.ts | 12 +++++++++ .../src/__tests__/temporary-accounts.test.ts | 27 +++++++++++++++++++ packages/cli/src/commands/auth/whoami.ts | 3 +++ packages/cli/src/commands/deploy/shared.ts | 6 ++--- packages/cli/src/commands/previews/deploy.ts | 13 +++++---- .../src/commands/workers/triggers/deploy.ts | 6 ++--- packages/cli/src/lib/context.ts | 16 +++++++---- .../src/__tests__/deploy/core.test.ts | 2 ++ .../versions/versions.upload.test.ts | 14 ++++++++-- packages/wrangler-tests/vite.config.ts | 4 ++- 11 files changed, 86 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/__tests__/commands/previews-deploy.test.ts b/packages/cli/src/__tests__/commands/previews-deploy.test.ts index dcd52bdb03..f69a741c9e 100644 --- a/packages/cli/src/__tests__/commands/previews-deploy.test.ts +++ b/packages/cli/src/__tests__/commands/previews-deploy.test.ts @@ -205,7 +205,7 @@ describe("cf previews deploy", () => { }); mocks.assembleBuildResult.mockReturnValue({ content: "worker code" }); mocks.getAuthToken.mockResolvedValue("token"); - mocks.getAccountId.mockResolvedValue("account-id"); + mocks.getAccountId.mockResolvedValue("built-preview-account"); mocks.createDeployContext.mockReturnValue({ logger: { log: vi.fn(), info: vi.fn() }, }); @@ -219,7 +219,9 @@ describe("cf previews deploy", () => { ).resolves.toBe(previewResult); expect(mocks.runBuild).not.toHaveBeenCalled(); - expect(mocks.getAccountId).not.toHaveBeenCalled(); + expect(mocks.getAccountId).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "built-preview-account" }) + ); expect(mocks.previewBuildOutput).toHaveBeenCalledWith( "built-preview-account", { name: "feature", json: true }, diff --git a/packages/cli/src/__tests__/lib/context.test.ts b/packages/cli/src/__tests__/lib/context.test.ts index 0e6e35e237..82890a82ee 100644 --- a/packages/cli/src/__tests__/lib/context.test.ts +++ b/packages/cli/src/__tests__/lib/context.test.ts @@ -77,6 +77,15 @@ describe("context", () => { }); }); + it("prefers a Build Output account ID without selecting one", async () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "env-acct"); + + await expect( + getAccountId({ accountId: "build-output-acct" }) + ).resolves.toBe("build-output-acct"); + expect(getOrSelectAccountIdMock).not.toHaveBeenCalled(); + }); + it("uses a temporary account instead of configured accounts", async () => { writeProjectSettings({ accountId: "settings-acct" }); setTemporaryAccountAllowed(true); @@ -84,6 +93,9 @@ describe("context", () => { await expect(resolveAccountIdSilent()).resolves.toBeUndefined(); await expect(getAccountId()).resolves.toBe("temporary-account"); + await expect( + getAccountId({ accountId: "build-output-acct" }) + ).resolves.toBe("temporary-account"); expect(requireAuthMock).toHaveBeenCalledWith({ compliance_region: "public", }); diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index bc00628d5d..920c4d4bcd 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -355,6 +355,33 @@ describe("--temporary-account", () => { expect(stderr()).toContain("cf deploy --temporary-account"); }); + it("reports the temporary account as whoami's auth source", async () => { + captureStderr(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const authorizations: Array = []; + server.use( + http.get(`${TEST_BASE_URL}/user`, ({ request }) => { + authorizations.push(request.headers.get("authorization")); + return HttpResponse.json({ success: true, result: {} }); + }), + http.get(`${TEST_BASE_URL}/accounts`, ({ request }) => { + authorizations.push(request.headers.get("authorization")); + return HttpResponse.json({ + success: true, + result: [{ id: accountId, name: "Temporary Account" }], + }); + }) + ); + + await runCf(["auth", "whoami", "--temporary-account"], env); + + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toMatchObject({ + authenticated: true, + authSource: "temporary account", + }); + expect(new Set(authorizations)).toEqual(new Set([`Bearer ${apiToken}`])); + }); + it("rejects the Wrangler flag spelling", async () => { await expect( runCf(["kv", "namespaces", "list", "--temporary"], env) diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 49951e7a30..4b4f7defd3 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -2,6 +2,7 @@ import { getAuthToken } from "../../lib/auth-token.js"; import { fetchAuthorizedAccounts, getConfigPath, + isTemporaryAccountAllowed, readAuthCredentials, } from "../../lib/oauth/index.js"; import { formatOutput } from "../../lib/output.js"; @@ -28,6 +29,8 @@ const whoamiCommand: CommandModule = { authSource = "CLOUDFLARE_API_TOKEN environment variable"; } else if (stored?.oauth_token) { authSource = `OAuth token from ${getConfigPath()}`; + } else if (isTemporaryAccountAllowed()) { + authSource = "temporary account"; } else { authSource = "none"; } diff --git a/packages/cli/src/commands/deploy/shared.ts b/packages/cli/src/commands/deploy/shared.ts index c2277dca36..5652afbd88 100644 --- a/packages/cli/src/commands/deploy/shared.ts +++ b/packages/cli/src/commands/deploy/shared.ts @@ -143,11 +143,11 @@ async function uploadBuildOutput(argv: UploadArgs, ctx: UploadCommand) { const authToken = argv["dry-run"] ? "" : await getAuthToken(); const accountId = argv["dry-run"] ? undefined - : (rootConfig.accountId ?? - (await getAccountId({ + : await getAccountId({ + accountId: rootConfig.accountId, skipProjectSettings: true, complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - }))); + }); // Initialize the deploy-helpers context. const deployContext = createDeployContext(authToken); diff --git a/packages/cli/src/commands/previews/deploy.ts b/packages/cli/src/commands/previews/deploy.ts index 65dde66994..32c9f14d05 100644 --- a/packages/cli/src/commands/previews/deploy.ts +++ b/packages/cli/src/commands/previews/deploy.ts @@ -109,13 +109,12 @@ async function deployPreviewBuildOutput( ); const authToken = await getAuthToken(); - const accountId = - output.rootConfig.accountId ?? - (await getAccountId({ - isPreview: true, - skipProjectSettings: true, - complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - })); + const accountId = await getAccountId({ + accountId: output.rootConfig.accountId, + isPreview: true, + skipProjectSettings: true, + complianceRegion: getCloudflareComplianceRegion(wranglerConfig), + }); const deployContext = createDeployContext(authToken); // TODO(cloudflare/cf#330): Let previewBuildOutput() return without printing. // Until then, hide its summary so this command prints one clean JSON result. diff --git a/packages/cli/src/commands/workers/triggers/deploy.ts b/packages/cli/src/commands/workers/triggers/deploy.ts index 79bbba0f0f..0adee4eba4 100644 --- a/packages/cli/src/commands/workers/triggers/deploy.ts +++ b/packages/cli/src/commands/workers/triggers/deploy.ts @@ -94,11 +94,11 @@ async function deployTriggers(argv: TriggersDeployArgs): Promise { const authToken = argv["dry-run"] ? "" : await getAuthToken(); const accountId = argv["dry-run"] ? undefined - : (rootConfig.accountId ?? - (await getAccountId({ + : await getAccountId({ + accountId: rootConfig.accountId, skipProjectSettings: true, complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - }))); + }); initDeployHelpersContext(createDeployContext(authToken)); clack.log.message(theme.bold("Deploy triggers"), { diff --git a/packages/cli/src/lib/context.ts b/packages/cli/src/lib/context.ts index d4b85e29cc..6c163f44e9 100644 --- a/packages/cli/src/lib/context.ts +++ b/packages/cli/src/lib/context.ts @@ -3,12 +3,13 @@ * * Account ID priority: * 1. Temporary preview account when --temporary-account is requested - * 2. CLOUDFLARE_ACCOUNT_ID environment variable - * 3. account settings from cloudflare.config.ts's default export - * 4. workers-auth account cache - * 5. Interactive account picker / single-account auto-select + * 2. Build Output account ID passed by the caller + * 3. CLOUDFLARE_ACCOUNT_ID environment variable + * 4. account settings from cloudflare.config.ts's default export + * 5. workers-auth account cache + * 6. Interactive account picker / single-account auto-select * - * Configured sources 2–4 are offline (`resolveAccountIdSilent`); only + * Configured sources 3–5 are offline (`resolveAccountIdSilent`); only * `getAccountId` can provision, enumerate accounts, or ask. * * Zone priority: @@ -112,6 +113,8 @@ export async function resolveAccountIdSilent(): Promise { * @throws Error if no account ID is found */ export async function getAccountId(options?: { + /** An account ID from Build Output, used unless a temporary account applies. */ + accountId?: string; isPreview?: boolean; skipProjectSettings?: boolean; complianceRegion?: ComplianceRegion; @@ -126,6 +129,9 @@ export async function getAccountId(options?: { if (isTemporaryAccountAllowed()) { return (await requireTemporaryAccount(options)).accountId; } + if (options?.accountId !== undefined) { + return options.accountId; + } const result = await resolveAccountId(options); diff --git a/packages/wrangler-tests/src/__tests__/deploy/core.test.ts b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts index 6f305084da..a64f36e302 100644 --- a/packages/wrangler-tests/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts @@ -365,10 +365,12 @@ describe("deploy", () => { }); }); +// The configured account proves that a temporary account takes precedence. async function seedBuildOutput(): Promise { await seed({ ".cloudflare/output/v0/config.json": JSON.stringify({ buildContext: { isPreview: false }, + accountId: "configured-account-id", }), ".cloudflare/output/v0/workers/default/worker.config.json": JSON.stringify({ name: "test-name", diff --git a/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts index 2d35a25bd6..a0e0cb74c9 100644 --- a/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts @@ -28,7 +28,10 @@ describe("versions upload", () => { expect, }) => { setIsTTY(false); - await seedBuildOutput({ name: "test-name" }); + await seedBuildOutput({ + name: "test-name", + accountId: "configured-account-id", + }); const creationRequests = mockTemporaryPreviewAccount(); const accountIds: string[] = []; msw.use( @@ -219,7 +222,13 @@ describe("versions upload", () => { ); }); -async function seedBuildOutput({ name }: { name: string | undefined }) { +async function seedBuildOutput({ + name, + accountId, +}: { + name: string | undefined; + accountId?: string; +}) { const config = name === undefined ? { @@ -243,6 +252,7 @@ async function seedBuildOutput({ name }: { name: string | undefined }) { await seed({ ".cloudflare/output/v0/config.json": JSON.stringify({ buildContext: { isPreview: false }, + accountId, }), ".cloudflare/output/v0/workers/default/worker.config.json": JSON.stringify(config), diff --git a/packages/wrangler-tests/vite.config.ts b/packages/wrangler-tests/vite.config.ts index 1a473c2dd2..6f186c65b9 100644 --- a/packages/wrangler-tests/vite.config.ts +++ b/packages/wrangler-tests/vite.config.ts @@ -8,7 +8,9 @@ const compatibilityTests = { test: { name: "compatibility", clearMocks: false, - env: { TZ: "UTC" }, + // The setup file mocks cf's `ci-info`, but workers-auth's prompts check a + // copy bundled into workers-utils. `CI=false` disables that copy too. + env: { TZ: "UTC", CI: "false" }, testTimeout: 15_000, pool: "forks" as const, isolate: false, From a720feefb84e3b5660e2a99c0d7f448542dafcb1 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 13 Oct 2026 22:54:48 +0100 Subject: [PATCH 05/15] Keep Build Output accounts and regions for temporary accounts --- .../commands/previews-deploy.test.ts | 7 ++- .../src/__tests__/lib/build-output.test.ts | 23 +++++++++ .../cli/src/__tests__/lib/context.test.ts | 12 ----- .../src/__tests__/temporary-accounts.test.ts | 47 ++++++++++++++++++- packages/cli/src/commands/auth/whoami.ts | 5 +- packages/cli/src/commands/deploy/shared.ts | 18 +++++-- packages/cli/src/commands/previews/deploy.ts | 21 ++++++--- .../src/commands/workers/triggers/deploy.ts | 18 +++++-- packages/cli/src/lib/auth-token.ts | 6 ++- packages/cli/src/lib/build-output.ts | 12 +++++ packages/cli/src/lib/context.ts | 16 ++----- .../src/__tests__/deploy/core.test.ts | 22 +++++++-- .../versions/versions.upload.test.ts | 14 +----- 13 files changed, 158 insertions(+), 63 deletions(-) diff --git a/packages/cli/src/__tests__/commands/previews-deploy.test.ts b/packages/cli/src/__tests__/commands/previews-deploy.test.ts index f69a741c9e..7f4a4bddea 100644 --- a/packages/cli/src/__tests__/commands/previews-deploy.test.ts +++ b/packages/cli/src/__tests__/commands/previews-deploy.test.ts @@ -74,6 +74,7 @@ vi.mock("../../lib/build-output.js", async (importOriginal) => { buildOutputWorkerOption: actual.buildOutputWorkerOption, parseWorkerConfig: mocks.parseWorkerConfig, selectBuildOutputWorker: actual.selectBuildOutputWorker, + validateBuildOutputAccount: actual.validateBuildOutputAccount, validateBuildOutputMode: actual.validateBuildOutputMode, }; }); @@ -205,7 +206,7 @@ describe("cf previews deploy", () => { }); mocks.assembleBuildResult.mockReturnValue({ content: "worker code" }); mocks.getAuthToken.mockResolvedValue("token"); - mocks.getAccountId.mockResolvedValue("built-preview-account"); + mocks.getAccountId.mockResolvedValue("account-id"); mocks.createDeployContext.mockReturnValue({ logger: { log: vi.fn(), info: vi.fn() }, }); @@ -219,9 +220,7 @@ describe("cf previews deploy", () => { ).resolves.toBe(previewResult); expect(mocks.runBuild).not.toHaveBeenCalled(); - expect(mocks.getAccountId).toHaveBeenCalledWith( - expect.objectContaining({ accountId: "built-preview-account" }) - ); + expect(mocks.getAccountId).not.toHaveBeenCalled(); expect(mocks.previewBuildOutput).toHaveBeenCalledWith( "built-preview-account", { name: "feature", json: true }, diff --git a/packages/cli/src/__tests__/lib/build-output.test.ts b/packages/cli/src/__tests__/lib/build-output.test.ts index 5766b63e86..78cd277263 100644 --- a/packages/cli/src/__tests__/lib/build-output.test.ts +++ b/packages/cli/src/__tests__/lib/build-output.test.ts @@ -9,6 +9,7 @@ import { BuildOutputConfigError, parseWorkerConfig, selectBuildOutputWorker, + validateBuildOutputAccount, validateBuildOutputMode, } from "../../lib/build-output.js"; @@ -183,6 +184,28 @@ describe("build output", () => { }); }); + describe("account validation", () => { + it.each([ + { accountId: undefined, temporaryAccount: true }, + { accountId: "account-id", temporaryAccount: false }, + ])( + "accepts account $accountId when temporary is $temporaryAccount", + ({ accountId, temporaryAccount }) => { + expect(() => + validateBuildOutputAccount(accountId, temporaryAccount) + ).not.toThrow(); + } + ); + + it("rejects a Build Output account with a temporary account", () => { + expect(() => validateBuildOutputAccount("account-id", true)).toThrowError( + new BuildOutputConfigError( + 'The Build Output targets account "account-id", so `--temporary-account` can\'t be used. Remove `--temporary-account` to deploy to that account, or remove its account ID and rebuild.' + ) + ); + }); + }); + describe("worker selection", () => { async function seedWorkers(workers: Record) { await seed({ diff --git a/packages/cli/src/__tests__/lib/context.test.ts b/packages/cli/src/__tests__/lib/context.test.ts index 82890a82ee..0e6e35e237 100644 --- a/packages/cli/src/__tests__/lib/context.test.ts +++ b/packages/cli/src/__tests__/lib/context.test.ts @@ -77,15 +77,6 @@ describe("context", () => { }); }); - it("prefers a Build Output account ID without selecting one", async () => { - vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "env-acct"); - - await expect( - getAccountId({ accountId: "build-output-acct" }) - ).resolves.toBe("build-output-acct"); - expect(getOrSelectAccountIdMock).not.toHaveBeenCalled(); - }); - it("uses a temporary account instead of configured accounts", async () => { writeProjectSettings({ accountId: "settings-acct" }); setTemporaryAccountAllowed(true); @@ -93,9 +84,6 @@ describe("context", () => { await expect(resolveAccountIdSilent()).resolves.toBeUndefined(); await expect(getAccountId()).resolves.toBe("temporary-account"); - await expect( - getAccountId({ accountId: "build-output-acct" }) - ).resolves.toBe("temporary-account"); expect(requireAuthMock).toHaveBeenCalledWith({ compliance_region: "public", }); diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index 920c4d4bcd..1442527dbc 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -7,7 +7,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { getCfConfigPath } from "@cloudflare/workers-auth/cf"; -import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { getAccountId, getAuthToken } from "../lib/auth.js"; @@ -382,6 +382,51 @@ describe("--temporary-account", () => { expect(new Set(authorizations)).toEqual(new Set([`Bearer ${apiToken}`])); }); + it("rejects temporary-account failures from whoami", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + log.mockClear(); + + await expect( + runCf(["auth", "whoami", "--temporary-account"], { + ...env, + CLOUDFLARE_API_TOKEN: "existing-token", + }) + ).rejects.toThrow( + /You're already authenticated with Cloudflare, so `--temporary-account` can't be used\./ + ); + expect(log).not.toHaveBeenCalled(); + }); + + it("uses the Build Output compliance region before provisioning", async () => { + captureStderr(); + const creationRequests = mockProvisioning(); + await seed({ + ".cloudflare/output/v0/config.json": JSON.stringify({ + buildContext: { isPreview: false }, + complianceRegion: "fedramp-high", + }), + ".cloudflare/output/v0/workers/default/worker.config.json": + JSON.stringify({ + name: "test-name", + compatibilityDate: "2025-01-01", + manifest: { + type: "complete", + mainModule: "index.js", + modules: { "index.js": { type: "esm" } }, + }, + }), + ".cloudflare/output/v0/workers/default/bundle/index.js": + "export default {};", + }); + + await expect( + runCf(["deploy", "--prebuilt", "--temporary-account"], env) + ).rejects.toThrow( + "Temporary accounts aren't available when the compliance region is not set to public." + ); + expect(creationRequests).toHaveLength(0); + }); + it("rejects the Wrangler flag spelling", async () => { await expect( runCf(["kv", "namespaces", "list", "--temporary"], env) diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 4b4f7defd3..01aa31a6da 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -39,7 +39,10 @@ const whoamiCommand: CommandModule = { let token: string; try { token = await getAuthToken(); - } catch { + } catch (error) { + if (isTemporaryAccountAllowed()) { + throw error; + } formatOutput({ authenticated: false, error: "Not logged in" }); if (authSource === "none") { console.error( diff --git a/packages/cli/src/commands/deploy/shared.ts b/packages/cli/src/commands/deploy/shared.ts index 5652afbd88..4185733721 100644 --- a/packages/cli/src/commands/deploy/shared.ts +++ b/packages/cli/src/commands/deploy/shared.ts @@ -20,6 +20,7 @@ import { buildOutputWorkerOption, parseWorkerConfig, selectBuildOutputWorker, + validateBuildOutputAccount, validateBuildOutputMode, } from "../../lib/build-output.js"; import { getAccountId } from "../../lib/context.js"; @@ -130,6 +131,10 @@ async function uploadBuildOutput(argv: UploadArgs, ctx: UploadCommand) { ); } validateBuildOutputMode(argv.mode, rootConfig.buildContext.mode); + validateBuildOutputAccount( + rootConfig.accountId, + argv["temporary-account"] === true + ); const worker = selectBuildOutputWorker(workers, argv.worker); const parsedWorker = parseWorkerConfig(worker, rootConfig); const wranglerConfig = applyDependenciesInstrumentationEnv( @@ -140,14 +145,17 @@ async function uploadBuildOutput(argv: UploadArgs, ctx: UploadCommand) { // Resolve auth + account. Dry runs make no API requests: deploy-helpers // skips its account-scoped checks (such as the latest-deployment lookup) // only when no account is supplied, so leave it unresolved. - const authToken = argv["dry-run"] ? "" : await getAuthToken(); + const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); + const authToken = argv["dry-run"] + ? "" + : await getAuthToken({ complianceRegion }); const accountId = argv["dry-run"] ? undefined - : await getAccountId({ - accountId: rootConfig.accountId, + : (rootConfig.accountId ?? + (await getAccountId({ skipProjectSettings: true, - complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - }); + complianceRegion, + }))); // Initialize the deploy-helpers context. const deployContext = createDeployContext(authToken); diff --git a/packages/cli/src/commands/previews/deploy.ts b/packages/cli/src/commands/previews/deploy.ts index 32c9f14d05..98dfac65d4 100644 --- a/packages/cli/src/commands/previews/deploy.ts +++ b/packages/cli/src/commands/previews/deploy.ts @@ -21,6 +21,7 @@ import { buildOutputWorkerOption, parseWorkerConfig, selectBuildOutputWorker, + validateBuildOutputAccount, validateBuildOutputMode, } from "../../lib/build-output.js"; import { getAccountId } from "../../lib/context.js"; @@ -102,19 +103,25 @@ async function deployPreviewBuildOutput( const output = await readBuildOutput(process.cwd()); assertPreviewBuildOutputRootConfig(output.rootConfig); validateBuildOutputMode(argv.mode, output.rootConfig.buildContext.mode); + validateBuildOutputAccount( + output.rootConfig.accountId, + argv["temporary-account"] === true + ); const worker = selectBuildOutputWorker(output.workers, argv.worker); const { wranglerConfig, builtConfig } = parseWorkerConfig( worker, output.rootConfig ); - const authToken = await getAuthToken(); - const accountId = await getAccountId({ - accountId: output.rootConfig.accountId, - isPreview: true, - skipProjectSettings: true, - complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - }); + const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); + const authToken = await getAuthToken({ isPreview: true, complianceRegion }); + const accountId = + output.rootConfig.accountId ?? + (await getAccountId({ + isPreview: true, + skipProjectSettings: true, + complianceRegion, + })); const deployContext = createDeployContext(authToken); // TODO(cloudflare/cf#330): Let previewBuildOutput() return without printing. // Until then, hide its summary so this command prints one clean JSON result. diff --git a/packages/cli/src/commands/workers/triggers/deploy.ts b/packages/cli/src/commands/workers/triggers/deploy.ts index 0adee4eba4..4e3cdec81c 100644 --- a/packages/cli/src/commands/workers/triggers/deploy.ts +++ b/packages/cli/src/commands/workers/triggers/deploy.ts @@ -10,6 +10,7 @@ import { buildOutputWorkerOption, parseWorkerConfig, selectBuildOutputWorker, + validateBuildOutputAccount, validateBuildOutputMode, } from "../../../lib/build-output.js"; import { getAccountId } from "../../../lib/context.js"; @@ -87,18 +88,25 @@ async function deployTriggers(argv: TriggersDeployArgs): Promise { ); } validateBuildOutputMode(argv.mode, rootConfig.buildContext.mode); + validateBuildOutputAccount( + rootConfig.accountId, + argv["temporary-account"] === true + ); const worker = selectBuildOutputWorker(workers, argv.worker); const { wranglerConfig } = parseWorkerConfig(worker, rootConfig); // Dry runs make no API requests, so they never need credentials. - const authToken = argv["dry-run"] ? "" : await getAuthToken(); + const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); + const authToken = argv["dry-run"] + ? "" + : await getAuthToken({ complianceRegion }); const accountId = argv["dry-run"] ? undefined - : await getAccountId({ - accountId: rootConfig.accountId, + : (rootConfig.accountId ?? + (await getAccountId({ skipProjectSettings: true, - complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - }); + complianceRegion, + }))); initDeployHelpersContext(createDeployContext(authToken)); clack.log.message(theme.bold("Deploy triggers"), { diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index dfd481a019..a298bab8dc 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -18,9 +18,11 @@ import { * * @throws Error if no authentication token is found. */ -export async function getAuthToken(): Promise { +export async function getAuthToken( + options?: Parameters[0] +): Promise { if (isTemporaryAccountAllowed()) { - return (await requireTemporaryAccount()).apiToken; + return (await requireTemporaryAccount(options)).apiToken; } // Environment credential (scoped API token only). diff --git a/packages/cli/src/lib/build-output.ts b/packages/cli/src/lib/build-output.ts index 2aed7a0d66..d0c142599e 100644 --- a/packages/cli/src/lib/build-output.ts +++ b/packages/cli/src/lib/build-output.ts @@ -41,6 +41,18 @@ export function validateBuildOutputMode( } } +/** A Build Output account is the deployment target, so it can't be replaced. */ +export function validateBuildOutputAccount( + accountId: string | undefined, + temporaryAccount: boolean +): void { + if (accountId !== undefined && temporaryAccount) { + throw new BuildOutputConfigError( + `The Build Output targets account "${accountId}", so \`--temporary-account\` can't be used. Remove \`--temporary-account\` to deploy to that account, or remove its account ID and rebuild.` + ); + } +} + /** Selects a Build Output Worker for every workflow that consumes one. */ export const buildOutputWorkerOption = { type: "string", diff --git a/packages/cli/src/lib/context.ts b/packages/cli/src/lib/context.ts index 6c163f44e9..d4b85e29cc 100644 --- a/packages/cli/src/lib/context.ts +++ b/packages/cli/src/lib/context.ts @@ -3,13 +3,12 @@ * * Account ID priority: * 1. Temporary preview account when --temporary-account is requested - * 2. Build Output account ID passed by the caller - * 3. CLOUDFLARE_ACCOUNT_ID environment variable - * 4. account settings from cloudflare.config.ts's default export - * 5. workers-auth account cache - * 6. Interactive account picker / single-account auto-select + * 2. CLOUDFLARE_ACCOUNT_ID environment variable + * 3. account settings from cloudflare.config.ts's default export + * 4. workers-auth account cache + * 5. Interactive account picker / single-account auto-select * - * Configured sources 3–5 are offline (`resolveAccountIdSilent`); only + * Configured sources 2–4 are offline (`resolveAccountIdSilent`); only * `getAccountId` can provision, enumerate accounts, or ask. * * Zone priority: @@ -113,8 +112,6 @@ export async function resolveAccountIdSilent(): Promise { * @throws Error if no account ID is found */ export async function getAccountId(options?: { - /** An account ID from Build Output, used unless a temporary account applies. */ - accountId?: string; isPreview?: boolean; skipProjectSettings?: boolean; complianceRegion?: ComplianceRegion; @@ -129,9 +126,6 @@ export async function getAccountId(options?: { if (isTemporaryAccountAllowed()) { return (await requireTemporaryAccount(options)).accountId; } - if (options?.accountId !== undefined) { - return options.accountId; - } const result = await resolveAccountId(options); diff --git a/packages/wrangler-tests/src/__tests__/deploy/core.test.ts b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts index a64f36e302..7ae1c404a7 100644 --- a/packages/wrangler-tests/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts @@ -147,6 +147,23 @@ describe("deploy", () => { expect(std.err).not.toContain("Temporary account ready:"); }); + // cf-specific: a Build Output account is the deploy target. + it("rejects a Build Output account with a temporary account", async ({ + expect, + }) => { + setIsTTY(false); + await seedBuildOutput("configured-account-id"); + const creationRequests = mockTemporaryPreviewAccount(); + + await expect( + runWrangler("deploy --prebuilt --temporary-account") + ).rejects.toThrow( + 'The Build Output targets account "configured-account-id", so `--temporary-account` can\'t be used.' + ); + + expect(creationRequests).toHaveLength(0); + }); + describe("with temporary preview accounts", () => { it("creates a temporary preview account in non-interactive mode after printing terms notice", async ({ expect, @@ -365,12 +382,11 @@ describe("deploy", () => { }); }); -// The configured account proves that a temporary account takes precedence. -async function seedBuildOutput(): Promise { +async function seedBuildOutput(accountId?: string): Promise { await seed({ ".cloudflare/output/v0/config.json": JSON.stringify({ buildContext: { isPreview: false }, - accountId: "configured-account-id", + accountId, }), ".cloudflare/output/v0/workers/default/worker.config.json": JSON.stringify({ name: "test-name", diff --git a/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts b/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts index a0e0cb74c9..2d35a25bd6 100644 --- a/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts +++ b/packages/wrangler-tests/src/__tests__/versions/versions.upload.test.ts @@ -28,10 +28,7 @@ describe("versions upload", () => { expect, }) => { setIsTTY(false); - await seedBuildOutput({ - name: "test-name", - accountId: "configured-account-id", - }); + await seedBuildOutput({ name: "test-name" }); const creationRequests = mockTemporaryPreviewAccount(); const accountIds: string[] = []; msw.use( @@ -222,13 +219,7 @@ describe("versions upload", () => { ); }); -async function seedBuildOutput({ - name, - accountId, -}: { - name: string | undefined; - accountId?: string; -}) { +async function seedBuildOutput({ name }: { name: string | undefined }) { const config = name === undefined ? { @@ -252,7 +243,6 @@ async function seedBuildOutput({ await seed({ ".cloudflare/output/v0/config.json": JSON.stringify({ buildContext: { isPreview: false }, - accountId, }), ".cloudflare/output/v0/workers/default/worker.config.json": JSON.stringify(config), From 2e9b1017e35ff00604b4f20a9a9e6bf5e6c3a7dc Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 15 Oct 2026 12:50:46 +0100 Subject: [PATCH 06/15] Address temporary-account review feedback --- .changeset/temporary-accounts.md | 4 + AGENTS.md | 2 +- packages/cli/AGENTS.md | 2 +- .../cli/src/__tests__/lib/auth-token.test.ts | 2 +- .../src/__tests__/lib/build-output.test.ts | 2 +- .../cli/src/__tests__/lib/context.test.ts | 6 +- .../src/__tests__/temporary-accounts.test.ts | 215 +++++++++--------- packages/cli/src/commands/auth/whoami.ts | 19 +- packages/cli/src/commands/deploy/shared.ts | 21 +- packages/cli/src/commands/previews/deploy.ts | 16 +- .../src/commands/workers/triggers/deploy.ts | 20 +- packages/cli/src/index.ts | 4 +- packages/cli/src/lib/auth-token.ts | 4 +- packages/cli/src/lib/auth.ts | 12 +- packages/cli/src/lib/build-output-auth.ts | 33 +++ packages/cli/src/lib/build-output.ts | 2 +- packages/cli/src/lib/context.ts | 8 +- packages/cli/src/lib/oauth/index.ts | 18 +- .../src/__tests__/deploy/core.test.ts | 6 +- .../src/__tests__/index.test.ts | 5 +- 20 files changed, 209 insertions(+), 192 deletions(-) create mode 100644 packages/cli/src/lib/build-output-auth.ts diff --git a/.changeset/temporary-accounts.md b/.changeset/temporary-accounts.md index aeebb349c6..06810e5f10 100644 --- a/.changeset/temporary-accounts.md +++ b/.changeset/temporary-accounts.md @@ -8,6 +8,10 @@ When no Cloudflare credentials are available, commands can now create or reuse a short-lived account. cf prints the account's claim URL and uses that account's ID and scoped API token for the command. +Help never creates a temporary account. `cf auth whoami` rejects +`--temporary-account` and `--event-code` so checking authentication cannot +create an account. + When a command fails because no credentials exist, cf suggests rerunning it with `--temporary-account`. diff --git a/AGENTS.md b/AGENTS.md index c30498a15b..0946a0c039 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -670,7 +670,7 @@ Live globals (`packages/cli/src/index.ts:buildCli`): | `--local` | — | Run against cf's global local Miniflare state | | `--persist-to` | — | Override state directory (default `~/.config/cloudflare/state`) | -There is no single global resolution order. Zone uses positional value → `--zone` → `CLOUDFLARE_ZONE_ID`; account uses `--temporary-account` → `CLOUDFLARE_ACCOUNT_ID` → project settings → cached/prompted workers-auth selection; compliance region uses its environment variable → project settings; and the token uses `--temporary-account` → `CLOUDFLARE_API_TOKEN` → stored OAuth credentials. `--temporary-account` is rejected when credentials already exist or with `--local`. `--event-code` requires `--temporary-account`, is sent only when a new account is created, and is never cached or printed. There are no `--account-id`, `--api-token`, or `--compliance-region` flags. +There is no single global resolution order. Zone uses positional value → `--zone` → `CLOUDFLARE_ZONE_ID`; account uses `--temporary-account` → `CLOUDFLARE_ACCOUNT_ID` → project settings → cached/prompted workers-auth selection; compliance region uses its environment variable → project settings; and the token uses `--temporary-account` → `CLOUDFLARE_API_TOKEN` → stored OAuth credentials. `--temporary-account` is rejected when credentials already exist or with `--local`. Help never provisions a temporary account, and `cf auth whoami` rejects both temporary-account flags. `--event-code` requires `--temporary-account`, is sent only when a new account is created, and is never cached or printed. There are no `--account-id`, `--api-token`, or `--compliance-region` flags. `--mode` is supplied through the CLI and is available as `ctx.mode` when function-form configuration exports are evaluated. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 363e325997..3b0e0d1c65 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -111,7 +111,7 @@ or alias is added. ## Global Flags -`--zone` (`-z`), `--profile`, `--mode` (`-m`), `--temporary-account` (hidden), `--event-code` (hidden), `--quiet` (`-q`), `--help` (`-h`), `--version` (`-v`), `--local`, and `--persist-to`. `--mode` is available as `ctx.mode` when function-form exports in `cloudflare.config.ts` are evaluated. `--temporary-account` creates or reuses a temporary account when no credentials exist, and missing-credential errors recommend it. `--event-code` requires `--temporary-account` and is sent only when a new account is created; it is never cached, printed, or recorded in telemetry. `--persist-to` is valid only with `--local`; local mode starts and disposes a Miniflare instance over cf's state without requiring a running dev server. Hand-written root commands reject `--local`, and generated endpoints without an explorer equivalent return a specific error. Every generated command gets `--dry-run` (mutating and read alike — reads show URL/method/path params without a body preview). Almost every body operation gets `--body`; explicit `--file` is limited to eligible binary, multipart, and other non-JSON upload shapes. Destructive commands get `--force` (`-f`) at runtime. API fields that would emit an exact `--mode`, `--temporary-account`, or `--event-code` flag are omitted to avoid shadowing global flags. Body fields can still be supplied through `--body`; query, path, and header fields are temporarily unavailable. +`--zone` (`-z`), `--profile`, `--mode` (`-m`), `--temporary-account` (hidden), `--event-code` (hidden), `--quiet` (`-q`), `--help` (`-h`), `--version` (`-v`), `--local`, and `--persist-to`. `--mode` is available as `ctx.mode` when function-form exports in `cloudflare.config.ts` are evaluated. `--temporary-account` creates or reuses a temporary account when no credentials exist, and missing-credential errors recommend it. Help never provisions a temporary account, and `cf auth whoami` rejects both temporary-account flags. `--event-code` requires `--temporary-account` and is sent only when a new account is created; it is never cached, printed, or recorded in telemetry. `--persist-to` is valid only with `--local`; local mode starts and disposes a Miniflare instance over cf's state without requiring a running dev server. Hand-written root commands reject `--local`, and generated endpoints without an explorer equivalent return a specific error. Every generated command gets `--dry-run` (mutating and read alike — reads show URL/method/path params without a body preview). Almost every body operation gets `--body`; explicit `--file` is limited to eligible binary, multipart, and other non-JSON upload shapes. Destructive commands get `--force` (`-f`) at runtime. API fields that would emit an exact `--mode`, `--temporary-account`, or `--event-code` flag are omitted to avoid shadowing global flags. Body fields can still be supplied through `--body`; query, path, and header fields are temporarily unavailable. Normal structured API results are pretty-printed JSON. Null mutation results leave stdout empty and may print a TTY-only success marker on stderr. Endpoints declared as raw bytes or raw text write their payload directly to stdout, and some raw-byte commands accept `--text` to decode as UTF-8. Callers needing newline-delimited JSON can pipe structured results through `jq -c '.[]'` or similar; there is no global output-format `--format` / `--ndjson` flag, though some leaves use those words for endpoint-specific API parameters. diff --git a/packages/cli/src/__tests__/lib/auth-token.test.ts b/packages/cli/src/__tests__/lib/auth-token.test.ts index 4700c38be9..402210e18b 100644 --- a/packages/cli/src/__tests__/lib/auth-token.test.ts +++ b/packages/cli/src/__tests__/lib/auth-token.test.ts @@ -5,7 +5,7 @@ import { CliExit } from "../../lib/cli-exit.js"; const { getOAuthToken } = vi.hoisted(() => ({ getOAuthToken: vi.fn() })); vi.mock("../../lib/oauth/index.js", () => ({ getValidToken: getOAuthToken, - isTemporaryAccountAllowed: () => false, + isTemporaryAccountRequired: () => false, })); beforeEach(() => { diff --git a/packages/cli/src/__tests__/lib/build-output.test.ts b/packages/cli/src/__tests__/lib/build-output.test.ts index 78cd277263..22f84db42c 100644 --- a/packages/cli/src/__tests__/lib/build-output.test.ts +++ b/packages/cli/src/__tests__/lib/build-output.test.ts @@ -200,7 +200,7 @@ describe("build output", () => { it("rejects a Build Output account with a temporary account", () => { expect(() => validateBuildOutputAccount("account-id", true)).toThrowError( new BuildOutputConfigError( - 'The Build Output targets account "account-id", so `--temporary-account` can\'t be used. Remove `--temporary-account` to deploy to that account, or remove its account ID and rebuild.' + 'Temporary accounts can\'t be used when a specific account ID ("account-id") is set in your config file. Remove `--temporary-account` to use that account, or remove `accountId` from your config file and rebuild to use a temporary account.' ) ); }); diff --git a/packages/cli/src/__tests__/lib/context.test.ts b/packages/cli/src/__tests__/lib/context.test.ts index 0e6e35e237..47aa4bc30c 100644 --- a/packages/cli/src/__tests__/lib/context.test.ts +++ b/packages/cli/src/__tests__/lib/context.test.ts @@ -9,7 +9,7 @@ import { getZoneId, resolveAccountIdSilent, } from "../../lib/context.js"; -import { setTemporaryAccountAllowed } from "../../lib/oauth/index.js"; +import { setTemporaryAccountRequired } from "../../lib/oauth/index.js"; import { clearLoadedProjectSettings } from "../../lib/project-settings.js"; import type { ParsedInputSettingsConfig } from "@cloudflare/config"; @@ -45,7 +45,7 @@ describe("context", () => { vi.restoreAllMocks(); getOrSelectAccountIdMock.mockReset(); requireAuthMock.mockReset(); - setTemporaryAccountAllowed(false); + setTemporaryAccountRequired(false); getActiveAccountIdMock.mockReset(); fetchAllAccountsMock.mockReset(); fetchAllAccountsMock.mockResolvedValue([]); @@ -79,7 +79,7 @@ describe("context", () => { it("uses a temporary account instead of configured accounts", async () => { writeProjectSettings({ accountId: "settings-acct" }); - setTemporaryAccountAllowed(true); + setTemporaryAccountRequired(true); requireAuthMock.mockResolvedValue("temporary-account"); await expect(resolveAccountIdSilent()).resolves.toBeUndefined(); diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index 1442527dbc..aa803f23d0 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -11,7 +11,7 @@ import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { getAccountId, getAuthToken } from "../lib/auth.js"; -import { setTemporaryAccountAllowed } from "../lib/oauth/index.js"; +import { setTemporaryAccountRequired } from "../lib/oauth/index.js"; import { server, setupMsw, TEST_BASE_URL } from "./helpers/msw.js"; import { runCf } from "./helpers/run-cf.js"; @@ -104,9 +104,21 @@ describe("--temporary-account", () => { function captureStderr() { const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + errorLog.mockClear(); return () => errorLog.mock.calls.flat().join("\n"); } + function captureProvisioningRequests() { + const requests: string[] = []; + server.use( + http.post(/\/provisioning\/previews(?:\/challenge)?$/, ({ request }) => { + requests.push(request.url); + return HttpResponse.error(); + }) + ); + return requests; + } + it("uses the temporary account ID and token for generated commands", async () => { const stderr = captureStderr(); let authorization: string | null = null; @@ -168,50 +180,47 @@ describe("--temporary-account", () => { captureStderr(); vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "configured-account-id"); vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); - setTemporaryAccountAllowed(true); + setTemporaryAccountRequired(true); try { await expect(getAuthToken()).resolves.toBe(apiToken); await expect(getAccountId()).resolves.toBe(accountId); } finally { - setTemporaryAccountAllowed(false); + setTemporaryAccountRequired(false); } }); - it("keeps runtime-schema help on the temporary account", async () => { - captureStderr(); - const model = "@cf/test/model"; - let authorization: string | null = null; - server.use( - http.get( - `${TEST_BASE_URL}/accounts/${accountId}/ai/models/schema`, - ({ request }) => { - authorization = request.headers.get("authorization"); - expect(new URL(request.url).searchParams.get("model")).toBe(model); - return HttpResponse.json({ - success: true, - errors: [], - result: { - input: { - type: "object", - properties: { prompt: { type: "string" } }, - }, - output: { type: "object" }, - }, - }); + describe.each([ + { command: ["ai", "run", "@cf/test/model"] }, + { command: ["registrar", "registrations", "create", "example.com"] }, + ])("schema-backed help for $command", ({ command }) => { + it.each([true, false])( + "never activates or provisions an account (cached: %s)", + async (cached) => { + const stderr = captureStderr(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + log.mockClear(); + if (!cached) { + rmSync(cachePath()); + } + const requests = captureProvisioningRequests(); + + for (const eventFlags of [[], ["--event-code", "TEST-EVENT-CODE"]]) { + await expect( + runCf( + [...command, "--help", "--temporary-account", ...eventFlags], + { + ...env, + CLOUDFLARE_ACCOUNT_ID: "configured-account-id", + } + ) + ).resolves.toEqual({ exitCode: 0 }); } - ) - ); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - - const result = await runCf( - ["ai", "run", model, "--help", "--temporary-account"], - { ...env, CLOUDFLARE_ACCOUNT_ID: "configured-account-id" } - ); - expect(result.exitCode).toBe(0); - expect(authorization).toBe(`Bearer ${apiToken}`); - expect(log.mock.calls.flat().join("\n")).toContain( - `Input flags for ${model}` + expect(requests).toEqual([]); + expect(stderr()).not.toContain("Temporary account ready:"); + expect(log.mock.calls.flat().join("\n")).toContain("cf auth login"); + expect(existsSync(cachePath())).toBe(cached); + } ); }); @@ -355,77 +364,71 @@ describe("--temporary-account", () => { expect(stderr()).toContain("cf deploy --temporary-account"); }); - it("reports the temporary account as whoami's auth source", async () => { - captureStderr(); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const authorizations: Array = []; - server.use( - http.get(`${TEST_BASE_URL}/user`, ({ request }) => { - authorizations.push(request.headers.get("authorization")); - return HttpResponse.json({ success: true, result: {} }); - }), - http.get(`${TEST_BASE_URL}/accounts`, ({ request }) => { - authorizations.push(request.headers.get("authorization")); - return HttpResponse.json({ - success: true, - result: [{ id: accountId, name: "Temporary Account" }], - }); - }) - ); - - await runCf(["auth", "whoami", "--temporary-account"], env); - - expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toMatchObject({ - authenticated: true, - authSource: "temporary account", - }); - expect(new Set(authorizations)).toEqual(new Set([`Bearer ${apiToken}`])); - }); - - it("rejects temporary-account failures from whoami", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - log.mockClear(); - - await expect( - runCf(["auth", "whoami", "--temporary-account"], { - ...env, - CLOUDFLARE_API_TOKEN: "existing-token", - }) - ).rejects.toThrow( - /You're already authenticated with Cloudflare, so `--temporary-account` can't be used\./ - ); - expect(log).not.toHaveBeenCalled(); - }); - - it("uses the Build Output compliance region before provisioning", async () => { - captureStderr(); - const creationRequests = mockProvisioning(); - await seed({ - ".cloudflare/output/v0/config.json": JSON.stringify({ - buildContext: { isPreview: false }, - complianceRegion: "fedramp-high", - }), - ".cloudflare/output/v0/workers/default/worker.config.json": - JSON.stringify({ - name: "test-name", - compatibilityDate: "2025-01-01", - manifest: { - type: "complete", - mainModule: "index.js", - modules: { "index.js": { type: "esm" } }, - }, + it.each([true, false])( + "rejects temporary-account flags from whoami without provisioning (cached: %s)", + async (cached) => { + captureStderr(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + log.mockClear(); + if (!cached) { + rmSync(cachePath()); + } + const requests = captureProvisioningRequests(); + + for (const eventFlags of [[], ["--event-code", "TEST-EVENT-CODE"]]) { + await expect( + runCf(["auth", "whoami", "--temporary-account", ...eventFlags], env) + ).rejects.toThrow( + "--temporary-account and --event-code are not supported by cf auth whoami." + ); + } + await expect( + runCf(["auth", "whoami", "--event-code", "TEST-EVENT-CODE"], env) + ).rejects.toThrow("--event-code requires --temporary-account."); + expect(requests).toEqual([]); + expect(log).not.toHaveBeenCalled(); + expect(existsSync(cachePath())).toBe(cached); + } + ); + + it.each([ + { command: ["deploy"], isPreview: false }, + { command: ["workers", "versions", "create"], isPreview: false }, + { command: ["workers", "triggers", "deploy"], isPreview: false }, + { command: ["previews", "deploy", "test-preview"], isPreview: true }, + ])( + "uses the build's compliance region before provisioning for $command", + async ({ command, isPreview }) => { + captureStderr(); + rmSync(cachePath()); + const requests = captureProvisioningRequests(); + await seed({ + ".cloudflare/output/v0/config.json": JSON.stringify({ + buildContext: { isPreview }, + complianceRegion: "fedramp-high", }), - ".cloudflare/output/v0/workers/default/bundle/index.js": - "export default {};", - }); - - await expect( - runCf(["deploy", "--prebuilt", "--temporary-account"], env) - ).rejects.toThrow( - "Temporary accounts aren't available when the compliance region is not set to public." - ); - expect(creationRequests).toHaveLength(0); - }); + ".cloudflare/output/v0/workers/default/worker.config.json": + JSON.stringify({ + name: "test-name", + compatibilityDate: "2025-01-01", + manifest: { + type: "complete", + mainModule: "index.js", + modules: { "index.js": { type: "esm" } }, + }, + }), + ".cloudflare/output/v0/workers/default/bundle/index.js": + "export default {};", + }); + + await expect( + runCf([...command, "--prebuilt", "--temporary-account"], env) + ).rejects.toThrow( + "Temporary accounts aren't available when the compliance region is not set to public." + ); + expect(requests).toEqual([]); + } + ); it("rejects the Wrangler flag spelling", async () => { await expect( diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 01aa31a6da..4fb78e18c7 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -2,14 +2,14 @@ import { getAuthToken } from "../../lib/auth-token.js"; import { fetchAuthorizedAccounts, getConfigPath, - isTemporaryAccountAllowed, readAuthCredentials, } from "../../lib/oauth/index.js"; import { formatOutput } from "../../lib/output.js"; import { hint } from "../../lib/ui/index.js"; +import type { CommonYargsOptions } from "../../lib/cli-types.js"; import type { ArgumentsCamelCase, Argv, CommandModule } from "yargs"; -interface WhoamiArgs {} +type WhoamiArgs = Pick; const whoamiCommand: CommandModule = { command: "whoami", @@ -19,7 +19,13 @@ const whoamiCommand: CommandModule = { return yargs as Argv; }, - handler: async (_argv: ArgumentsCamelCase): Promise => { + handler: async (argv: ArgumentsCamelCase): Promise => { + if (argv["temporary-account"] || argv["event-code"] !== undefined) { + throw new Error( + "--temporary-account and --event-code are not supported by cf auth whoami." + ); + } + // Determine the authentication source. const envToken = process.env.CLOUDFLARE_API_TOKEN; const stored = readAuthCredentials(); @@ -29,8 +35,6 @@ const whoamiCommand: CommandModule = { authSource = "CLOUDFLARE_API_TOKEN environment variable"; } else if (stored?.oauth_token) { authSource = `OAuth token from ${getConfigPath()}`; - } else if (isTemporaryAccountAllowed()) { - authSource = "temporary account"; } else { authSource = "none"; } @@ -39,10 +43,7 @@ const whoamiCommand: CommandModule = { let token: string; try { token = await getAuthToken(); - } catch (error) { - if (isTemporaryAccountAllowed()) { - throw error; - } + } catch { formatOutput({ authenticated: false, error: "Not logged in" }); if (authSource === "none") { console.error( diff --git a/packages/cli/src/commands/deploy/shared.ts b/packages/cli/src/commands/deploy/shared.ts index 4185733721..82b679bf95 100644 --- a/packages/cli/src/commands/deploy/shared.ts +++ b/packages/cli/src/commands/deploy/shared.ts @@ -15,7 +15,7 @@ import { getCloudflareComplianceRegion, getDockerPath, } from "@cloudflare/workers-utils"; -import { getAuthToken } from "../../lib/auth-token.js"; +import { resolveBuildOutputAuth } from "../../lib/build-output-auth.js"; import { buildOutputWorkerOption, parseWorkerConfig, @@ -23,7 +23,6 @@ import { validateBuildOutputAccount, validateBuildOutputMode, } from "../../lib/build-output.js"; -import { getAccountId } from "../../lib/context.js"; import { applyDependenciesInstrumentationEnv } from "../../lib/dependencies-instrumentation.js"; import { createDeployContext } from "../../lib/deploy-context.js"; import { @@ -142,20 +141,12 @@ async function uploadBuildOutput(argv: UploadArgs, ctx: UploadCommand) { ); const buildResult = assembleBuildResult(worker, parsedWorker.builtConfig); - // Resolve auth + account. Dry runs make no API requests: deploy-helpers - // skips its account-scoped checks (such as the latest-deployment lookup) - // only when no account is supplied, so leave it unresolved. const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); - const authToken = argv["dry-run"] - ? "" - : await getAuthToken({ complianceRegion }); - const accountId = argv["dry-run"] - ? undefined - : (rootConfig.accountId ?? - (await getAccountId({ - skipProjectSettings: true, - complianceRegion, - }))); + const { authToken, accountId } = await resolveBuildOutputAuth({ + accountId: rootConfig.accountId, + complianceRegion, + dryRun: argv["dry-run"], + }); // Initialize the deploy-helpers context. const deployContext = createDeployContext(authToken); diff --git a/packages/cli/src/commands/previews/deploy.ts b/packages/cli/src/commands/previews/deploy.ts index 98dfac65d4..e641d95cbd 100644 --- a/packages/cli/src/commands/previews/deploy.ts +++ b/packages/cli/src/commands/previews/deploy.ts @@ -15,7 +15,7 @@ import { getCloudflareComplianceRegion, writeOutput, } from "@cloudflare/workers-utils"; -import { getAuthToken } from "../../lib/auth-token.js"; +import { resolveBuildOutputAuth } from "../../lib/build-output-auth.js"; import { BuildOutputConfigError } from "../../lib/build-output-error.js"; import { buildOutputWorkerOption, @@ -24,7 +24,6 @@ import { validateBuildOutputAccount, validateBuildOutputMode, } from "../../lib/build-output.js"; -import { getAccountId } from "../../lib/context.js"; import { createDeployContext } from "../../lib/deploy-context.js"; import { assembleBuildResult } from "../../lib/deploy-input.js"; import { withCloudflareDotEnv } from "../../lib/dotenv.js"; @@ -114,14 +113,11 @@ async function deployPreviewBuildOutput( ); const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); - const authToken = await getAuthToken({ isPreview: true, complianceRegion }); - const accountId = - output.rootConfig.accountId ?? - (await getAccountId({ - isPreview: true, - skipProjectSettings: true, - complianceRegion, - })); + const { authToken, accountId } = await resolveBuildOutputAuth({ + accountId: output.rootConfig.accountId, + complianceRegion, + isPreview: true, + }); const deployContext = createDeployContext(authToken); // TODO(cloudflare/cf#330): Let previewBuildOutput() return without printing. // Until then, hide its summary so this command prints one clean JSON result. diff --git a/packages/cli/src/commands/workers/triggers/deploy.ts b/packages/cli/src/commands/workers/triggers/deploy.ts index 4e3cdec81c..149c8117ec 100644 --- a/packages/cli/src/commands/workers/triggers/deploy.ts +++ b/packages/cli/src/commands/workers/triggers/deploy.ts @@ -5,7 +5,7 @@ import { triggersDeploy, } from "@cloudflare/deploy-helpers"; import { getCloudflareComplianceRegion } from "@cloudflare/workers-utils"; -import { getAuthToken } from "../../../lib/auth-token.js"; +import { resolveBuildOutputAuth } from "../../../lib/build-output-auth.js"; import { buildOutputWorkerOption, parseWorkerConfig, @@ -13,7 +13,6 @@ import { validateBuildOutputAccount, validateBuildOutputMode, } from "../../../lib/build-output.js"; -import { getAccountId } from "../../../lib/context.js"; import { createDeployContext } from "../../../lib/deploy-context.js"; import { createTriggerProps } from "../../../lib/deploy-input.js"; import { withCloudflareDotEnv } from "../../../lib/dotenv.js"; @@ -95,18 +94,13 @@ async function deployTriggers(argv: TriggersDeployArgs): Promise { const worker = selectBuildOutputWorker(workers, argv.worker); const { wranglerConfig } = parseWorkerConfig(worker, rootConfig); - // Dry runs make no API requests, so they never need credentials. const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); - const authToken = argv["dry-run"] - ? "" - : await getAuthToken({ complianceRegion }); - const accountId = argv["dry-run"] - ? undefined - : (rootConfig.accountId ?? - (await getAccountId({ - skipProjectSettings: true, - complianceRegion, - }))); + const { authToken, accountId } = await resolveBuildOutputAuth({ + accountId: rootConfig.accountId, + complianceRegion, + dryRun: argv["dry-run"], + }); + initDeployHelpersContext(createDeployContext(authToken)); clack.log.message(theme.bold("Deploy triggers"), { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cd1ef88b82..79f619b994 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -668,9 +668,9 @@ export function buildCli(rawArgs: string[], options: BuildCliOptions = {}) { // Local mode bypasses profile credentials, so resolving and displaying // an active profile would be misleading. if (!argv.local) { - const { resolveProfile, setTemporaryAccountAllowed } = + const { resolveProfile, setTemporaryAccountRequired } = await import("./lib/oauth/index.js"); - setTemporaryAccountAllowed( + setTemporaryAccountRequired( temporaryAccount, eventCode === undefined ? undefined : { eventCode } ); diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index a298bab8dc..c9e823653e 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -2,7 +2,7 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { MissingCredentialsError } from "./errors.js"; import { getValidToken as getOAuthToken, - isTemporaryAccountAllowed, + isTemporaryAccountRequired, requireTemporaryAccount, } from "./oauth/index.js"; @@ -21,7 +21,7 @@ import { export async function getAuthToken( options?: Parameters[0] ): Promise { - if (isTemporaryAccountAllowed()) { + if (isTemporaryAccountRequired()) { return (await requireTemporaryAccount(options)).apiToken; } diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 4ba2cd8f92..9eb1a6bdf1 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -3,10 +3,7 @@ import { API_TIMEOUT_MS } from "./api-constants.js"; import { isUploadBody } from "./api-upload.js"; import { getAuthToken } from "./auth-token.js"; import { getComplianceRegion, resolveAccountIdSilent } from "./context.js"; -import { - isTemporaryAccountAllowed, - requireTemporaryAccount, -} from "./oauth/index.js"; +import { isTemporaryAccountRequired } from "./oauth/index.js"; import { getDefaultHeaders } from "./request-headers.js"; import { ApiError, Client, type ClientOptions } from "#api"; import listAccounts from "#operations/accounts/list.js"; @@ -289,11 +286,8 @@ export function createCloudflareClientWithToken( export async function resolveAccountIdForHelp( deadline: number ): Promise { - if (isTemporaryAccountAllowed()) { - return requireTemporaryAccount().then( - ({ accountId }) => accountId, - () => undefined - ); + if (isTemporaryAccountRequired()) { + return undefined; } const stored = await resolveAccountIdSilent(); diff --git a/packages/cli/src/lib/build-output-auth.ts b/packages/cli/src/lib/build-output-auth.ts new file mode 100644 index 0000000000..28e185b799 --- /dev/null +++ b/packages/cli/src/lib/build-output-auth.ts @@ -0,0 +1,33 @@ +import { getAuthToken } from "./auth-token.js"; +import { getAccountId, type ComplianceRegion } from "./context.js"; + +interface BuildOutputAuthOptions { + accountId?: string; + complianceRegion: ComplianceRegion; + dryRun?: boolean; + isPreview?: boolean; +} + +/** Resolve deployment credentials using the account and region from the build. */ +export function resolveBuildOutputAuth( + options: BuildOutputAuthOptions & { dryRun?: false } +): Promise<{ authToken: string; accountId: string }>; +export function resolveBuildOutputAuth( + options: BuildOutputAuthOptions +): Promise<{ authToken: string; accountId: string | undefined }>; +export async function resolveBuildOutputAuth( + options: BuildOutputAuthOptions +): Promise<{ authToken: string; accountId: string | undefined }> { + const { accountId: configuredAccountId, dryRun, ...authOptions } = options; + // Dry runs make no API requests. Leaving the account unresolved also skips + // deploy-helpers' account-scoped checks, such as its deployment lookup. + if (dryRun) { + return { authToken: "", accountId: undefined }; + } + + const authToken = await getAuthToken(authOptions); + const accountId = + configuredAccountId ?? + (await getAccountId({ ...authOptions, skipProjectSettings: true })); + return { authToken, accountId }; +} diff --git a/packages/cli/src/lib/build-output.ts b/packages/cli/src/lib/build-output.ts index d0c142599e..f8bbb1c7d8 100644 --- a/packages/cli/src/lib/build-output.ts +++ b/packages/cli/src/lib/build-output.ts @@ -48,7 +48,7 @@ export function validateBuildOutputAccount( ): void { if (accountId !== undefined && temporaryAccount) { throw new BuildOutputConfigError( - `The Build Output targets account "${accountId}", so \`--temporary-account\` can't be used. Remove \`--temporary-account\` to deploy to that account, or remove its account ID and rebuild.` + `Temporary accounts can't be used when a specific account ID ("${accountId}") is set in your config file. Remove \`--temporary-account\` to use that account, or remove \`accountId\` from your config file and rebuild to use a temporary account.` ); } } diff --git a/packages/cli/src/lib/context.ts b/packages/cli/src/lib/context.ts index d4b85e29cc..d01664437f 100644 --- a/packages/cli/src/lib/context.ts +++ b/packages/cli/src/lib/context.ts @@ -85,11 +85,11 @@ async function resolveAccountId( */ export async function resolveAccountIdSilent(): Promise { // Lazily imported for the same dependency-cycle reason as getAccountId. - const { getActiveAccountId, isTemporaryAccountAllowed } = + const { getActiveAccountId, isTemporaryAccountRequired } = await import("./oauth/index.js"); // Configured accounts don't apply to a temporary account, and provisioning // one is not silent. - if (isTemporaryAccountAllowed()) { + if (isTemporaryAccountRequired()) { return undefined; } const result = await resolveAccountId(); @@ -120,10 +120,10 @@ export async function getAccountId(options?: { // module has initialised to avoid a static context ↔ OAuth dependency cycle. const { getOrSelectAccountId, - isTemporaryAccountAllowed, + isTemporaryAccountRequired, requireTemporaryAccount, } = await import("./oauth/index.js"); - if (isTemporaryAccountAllowed()) { + if (isTemporaryAccountRequired()) { return (await requireTemporaryAccount(options)).accountId; } diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index ce2c1023d1..517dd346f9 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -85,23 +85,23 @@ const auth = createCfAuth({ error instanceof NonInteractiveSelectionError, }); -let temporaryAccountAllowed = false; +let temporaryAccountRequired = false; /** - * Mark whether this invocation may provision a temporary preview account, + * Mark whether this invocation requires a temporary preview account, * optionally for an event. */ -export function setTemporaryAccountAllowed( - allowed: boolean, +export function setTemporaryAccountRequired( + required: boolean, request?: TemporaryAccountRequest ): void { - temporaryAccountAllowed = allowed; - auth.setTemporaryAllowed(allowed, request); + temporaryAccountRequired = required; + auth.setTemporaryAllowed(required, request); } -/** Whether this invocation requested a temporary preview account. */ -export function isTemporaryAccountAllowed(): boolean { - return temporaryAccountAllowed; +/** Whether authentication requires a temporary preview account. */ +export function isTemporaryAccountRequired(): boolean { + return temporaryAccountRequired; } /** diff --git a/packages/wrangler-tests/src/__tests__/deploy/core.test.ts b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts index 7ae1c404a7..998e355181 100644 --- a/packages/wrangler-tests/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts @@ -23,8 +23,8 @@ import { runWrangler } from "../helpers/run-wrangler"; // cf deploys Build Output rather than bundling an entrypoint, so these cases // seed `.cloudflare/output` and run `cf deploy --prebuilt`. cf spells // Wrangler's `--temporary` flag `--temporary-account`, accepts it (and -// `--event-code`) on every command, and writes temporary-account notices to -// stderr so stdout stays reserved for command output. +// `--event-code`) globally except on `auth whoami`, and writes temporary-account +// notices to stderr so stdout stays reserved for command output. describe("deploy", () => { runInTempDir(); const std = mockConsoleMethods(); @@ -158,7 +158,7 @@ describe("deploy", () => { await expect( runWrangler("deploy --prebuilt --temporary-account") ).rejects.toThrow( - 'The Build Output targets account "configured-account-id", so `--temporary-account` can\'t be used.' + 'Temporary accounts can\'t be used when a specific account ID ("configured-account-id") is set in your config file.' ); expect(creationRequests).toHaveLength(0); diff --git a/packages/wrangler-tests/src/__tests__/index.test.ts b/packages/wrangler-tests/src/__tests__/index.test.ts index 46cf658232..3b11f0af95 100644 --- a/packages/wrangler-tests/src/__tests__/index.test.ts +++ b/packages/wrangler-tests/src/__tests__/index.test.ts @@ -32,8 +32,9 @@ describe("wrangler", () => { }); describe("--temporary", () => { - // cf accepts --temporary-account on every command and lets the API - // report operations the temporary account's token cannot perform. + // Preserve this upstream test identity as an intentional divergence: + // cf registers --temporary-account globally rather than requiring each + // API command to opt in. `auth whoami` rejects the flag separately. it.skip("is rejected on commands that don't opt in"); }); From 7278f19e5d0d0cdc47fa122d5c6b649aad0bf037 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 19 Oct 2026 12:22:48 +0100 Subject: [PATCH 07/15] fix: address temporary-account review feedback --- .changeset/temporary-accounts.md | 7 ++ packages/cli/generator/plugin/args.ts | 4 +- .../generator/plugin/arg-derivation.test.ts | 31 ++++-- .../src/__tests__/temporary-accounts.test.ts | 101 ++++++++++++++++-- packages/cli/src/commands/auth/whoami.ts | 5 +- packages/cli/src/lib/auth.ts | 7 ++ 6 files changed, 134 insertions(+), 21 deletions(-) diff --git a/.changeset/temporary-accounts.md b/.changeset/temporary-accounts.md index 06810e5f10..b6f2e93c47 100644 --- a/.changeset/temporary-accounts.md +++ b/.changeset/temporary-accounts.md @@ -12,6 +12,13 @@ Help never creates a temporary account. `cf auth whoami` rejects `--temporary-account` and `--event-code` so checking authentication cannot create an account. +Dry runs do not provision temporary accounts, including when runtime input +schemas are needed. Use existing credentials for those schema requests. + +When an unauthenticated agent runs `cf auth whoami`, cf suggests asking the user +whether to deploy for 60 minutes and explains that claiming the account before +it expires is required to keep it. + When a command fails because no credentials exist, cf suggests rerunning it with `--temporary-account`. diff --git a/packages/cli/generator/plugin/args.ts b/packages/cli/generator/plugin/args.ts index dd4b8b0831..5b29a6c7fd 100644 --- a/packages/cli/generator/plugin/args.ts +++ b/packages/cli/generator/plugin/args.ts @@ -260,9 +260,7 @@ function flagName(name: string): string { function cliFlagName(name: string): string | undefined { const flag = flagName(name); - return ["mode", "temporary-account", "event-code"].includes( - flag.toLowerCase() - ) + return ["mode", "temporary-account", "event-code"].includes(kebab(flag)) ? undefined : flag; } diff --git a/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts b/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts index 213893e306..695f1c355e 100644 --- a/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts +++ b/packages/cli/src/__tests__/generator/plugin/arg-derivation.test.ts @@ -32,7 +32,7 @@ function queryOperation(name: string, sdkName?: string): OperationInfo { }); } -/** A multipart POST whose fields include `mode` in three cases. */ +/** A multipart POST with reserved global flag names and an ordinary field. */ function multipartOperation(): OperationInfo { const field = (name: string, description: string): MultipartField => ({ name, @@ -48,6 +48,8 @@ function multipartOperation(): OperationInfo { field("mode", "Mode"), field("Mode", "Mixed-case mode"), field("MODE", "Uppercase mode"), + field("temporaryAccount", "Temporary account"), + field("eventCode", "Event code"), field("label", "Label"), ], }, @@ -114,11 +116,14 @@ describe("deriveArgsFromOp query flags", () => { }); }); - it("omits a query param that would shadow --temporary-account", () => { - expect( - derived(search(), "example", queryOperation("temporary_account")).args - ).toEqual([]); - }); + it.each(["temporary_account", "temporaryAccount", "event_code", "eventCode"])( + "omits the reserved query field %s", + (name) => { + expect(derived(search(), "example", queryOperation(name)).args).toEqual( + [] + ); + } + ); it("classifies by the wire name", () => { const d = derived( @@ -345,6 +350,18 @@ describe("deriveArgsFromOp path params", () => { }); describe("deriveArgsFromOp body flags", () => { + it.each(["temporaryAccount", "eventCode"])( + "omits the reserved camelCase body field %s", + (name) => { + const op = operation("post", "/accounts/{account_id}/example", { + bodyParams: [bodyParam(name, "string", [name])], + hasRequestBody: true, + requestContentTypes: ["application/json"], + }); + expect(derived(search(), "example", op).args).toEqual([]); + } + ); + it("uses exact description overrides without inheriting parent object prose", () => { const op = operation("put", "/accounts/{account_id}/example", { bodyParams: [ @@ -429,7 +446,7 @@ describe("deriveArgsFromOp body flags", () => { }); describe("deriveArgsFromOp multipart flags", () => { - it("omits reserved mode fields case-insensitively", () => { + it("omits reserved global fields after normalizing their names", () => { const d = derived(search(), "example", multipartOperation()); expect(d.multipartFlagFields.map((f) => f.name)).toEqual(["label"]); }); diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index aa803f23d0..53c2cc9cfa 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -10,6 +10,7 @@ import { getCfConfigPath } from "@cloudflare/workers-auth/cf"; import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as agentContext from "../lib/agent-context.js"; import { getAccountId, getAuthToken } from "../lib/auth.js"; import { setTemporaryAccountRequired } from "../lib/oauth/index.js"; import { server, setupMsw, TEST_BASE_URL } from "./helpers/msw.js"; @@ -224,6 +225,59 @@ describe("--temporary-account", () => { ); }); + describe.each([ + { command: ["ai", "run", "@cf/test/model"] }, + { command: ["registrar", "registrations", "create", "example.com"] }, + ])("schema-backed dry runs for $command", ({ command }) => { + it.each([true, false])( + "never activates or provisions an account (cached: %s)", + async (cached) => { + const stderr = captureStderr(); + if (!cached) { + rmSync(cachePath()); + } + const requests = captureProvisioningRequests(); + + await expect( + runCf([...command, "--dry-run", "--temporary-account"], env) + ).rejects.toThrow( + "Temporary accounts cannot make API requests during --dry-run." + ); + + expect(requests).toEqual([]); + expect(stderr()).not.toContain("Temporary account ready:"); + expect(existsSync(cachePath())).toBe(cached); + } + ); + }); + + it("previews an AI request body without provisioning a temporary account", async () => { + captureStderr(); + rmSync(cachePath()); + const requests = captureProvisioningRequests(); + const stdout = vi.spyOn(console, "log").mockImplementation(() => {}); + stdout.mockClear(); + + await expect( + runCf( + [ + "ai", + "run", + "@cf/test/model", + "--dry-run", + "--temporary-account", + "--body", + '{"prompt":"hello"}', + ], + env + ) + ).resolves.toEqual({ exitCode: 0 }); + + expect(stdout.mock.calls.flat().join("\n")).toContain('"prompt": "hello"'); + expect(requests).toEqual([]); + expect(existsSync(cachePath())).toBe(false); + }); + it("rejects existing credentials using cf's flag name", async () => { captureStderr(); await expect( @@ -354,15 +408,44 @@ describe("--temporary-account", () => { expect(stderr()).toContain("Cleared temporary preview account."); }); - it("suggests a temporary account from whoami when unauthenticated", async () => { - const stderr = captureStderr(); - vi.spyOn(process.stdout, "write").mockImplementation(() => true); - vi.spyOn(console, "log").mockImplementation(() => {}); - - await runCf(["auth", "whoami"], env); - - expect(stderr()).toContain("cf deploy --temporary-account"); - }); + it.each([true, false])( + "suggests a temporary account from whoami only for agents (agent: %s)", + async (isAgentic) => { + const stderr = captureStderr(); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(console, "log").mockImplementation(() => {}); + const context = agentContext.detectAgentContext({}); + const detection = vi + .spyOn(agentContext, "detectAgentContext") + .mockReturnValue({ + ...context, + isAgentic, + }); + + try { + await runCf(["auth", "whoami"], env); + + if (isAgentic) { + expect(stderr()).toContain( + 'Ask the user: "I can put this online for 60 minutes' + ); + expect(stderr()).toContain( + "complete the temporary-account claim before it expires" + ); + expect(stderr()).toContain( + "https://developers.cloudflare.com/workers/platform/claim-deployments/" + ); + expect(stderr()).toContain( + "If they agree, run `cf deploy --temporary-account`" + ); + } else { + expect(stderr()).not.toContain("cf deploy --temporary-account"); + } + } finally { + detection.mockRestore(); + } + } + ); it.each([true, false])( "rejects temporary-account flags from whoami without provisioning (cached: %s)", diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 4fb78e18c7..5e4704b7b8 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -1,3 +1,4 @@ +import { detectAgentContext } from "../../lib/agent-context.js"; import { getAuthToken } from "../../lib/auth-token.js"; import { fetchAuthorizedAccounts, @@ -45,10 +46,10 @@ const whoamiCommand: CommandModule = { token = await getAuthToken(); } catch { formatOutput({ authenticated: false, error: "Not logged in" }); - if (authSource === "none") { + if (authSource === "none" && detectAgentContext().isAgentic) { console.error( hint( - "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary preview account." + 'Ask the user: "I can put this online for 60 minutes without you signing in. To keep it, complete the temporary-account claim before it expires. Would you like me to deploy it that way?" (https://developers.cloudflare.com/workers/platform/claim-deployments/) If they agree, run `cf deploy --temporary-account`.' ) ); } diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 9eb1a6bdf1..cd3d9b5fbf 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -367,6 +367,7 @@ export async function resolveAccountIdForHelp( * regardless; the index signature reflects that. */ export interface CommandClientArgs { + dryRun?: boolean; /** When true, route through the local-explorer instead of the API. */ local?: boolean; /** `--persist-to` value, if the user supplied one. */ @@ -422,6 +423,12 @@ const LOCAL_MODE_PLACEHOLDER_TOKEN = "cf-local-mode-no-auth"; export async function createCommandClient( args: CommandClientArgs ): Promise { + if (args.dryRun && isTemporaryAccountRequired()) { + throw new Error( + "Temporary accounts cannot make API requests during --dry-run. Remove --temporary-account and sign in to fetch the schema." + ); + } + const baseURL = getCloudflareApiBaseUrl({ compliance_region: await getComplianceRegion(), }); From c32a9cb546c377d4eb458fe41586e8171d31be8e Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 19 Oct 2026 12:22:49 +0100 Subject: [PATCH 08/15] docs: correct Wrangler compatibility test-file counts --- packages/wrangler-tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler-tests/README.md b/packages/wrangler-tests/README.md index 1c96c66bab..1c2b293ae1 100644 --- a/packages/wrangler-tests/README.md +++ b/packages/wrangler-tests/README.md @@ -19,7 +19,7 @@ Each upstream case has exactly one outcome: [MANIFEST.md](./MANIFEST.md) records every canonical upstream identity, including full `describe()` ancestry where leaf names repeat. It currently contains 5,476 cases: 624 passing, 1,498 todo, and 3,354 skip. -The package has 109 test files: 97 ported/adapted files and 12 generated fallback shards under `src/__tests__/upstream/`. File count and canonical-case count are different units. +The package has 110 test files: 98 ported/adapted files and 12 generated fallback shards under `src/__tests__/upstream/`. File count and canonical-case count are different units. ## Running the suite From 910a5ca52b1d385a5c39f7f5e92aa832faf8d8db Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 19 Oct 2026 17:43:05 +0100 Subject: [PATCH 09/15] test: align whoami compatibility with agent-only hints --- packages/wrangler-tests/src/__tests__/whoami.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/wrangler-tests/src/__tests__/whoami.test.ts b/packages/wrangler-tests/src/__tests__/whoami.test.ts index 39b7a3c608..b892500409 100644 --- a/packages/wrangler-tests/src/__tests__/whoami.test.ts +++ b/packages/wrangler-tests/src/__tests__/whoami.test.ts @@ -87,11 +87,18 @@ describe("whoami", () => { expect, }) => { vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); + vi.stubEnv("CLAUDECODE", "1"); await runWrangler("auth whoami"); expect(JSON.parse(std.out)).toMatchObject({ authenticated: false }); - // The suggestion goes to stderr so stdout stays parseable JSON. + // cf gives agents instructions on stderr so stdout stays parseable JSON. expect(std.err).toContain( - "To deploy without logging in, run a command like `cf deploy --temporary-account` to use a temporary preview account." + 'Ask the user: "I can put this online for 60 minutes without you signing in. To keep it, complete the temporary-account claim before it expires. Would you like me to deploy it that way?"' + ); + expect(std.err).toContain( + "https://developers.cloudflare.com/workers/platform/claim-deployments/" + ); + expect(std.err).toContain( + "If they agree, run `cf deploy --temporary-account`" ); }); From 99c5f49440ee23c92f6394ace8ee1ccc709e21e2 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 19 Oct 2026 19:19:59 +0100 Subject: [PATCH 10/15] fix: avoid temporary-account hints for expired OAuth --- .changeset/temporary-accounts.md | 3 +- .../cli/src/__tests__/lib/auth-token.test.ts | 20 ++++++++- packages/cli/src/__tests__/lib/errors.test.ts | 11 +++-- .../src/__tests__/temporary-accounts.test.ts | 41 ++++++++++++++++++- packages/cli/src/lib/auth-token.ts | 6 +++ packages/cli/src/lib/errors.ts | 5 ++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/.changeset/temporary-accounts.md b/.changeset/temporary-accounts.md index b6f2e93c47..71b3a0fbc4 100644 --- a/.changeset/temporary-accounts.md +++ b/.changeset/temporary-accounts.md @@ -20,7 +20,8 @@ whether to deploy for 60 minutes and explains that claiming the account before it expires is required to keep it. When a command fails because no credentials exist, cf suggests rerunning it -with `--temporary-account`. +with `--temporary-account`. If stored OAuth credentials cannot be refreshed, +cf suggests retrying or running `cf auth login` instead. Pass `--event-code` with `--temporary-account` to redeem an event code when cf creates the account. cf does not cache, print, or record the code in telemetry. diff --git a/packages/cli/src/__tests__/lib/auth-token.test.ts b/packages/cli/src/__tests__/lib/auth-token.test.ts index 402210e18b..38979c8b15 100644 --- a/packages/cli/src/__tests__/lib/auth-token.test.ts +++ b/packages/cli/src/__tests__/lib/auth-token.test.ts @@ -1,10 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { getAuthToken } from "../../lib/auth-token.js"; import { CliExit } from "../../lib/cli-exit.js"; +import { MissingCredentialsError } from "../../lib/errors.js"; -const { getOAuthToken } = vi.hoisted(() => ({ getOAuthToken: vi.fn() })); +const { getOAuthToken, isOAuthLoggedIn } = vi.hoisted(() => ({ + getOAuthToken: vi.fn(), + isOAuthLoggedIn: vi.fn(), +})); vi.mock("../../lib/oauth/index.js", () => ({ getValidToken: getOAuthToken, + isOAuthLoggedIn, isTemporaryAccountRequired: () => false, })); @@ -18,6 +23,7 @@ beforeEach(() => { vi.stubEnv(key, undefined); } getOAuthToken.mockReset(); + isOAuthLoggedIn.mockReset().mockReturnValue(false); }); describe("getAuthToken", () => { @@ -42,6 +48,18 @@ describe("getAuthToken", () => { it("explains how to authenticate when no token exists", async () => { getOAuthToken.mockResolvedValue(undefined); + await expect(getAuthToken()).rejects.toBeInstanceOf( + MissingCredentialsError + ); + await expect(getAuthToken()).rejects.toThrow("cf auth login"); + }); + + it("does not classify a failed OAuth refresh as missing credentials", async () => { + getOAuthToken.mockResolvedValue(undefined); + isOAuthLoggedIn.mockReturnValue(true); + await expect(getAuthToken()).rejects.not.toBeInstanceOf( + MissingCredentialsError + ); await expect(getAuthToken()).rejects.toThrow("cf auth login"); }); diff --git a/packages/cli/src/__tests__/lib/errors.test.ts b/packages/cli/src/__tests__/lib/errors.test.ts index 946094c2ff..c18b310279 100644 --- a/packages/cli/src/__tests__/lib/errors.test.ts +++ b/packages/cli/src/__tests__/lib/errors.test.ts @@ -54,7 +54,7 @@ describe("handleError", () => { expect(handleError(err)).toBe(err); }); - it("recommends a temporary account when non-interactive auth fails", () => { + it("recommends a temporary account when credentials are missing", () => { handleError(new MissingCredentialsError("No authentication token found.")); expect(stderr()).toContain("--temporary-account"); @@ -66,16 +66,21 @@ describe("handleError", () => { ) ); expect(stderr()).toContain("--temporary-account"); + }); + it("does not recommend a temporary account when OAuth credentials expire", () => { errSpy.mockClear(); handleError( new UserError( - "Not logged in. Your auth token has expired and could not be refreshed, and the environment is non-interactive.", + "Not logged in. Your auth token has expired and could not be refreshed, and the environment is non-interactive. Run `cf auth login` in an interactive terminal or set a CLOUDFLARE_API_TOKEN.", { telemetryMessage: "cfetch auth login required" } ) ); - expect(stderr()).toContain("--temporary-account"); + expect(stderr()).toContain("cf auth login"); + expect(stderr()).not.toContain("--temporary-account"); + }); + it("does not recommend a temporary account for network or unrelated errors", () => { errSpy.mockClear(); handleError( new UserError( diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index 53c2cc9cfa..45b194d3bc 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -6,13 +6,19 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; -import { getCfConfigPath } from "@cloudflare/workers-auth/cf"; +import { + getCfConfigPath, + writeAuthConfigFile, +} from "@cloudflare/workers-auth/cf"; import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as agentContext from "../lib/agent-context.js"; import { getAccountId, getAuthToken } from "../lib/auth.js"; -import { setTemporaryAccountRequired } from "../lib/oauth/index.js"; +import { + readAuthCredentials, + setTemporaryAccountRequired, +} from "../lib/oauth/index.js"; import { server, setupMsw, TEST_BASE_URL } from "./helpers/msw.js"; import { runCf } from "./helpers/run-cf.js"; @@ -305,6 +311,37 @@ describe("--temporary-account", () => { expect(stderr()).toContain("rerun this command with `--temporary-account`"); }); + it.each([undefined, "configured-account-id"])( + "recommends login after a rejected OAuth refresh (account ID: %s)", + async (configuredAccountId) => { + const stderr = captureStderr(); + const provisioningRequests = captureProvisioningRequests(); + const credentials = { + oauth_token: "expired-token", + refresh_token: "expired-refresh-token", + expiration_time: "2000-01-01T00:00:00.000Z", + }; + writeAuthConfigFile(credentials); + server.use( + http.post("https://dash.cloudflare.com/oauth2/token", () => + HttpResponse.json({ error: "invalid_grant" }, { status: 400 }) + ) + ); + + await expect( + runCf(["kv", "namespaces", "list"], { + ...env, + CI: "true", + CLOUDFLARE_ACCOUNT_ID: configuredAccountId, + }) + ).rejects.toThrow("cf auth login"); + + expect(stderr()).not.toContain("--temporary-account"); + expect(provisioningRequests).toEqual([]); + expect(readAuthCredentials()).toMatchObject(credentials); + } + ); + it("creates an event account without persisting or printing the code", async () => { const stderr = captureStderr(); const creationRequests = mockProvisioning(); diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index c9e823653e..11d8af6a75 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -2,6 +2,7 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; import { MissingCredentialsError } from "./errors.js"; import { getValidToken as getOAuthToken, + isOAuthLoggedIn, isTemporaryAccountRequired, requireTemporaryAccount, } from "./oauth/index.js"; @@ -36,6 +37,11 @@ export async function getAuthToken( if (cfToken) { return cfToken; } + if (isOAuthLoggedIn()) { + throw new Error( + "Could not refresh your stored OAuth token. Check your connection and retry, or run 'cf auth login' to authenticate again." + ); + } throw new MissingCredentialsError(`No authentication token found. diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 89ff1f43d1..924b6e7a51 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -10,8 +10,8 @@ import { ApiError } from "#api"; export class MissingCredentialsError extends Error {} /** - * Whether to suggest a temporary account. Like Wrangler, this covers missing or - * expired credentials in a non-interactive environment. + * Only suggest a temporary account when credentials are missing. Stored OAuth + * credentials, even expired ones, prevent temporary-account creation. */ function isMissingCredentialsError(error: Error): boolean { if (error instanceof MissingCredentialsError) { @@ -22,6 +22,7 @@ function isMissingCredentialsError(error: Error): boolean { return ( "telemetryMessage" in error && error.telemetryMessage === "cfetch auth login required" && + error.message.includes("no credentials were found") && error.message.includes("the environment is non-interactive") ); } From 1839dd29459738f95568349a1cef8ef92e481788 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 19 Oct 2026 19:25:52 +0100 Subject: [PATCH 11/15] fix: clarify temporary-account dry-run guidance --- .changeset/temporary-accounts.md | 28 ++++------------------------ packages/cli/src/lib/auth.ts | 2 +- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/.changeset/temporary-accounts.md b/.changeset/temporary-accounts.md index 71b3a0fbc4..64f78bfead 100644 --- a/.changeset/temporary-accounts.md +++ b/.changeset/temporary-accounts.md @@ -4,28 +4,8 @@ Support temporary accounts with `--temporary-account` -When no Cloudflare credentials are available, commands can now create or reuse -a short-lived account. cf prints the account's claim URL and uses that -account's ID and scoped API token for the command. +Create or reuse a short-lived Cloudflare account without signing in. cf prints +a claim URL; use `--event-code` to redeem an event code during account creation. -Help never creates a temporary account. `cf auth whoami` rejects -`--temporary-account` and `--event-code` so checking authentication cannot -create an account. - -Dry runs do not provision temporary accounts, including when runtime input -schemas are needed. Use existing credentials for those schema requests. - -When an unauthenticated agent runs `cf auth whoami`, cf suggests asking the user -whether to deploy for 60 minutes and explains that claiming the account before -it expires is required to keep it. - -When a command fails because no credentials exist, cf suggests rerunning it -with `--temporary-account`. If stored OAuth credentials cannot be refreshed, -cf suggests retrying or running `cf auth login` instead. - -Pass `--event-code` with `--temporary-account` to redeem an event code when cf -creates the account. cf does not cache, print, or record the code in telemetry. - -`cf auth logout` now uses the same shared messages as Wrangler and also clears a -cached temporary account. When `CLOUDFLARE_API_TOKEN` is set, it asks you to -unset the variable and leaves any stored OAuth login in place, as Wrangler does. +Help, authentication checks, and dry runs never create accounts. `cf auth logout` +clears cached temporary accounts. diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index cd3d9b5fbf..5ce49bd47a 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -425,7 +425,7 @@ export async function createCommandClient( ): Promise { if (args.dryRun && isTemporaryAccountRequired()) { throw new Error( - "Temporary accounts cannot make API requests during --dry-run. Remove --temporary-account and sign in to fetch the schema." + "Temporary accounts cannot make API requests during --dry-run. Remove --temporary-account and sign in to continue." ); } From 27ce2ebe530804a4612a6ff40a70eecc5c223560 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 19 Oct 2026 19:33:11 +0100 Subject: [PATCH 12/15] fix: enforce temporary-account helper precondition --- packages/cli/src/__tests__/temporary-accounts.test.ts | 11 +++++++++++ packages/cli/src/lib/oauth/index.ts | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index 45b194d3bc..2a1653722c 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -17,6 +17,7 @@ import * as agentContext from "../lib/agent-context.js"; import { getAccountId, getAuthToken } from "../lib/auth.js"; import { readAuthCredentials, + requireTemporaryAccount, setTemporaryAccountRequired, } from "../lib/oauth/index.js"; import { server, setupMsw, TEST_BASE_URL } from "./helpers/msw.js"; @@ -183,6 +184,16 @@ describe("--temporary-account", () => { expect(authorization).toBe(`Bearer ${apiToken}`); }); + it("rejects direct temporary-account requests when the mode is disabled", async () => { + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "existing-account-id"); + vi.stubEnv("CLOUDFLARE_API_TOKEN", "existing-token"); + setTemporaryAccountRequired(false); + + await expect(requireTemporaryAccount()).rejects.toThrow( + "Temporary accounts require --temporary-account." + ); + }); + it("activates the account when the token is requested first", async () => { captureStderr(); vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "configured-account-id"); diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index 517dd346f9..7c786d4902 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -112,6 +112,10 @@ export async function requireTemporaryAccount(options?: { isPreview?: boolean; complianceRegion?: ComplianceRegion; }): Promise<{ accountId: string; apiToken: string }> { + if (!isTemporaryAccountRequired()) { + throw new Error("Temporary accounts require --temporary-account."); + } + try { const accountId = await auth.requireAuth({ compliance_region: From 230fc68e7037b9f219829b57505df240c7d7c23a Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 13:23:00 +0100 Subject: [PATCH 13/15] fix: clarify temporary-account authentication --- .../cli/src/__tests__/lib/auth-token.test.ts | 2 +- .../cli/src/__tests__/lib/context.test.ts | 29 +++++++++-- packages/cli/src/__tests__/lib/errors.test.ts | 17 ++++--- .../src/__tests__/temporary-accounts.test.ts | 20 ++++++-- packages/cli/src/commands/auth/whoami.ts | 13 ++--- packages/cli/src/commands/previews/deploy.ts | 2 +- packages/cli/src/index.ts | 14 +++--- packages/cli/src/lib/auth-token.ts | 4 +- packages/cli/src/lib/auth.ts | 8 +-- packages/cli/src/lib/context.ts | 8 +-- packages/cli/src/lib/errors.ts | 7 ++- packages/cli/src/lib/oauth/index.ts | 50 +++++++++++++------ .../helpers/mock-temporary-account.ts | 8 +-- 13 files changed, 112 insertions(+), 70 deletions(-) diff --git a/packages/cli/src/__tests__/lib/auth-token.test.ts b/packages/cli/src/__tests__/lib/auth-token.test.ts index 38979c8b15..6657e57a04 100644 --- a/packages/cli/src/__tests__/lib/auth-token.test.ts +++ b/packages/cli/src/__tests__/lib/auth-token.test.ts @@ -10,7 +10,7 @@ const { getOAuthToken, isOAuthLoggedIn } = vi.hoisted(() => ({ vi.mock("../../lib/oauth/index.js", () => ({ getValidToken: getOAuthToken, isOAuthLoggedIn, - isTemporaryAccountRequired: () => false, + isTemporaryAccountRequested: () => false, })); beforeEach(() => { diff --git a/packages/cli/src/__tests__/lib/context.test.ts b/packages/cli/src/__tests__/lib/context.test.ts index 47aa4bc30c..495cafec46 100644 --- a/packages/cli/src/__tests__/lib/context.test.ts +++ b/packages/cli/src/__tests__/lib/context.test.ts @@ -9,7 +9,8 @@ import { getZoneId, resolveAccountIdSilent, } from "../../lib/context.js"; -import { setTemporaryAccountRequired } from "../../lib/oauth/index.js"; +import { MissingCredentialsError } from "../../lib/errors.js"; +import { setTemporaryAccountRequested } from "../../lib/oauth/index.js"; import { clearLoadedProjectSettings } from "../../lib/project-settings.js"; import type { ParsedInputSettingsConfig } from "@cloudflare/config"; @@ -17,6 +18,7 @@ const getOrSelectAccountIdMock = vi.hoisted(() => vi.fn()); const requireAuthMock = vi.hoisted(() => vi.fn()); const getActiveAccountIdMock = vi.hoisted(() => vi.fn()); const fetchAllAccountsMock = vi.hoisted(() => vi.fn()); +const getAPITokenMock = vi.hoisted(() => vi.fn()); vi.mock("@cloudflare/workers-auth/cf", () => ({ createCfAuth: () => ({ @@ -26,6 +28,7 @@ vi.mock("@cloudflare/workers-auth/cf", () => ({ requireApiToken: () => ({ apiToken: "temporary-token" }), getActiveAccountId: getActiveAccountIdMock, fetchAllAccounts: fetchAllAccountsMock, + getAPIToken: getAPITokenMock, }), createCfProfileStore: vi.fn(), validateScopeKeys: vi.fn(), @@ -45,10 +48,11 @@ describe("context", () => { vi.restoreAllMocks(); getOrSelectAccountIdMock.mockReset(); requireAuthMock.mockReset(); - setTemporaryAccountRequired(false); + setTemporaryAccountRequested(false); getActiveAccountIdMock.mockReset(); fetchAllAccountsMock.mockReset(); fetchAllAccountsMock.mockResolvedValue([]); + getAPITokenMock.mockReset(); clearLoadedProjectSettings(); vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", undefined); vi.stubEnv("CLOUDFLARE_ZONE_ID", undefined); @@ -56,6 +60,25 @@ describe("context", () => { }); describe("getAccountId", () => { + it.each([undefined, { apiToken: "expired-stored-token" }])( + "classifies legacy auth errors by credential state (%j)", + async (credentials) => { + vi.stubEnv("CI", "true"); + getAPITokenMock.mockReturnValue(credentials); + const error = Object.assign(new Error("Auth wording changed"), { + telemetryMessage: "cfetch auth login required", + }); + getOrSelectAccountIdMock.mockRejectedValue(error); + if (credentials) { + await expect(getAccountId()).rejects.toBe(error); + } else { + await expect(getAccountId()).rejects.toBeInstanceOf( + MissingCredentialsError + ); + } + } + ); + it("loads account id from cloudflare.config.ts settings", async () => { writeProjectSettings({ accountId: "settings-acct" }); getOrSelectAccountIdMock.mockResolvedValue("settings-acct"); @@ -79,7 +102,7 @@ describe("context", () => { it("uses a temporary account instead of configured accounts", async () => { writeProjectSettings({ accountId: "settings-acct" }); - setTemporaryAccountRequired(true); + setTemporaryAccountRequested(true); requireAuthMock.mockResolvedValue("temporary-account"); await expect(resolveAccountIdSilent()).resolves.toBeUndefined(); diff --git a/packages/cli/src/__tests__/lib/errors.test.ts b/packages/cli/src/__tests__/lib/errors.test.ts index c18b310279..1fa1bdb86e 100644 --- a/packages/cli/src/__tests__/lib/errors.test.ts +++ b/packages/cli/src/__tests__/lib/errors.test.ts @@ -60,9 +60,11 @@ describe("handleError", () => { errSpy.mockClear(); handleError( - new UserError( - "Not logged in. Could not authenticate because no credentials were found and the environment is non-interactive.", - { telemetryMessage: "cfetch auth login required" } + Object.assign( + new UserError("Authentication required; wording may change.", { + telemetryMessage: "cfetch auth login required", + }), + { reason: "no-credentials-non-interactive" } ) ); expect(stderr()).toContain("--temporary-account"); @@ -71,9 +73,12 @@ describe("handleError", () => { it("does not recommend a temporary account when OAuth credentials expire", () => { errSpy.mockClear(); handleError( - new UserError( - "Not logged in. Your auth token has expired and could not be refreshed, and the environment is non-interactive. Run `cf auth login` in an interactive terminal or set a CLOUDFLARE_API_TOKEN.", - { telemetryMessage: "cfetch auth login required" } + Object.assign( + new UserError( + "Not logged in. Your auth token has expired and could not be refreshed, and the environment is non-interactive. Run `cf auth login` in an interactive terminal or set a CLOUDFLARE_API_TOKEN.", + { telemetryMessage: "cfetch auth login required" } + ), + { reason: "token-expired-non-interactive" } ) ); expect(stderr()).toContain("cf auth login"); diff --git a/packages/cli/src/__tests__/temporary-accounts.test.ts b/packages/cli/src/__tests__/temporary-accounts.test.ts index 2a1653722c..9905e5b027 100644 --- a/packages/cli/src/__tests__/temporary-accounts.test.ts +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -18,7 +18,7 @@ import { getAccountId, getAuthToken } from "../lib/auth.js"; import { readAuthCredentials, requireTemporaryAccount, - setTemporaryAccountRequired, + setTemporaryAccountRequested, } from "../lib/oauth/index.js"; import { server, setupMsw, TEST_BASE_URL } from "./helpers/msw.js"; import { runCf } from "./helpers/run-cf.js"; @@ -127,6 +127,16 @@ describe("--temporary-account", () => { return requests; } + it("does not activate a cached temporary account without the flag", async () => { + const provisioningRequests = captureProvisioningRequests(); + captureStderr(); + await expect(runCf(["kv", "namespaces", "list"], env)).rejects.toThrow( + "No authentication token found" + ); + expect(existsSync(cachePath())).toBe(true); + expect(provisioningRequests).toEqual([]); + }); + it("uses the temporary account ID and token for generated commands", async () => { const stderr = captureStderr(); let authorization: string | null = null; @@ -187,7 +197,7 @@ describe("--temporary-account", () => { it("rejects direct temporary-account requests when the mode is disabled", async () => { vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "existing-account-id"); vi.stubEnv("CLOUDFLARE_API_TOKEN", "existing-token"); - setTemporaryAccountRequired(false); + setTemporaryAccountRequested(false); await expect(requireTemporaryAccount()).rejects.toThrow( "Temporary accounts require --temporary-account." @@ -198,12 +208,12 @@ describe("--temporary-account", () => { captureStderr(); vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "configured-account-id"); vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); - setTemporaryAccountRequired(true); + setTemporaryAccountRequested(true); try { await expect(getAuthToken()).resolves.toBe(apiToken); await expect(getAccountId()).resolves.toBe(accountId); } finally { - setTemporaryAccountRequired(false); + setTemporaryAccountRequested(false); } }); @@ -258,7 +268,7 @@ describe("--temporary-account", () => { await expect( runCf([...command, "--dry-run", "--temporary-account"], env) ).rejects.toThrow( - "Temporary accounts cannot make API requests during --dry-run." + "This command's dry run cannot be used with --temporary-account. Remove --dry-run to run the command for real." ); expect(requests).toEqual([]); diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 5e4704b7b8..6c0abfdd88 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -7,20 +7,13 @@ import { } from "../../lib/oauth/index.js"; import { formatOutput } from "../../lib/output.js"; import { hint } from "../../lib/ui/index.js"; -import type { CommonYargsOptions } from "../../lib/cli-types.js"; -import type { ArgumentsCamelCase, Argv, CommandModule } from "yargs"; +import type { CommandModule } from "yargs"; -type WhoamiArgs = Pick; - -const whoamiCommand: CommandModule = { +const whoamiCommand: CommandModule = { command: "whoami", describe: "Show current user and authentication status", - builder: (yargs: Argv): Argv => { - return yargs as Argv; - }, - - handler: async (argv: ArgumentsCamelCase): Promise => { + handler: async (argv): Promise => { if (argv["temporary-account"] || argv["event-code"] !== undefined) { throw new Error( "--temporary-account and --event-code are not supported by cf auth whoami." diff --git a/packages/cli/src/commands/previews/deploy.ts b/packages/cli/src/commands/previews/deploy.ts index e641d95cbd..3405e71e5a 100644 --- a/packages/cli/src/commands/previews/deploy.ts +++ b/packages/cli/src/commands/previews/deploy.ts @@ -104,7 +104,7 @@ async function deployPreviewBuildOutput( validateBuildOutputMode(argv.mode, output.rootConfig.buildContext.mode); validateBuildOutputAccount( output.rootConfig.accountId, - argv["temporary-account"] === true + argv["temporary-account"] ); const worker = selectBuildOutputWorker(output.workers, argv.worker); const { wranglerConfig, builtConfig } = parseWorkerConfig( diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 79f619b994..e064827b09 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -85,7 +85,8 @@ const GLOBAL_OPTIONS = { // Errors never include the event code: it is a shared secret for an event. "event-code": { type: "string", - description: "Create a temporary account for an event", + description: + "Specify the event code to use when creating a temporary account for an event", requiresArg: true, global: true, hidden: true, @@ -648,13 +649,12 @@ export function buildCli(rawArgs: string[], options: BuildCliOptions = {}) { throw new Error("--persist-to can only be used with --local."); } - const temporaryAccount = argv["temporary-account"] === true; - if (temporaryAccount && argv.local) { + if (argv["temporary-account"] && argv.local) { throw new Error("--temporary-account cannot be used with --local."); } const eventCode = argv["event-code"]; // Never silently ignore an event code when real credentials apply. - if (eventCode !== undefined && !temporaryAccount) { + if (eventCode !== undefined && !argv["temporary-account"]) { throw new Error("--event-code requires --temporary-account."); } @@ -668,10 +668,10 @@ export function buildCli(rawArgs: string[], options: BuildCliOptions = {}) { // Local mode bypasses profile credentials, so resolving and displaying // an active profile would be misleading. if (!argv.local) { - const { resolveProfile, setTemporaryAccountRequired } = + const { resolveProfile, setTemporaryAccountRequested } = await import("./lib/oauth/index.js"); - setTemporaryAccountRequired( - temporaryAccount, + setTemporaryAccountRequested( + argv["temporary-account"], eventCode === undefined ? undefined : { eventCode } ); const profile = resolveProfile(argv.profile); diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index 11d8af6a75..60b3e66e92 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -3,7 +3,7 @@ import { MissingCredentialsError } from "./errors.js"; import { getValidToken as getOAuthToken, isOAuthLoggedIn, - isTemporaryAccountRequired, + isTemporaryAccountRequested, requireTemporaryAccount, } from "./oauth/index.js"; @@ -22,7 +22,7 @@ import { export async function getAuthToken( options?: Parameters[0] ): Promise { - if (isTemporaryAccountRequired()) { + if (isTemporaryAccountRequested()) { return (await requireTemporaryAccount(options)).apiToken; } diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 5ce49bd47a..8e2e2956bf 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -3,7 +3,7 @@ import { API_TIMEOUT_MS } from "./api-constants.js"; import { isUploadBody } from "./api-upload.js"; import { getAuthToken } from "./auth-token.js"; import { getComplianceRegion, resolveAccountIdSilent } from "./context.js"; -import { isTemporaryAccountRequired } from "./oauth/index.js"; +import { isTemporaryAccountRequested } from "./oauth/index.js"; import { getDefaultHeaders } from "./request-headers.js"; import { ApiError, Client, type ClientOptions } from "#api"; import listAccounts from "#operations/accounts/list.js"; @@ -286,7 +286,7 @@ export function createCloudflareClientWithToken( export async function resolveAccountIdForHelp( deadline: number ): Promise { - if (isTemporaryAccountRequired()) { + if (isTemporaryAccountRequested()) { return undefined; } @@ -423,9 +423,9 @@ const LOCAL_MODE_PLACEHOLDER_TOKEN = "cf-local-mode-no-auth"; export async function createCommandClient( args: CommandClientArgs ): Promise { - if (args.dryRun && isTemporaryAccountRequired()) { + if (args.dryRun && isTemporaryAccountRequested()) { throw new Error( - "Temporary accounts cannot make API requests during --dry-run. Remove --temporary-account and sign in to continue." + "This command's dry run cannot be used with --temporary-account. Remove --dry-run to run the command for real." ); } diff --git a/packages/cli/src/lib/context.ts b/packages/cli/src/lib/context.ts index d01664437f..2ea50f1878 100644 --- a/packages/cli/src/lib/context.ts +++ b/packages/cli/src/lib/context.ts @@ -85,11 +85,11 @@ async function resolveAccountId( */ export async function resolveAccountIdSilent(): Promise { // Lazily imported for the same dependency-cycle reason as getAccountId. - const { getActiveAccountId, isTemporaryAccountRequired } = + const { getActiveAccountId, isTemporaryAccountRequested } = await import("./oauth/index.js"); // Configured accounts don't apply to a temporary account, and provisioning // one is not silent. - if (isTemporaryAccountRequired()) { + if (isTemporaryAccountRequested()) { return undefined; } const result = await resolveAccountId(); @@ -120,10 +120,10 @@ export async function getAccountId(options?: { // module has initialised to avoid a static context ↔ OAuth dependency cycle. const { getOrSelectAccountId, - isTemporaryAccountRequired, + isTemporaryAccountRequested, requireTemporaryAccount, } = await import("./oauth/index.js"); - if (isTemporaryAccountRequired()) { + if (isTemporaryAccountRequested()) { return (await requireTemporaryAccount(options)).accountId; } diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 924b6e7a51..68c0e06fbc 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -17,13 +17,12 @@ function isMissingCredentialsError(error: Error): boolean { if (error instanceof MissingCredentialsError) { return true; } - // workers-auth's account lookup error. Its package may resolve a separate - // workers-utils copy, so compare its telemetry identity instead of class. + // Structural matching also works when the shared package is bundled separately. return ( "telemetryMessage" in error && error.telemetryMessage === "cfetch auth login required" && - error.message.includes("no credentials were found") && - error.message.includes("the environment is non-interactive") + "reason" in error && + error.reason === "no-credentials-non-interactive" ); } diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index 7c786d4902..eda1cfc46a 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -17,6 +17,7 @@ import { USER_AGENT, VERSION } from "../../version.js"; import { hasQuietFlag } from "../args.js"; import { CliExit } from "../cli-exit.js"; import { getComplianceRegion, type ComplianceRegion } from "../context.js"; +import { MissingCredentialsError } from "../errors.js"; import { isNonInteractiveOrCI } from "../interactive.js"; import { openSession } from "../session.js"; import { printAccountConfigHint } from "./account-hint.js"; @@ -85,34 +86,35 @@ const auth = createCfAuth({ error instanceof NonInteractiveSelectionError, }); -let temporaryAccountRequired = false; +let temporaryAccountRequested = false; /** - * Mark whether this invocation requires a temporary preview account, - * optionally for an event. + * Record whether the user requested --temporary-account for this invocation. + * An event code applies only when creating a new account, not reusing one. */ -export function setTemporaryAccountRequired( - required: boolean, +export function setTemporaryAccountRequested( + requested: boolean, request?: TemporaryAccountRequest ): void { - temporaryAccountRequired = required; - auth.setTemporaryAllowed(required, request); + temporaryAccountRequested = requested; + auth.setTemporaryAllowed(requested, request); } -/** Whether authentication requires a temporary preview account. */ -export function isTemporaryAccountRequired(): boolean { - return temporaryAccountRequired; +/** Whether the user requested --temporary-account for this invocation. */ +export function isTemporaryAccountRequested(): boolean { + return temporaryAccountRequested; } /** * Provision or reuse this invocation's temporary preview account. workers-auth * keeps it active for later calls and rejects it when credentials exist. + * Every invocation must opt in, even when an account is already cached. */ export async function requireTemporaryAccount(options?: { isPreview?: boolean; complianceRegion?: ComplianceRegion; }): Promise<{ accountId: string; apiToken: string }> { - if (!isTemporaryAccountRequired()) { + if (!isTemporaryAccountRequested()) { throw new Error("Temporary accounts require --temporary-account."); } @@ -141,11 +143,27 @@ export async function getOrSelectAccountId( accountId?: string, options?: { isPreview?: boolean; complianceRegion?: ComplianceRegion } ): Promise { - return auth.getOrSelectAccountId({ - account_id: accountId, - compliance_region: - options?.complianceRegion ?? (await getComplianceRegion(options)), - }); + try { + return await auth.getOrSelectAccountId({ + account_id: accountId, + compliance_region: + options?.complianceRegion ?? (await getComplianceRegion(options)), + }); + } catch (error) { + // Older workers-auth releases omit the structured failure reason. Use + // credential state at this boundary rather than parsing their message. + if ( + error instanceof Error && + !("reason" in error) && + "telemetryMessage" in error && + error.telemetryMessage === "cfetch auth login required" && + isNonInteractiveOrCI() && + auth.getAPIToken() === undefined + ) { + throw new MissingCredentialsError(error.message, { cause: error }); + } + throw error; + } } /** diff --git a/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts b/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts index 5d389ce59a..43591b264a 100644 --- a/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts +++ b/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts @@ -18,9 +18,6 @@ export const TEMPORARY_TERMS_PROMPT = `You must accept Cloudflare's Terms of Ser /** * cf's temporary-account cache. Computed lazily so `runInTempDir()`'s HOME and * XDG_CONFIG_HOME stubs are honoured. - * - * @param environment The Cloudflare API environment the cache belongs to. - * @returns The absolute cache path. */ export function temporaryAccountCachePath(environment = "production"): string { return join( @@ -37,8 +34,6 @@ export function temporaryAccountCachePath(environment = "production"): string { /** * Write raw contents to cf's temporary-account cache. - * - * @param contents The cache file contents. */ export function writeTemporaryAccountCache(contents: unknown): void { const path = temporaryAccountCachePath(); @@ -68,8 +63,7 @@ export function mockTemporaryPreviewChallenge(): void { * Mocks the temporary preview-account challenge and creation endpoints. The * created account is `preview-account-id` with token `preview-account-token`, * and event codes are acknowledged. - * - * @returns The creation request bodies and headers, appended as requests arrive. + * Records creation request bodies and headers as requests arrive. */ export function mockTemporaryPreviewAccount(): Array<{ body: { eventCode?: string }; From 8d667e9da9425ed06fa8c03a0017f0f9bc493df0 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 19 Oct 2026 20:43:18 +0000 Subject: [PATCH 14/15] fix(auth): support keyring storage options during login (#359) --- .changeset/auth-login-keyring.md | 7 + packages/cli/README.md | 7 + .../commands/auth/keyring-login.test.ts | 185 ++++++++++++++++++ .../commands/hand-written-metadata.test.ts | 12 ++ .../commands/_generated/_meta/commands.json | 6 + .../_meta/hand-written-commands.json | 6 + packages/cli/src/commands/auth/login.ts | 15 +- packages/cli/src/commands/auth/meta.ts | 7 + packages/cli/src/lib/oauth/index.ts | 11 +- 9 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 .changeset/auth-login-keyring.md create mode 100644 packages/cli/src/__tests__/commands/auth/keyring-login.test.ts diff --git a/.changeset/auth-login-keyring.md b/.changeset/auth-login-keyring.md new file mode 100644 index 0000000000..7641fa748a --- /dev/null +++ b/.changeset/auth-login-keyring.md @@ -0,0 +1,7 @@ +--- +"cf": minor +--- + +Add persisted keyring storage options to `cf auth login` + +Use `--use-keyring` and `--no-use-keyring` to persist the OAuth credential-storage preference. Apply the preference before reading credentials so users can disable an unavailable keyring backend, and expose the option in help, completions, and tools. diff --git a/packages/cli/README.md b/packages/cli/README.md index 6d4640ce11..be5b812efa 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -39,6 +39,13 @@ cf resolves credentials in this order: Use `cf auth create`, `delete`, `activate`, `deactivate`, and `list` to manage named profiles. `cf auth login` and `cf auth logout` manage the default profile. +Use `cf auth login --use-keyring` to encrypt OAuth credentials with a key stored +in your OS keychain. The choice is saved across invocations and applies to all +profiles. Use `cf auth login --no-use-keyring` to save a preference for plaintext +storage. Unless `CLOUDFLARE_AUTH_USE_KEYRING=true` is set, this also removes saved +encrypted credentials, so affected profiles must authenticate again. +`CLOUDFLARE_AUTH_USE_KEYRING=true` or `false` overrides the saved preference. + ## Projects `cf init [directory]` starts a project. If you leave out the directory, cf asks for one (use `cf init .` for the current directory in scripts). In a new or empty directory (only `.git` is allowed), it creates a hello-world Worker with `cloudflare.config.ts`, `vite.config.ts`, `src/index.ts`, `tsconfig.json`, `package.json`, and `.gitignore`, then installs dependencies with the package manager you choose (`--package-manager`, or `--no-install` to skip) and, once they are installed, generates Worker types in `.cloudflare/types/index.d.ts`. With `--no-install`, the types are generated later by `cf dev` or the project's `typecheck` script, which runs `cf workers types` and then `tsc`. In a directory that already contains files, it configures the existing project with autoconfig instead. Either way, it finishes with the next steps to run. `cf init workers [directory]` is the default initializer and currently does the same thing. diff --git a/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts new file mode 100644 index 0000000000..a15c542647 --- /dev/null +++ b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts @@ -0,0 +1,185 @@ +import { + readUserPreferences, + updateUserPreferences, +} from "@cloudflare/workers-auth/cf"; +import { + mockConsoleMethods, + runInTempDir, +} from "@cloudflare/workers-utils/test-helpers"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { runCf } from "../../helpers/run-cf.js"; + +const sharedAuth = vi.hoisted(() => ({ + activeProfile: "default", + getCredentialStore: vi.fn(), + getOAuthTokenFromLocalState: vi.fn(), + readAuthCredentials: vi.fn(), + login: vi.fn(), +})); + +const telemetryEvents = vi.hoisted( + () => [] as Array<{ name: string; properties: Record }> +); + +vi.mock("@cloudflare/workers-auth/cf", async (importOriginal) => ({ + ...(await importOriginal>()), + createCfAuth: () => ({ + setProfile: (profile: string) => { + sharedAuth.activeProfile = profile; + }, + getActiveProfile: () => sharedAuth.activeProfile, + getCredentialStore: sharedAuth.getCredentialStore, + getOAuthTokenFromLocalState: sharedAuth.getOAuthTokenFromLocalState, + readAuthCredentials: sharedAuth.readAuthCredentials, + login: sharedAuth.login, + }), +})); + +vi.mock("../../../lib/telemetry/dispatcher.js", () => ({ + getTelemetryDispatcher: () => ({ + enabled: true, + sendCommandEvent: (name: string, properties: Record) => + telemetryEvents.push({ name, properties }), + sendAdhocEvent: vi.fn(), + }), +})); + +describe("cf auth login keyring preference", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const credentials = { oauth_token: "test-token" }; + const encryptedStore = { kind: "encrypted-file", path: () => "auth.enc" }; + + beforeEach(() => { + sharedAuth.activeProfile = "default"; + sharedAuth.getCredentialStore.mockReset().mockReturnValue(encryptedStore); + sharedAuth.getOAuthTokenFromLocalState + .mockReset() + .mockResolvedValue(credentials.oauth_token); + sharedAuth.readAuthCredentials.mockReset().mockReturnValue(credentials); + sharedAuth.login.mockReset().mockResolvedValue(true); + telemetryEvents.length = 0; + vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); + vi.stubEnv("CLOUDFLARE_AUTH_USE_KEYRING", undefined); + }); + + it.each(["--use-keyring", "--useKeyring"])( + "persists %s before reading existing credentials", + async (flag) => { + sharedAuth.getCredentialStore.mockImplementation(() => { + expect(sharedAuth.activeProfile).toBe("default"); + expect(readUserPreferences().keyring_enabled).toBe(true); + return encryptedStore; + }); + sharedAuth.readAuthCredentials.mockImplementation(() => { + expect(readUserPreferences().keyring_enabled).toBe(true); + return credentials; + }); + + await runCf(["auth", "login", flag]); + + expect(readUserPreferences().keyring_enabled).toBe(true); + expect(sharedAuth.login).not.toHaveBeenCalled(); + expect(std.out).toContain("You are already logged in."); + } + ); + + it.each(["--no-use-keyring", "--use-keyring=false", "--no-useKeyring"])( + "persists %s before accessing an unavailable backend", + async (flag) => { + updateUserPreferences({ keyring_enabled: true }); + sharedAuth.getCredentialStore.mockImplementation(() => { + if (readUserPreferences().keyring_enabled) { + throw new Error("Keyring backend is unavailable"); + } + return { kind: "file", path: () => "auth.json" }; + }); + sharedAuth.readAuthCredentials.mockImplementation(() => { + sharedAuth.getCredentialStore(); + return undefined; + }); + sharedAuth.login.mockImplementation(async () => { + expect(readUserPreferences().keyring_enabled).toBe(false); + return true; + }); + + await runCf(["auth", "login", flag, "--no-browser"]); + + expect(readUserPreferences().keyring_enabled).toBe(false); + expect(sharedAuth.login).toHaveBeenCalledOnce(); + } + ); + + it.each([true, false, undefined])( + "preserves the %s preference when the flag is omitted", + async (enabled) => { + if (enabled !== undefined) { + updateUserPreferences({ keyring_enabled: enabled }); + } + const previous = readUserPreferences(); + + await runCf(["auth", "login"]); + + expect(readUserPreferences()).toEqual(previous); + } + ); + + it("honors the environment override while persisting the requested preference", async () => { + await runCf(["auth", "login", "--use-keyring"], { + CLOUDFLARE_AUTH_USE_KEYRING: "false", + }); + + expect(readUserPreferences().keyring_enabled).toBe(true); + expect(std.err).toContain( + "CLOUDFLARE_AUTH_USE_KEYRING=false overrides enabling keyring storage" + ); + }); + + it("supports the cf login alias and records the boolean as telemetry-safe", async () => { + await runCf(["login", "--use-keyring"]); + + expect(readUserPreferences().keyring_enabled).toBe(true); + expect(telemetryEvents[0]).toMatchObject({ + name: "cf command started", + properties: { + command: "login", + sanitizedArgs: { "use-keyring": true }, + }, + }); + }); + + it.each([ + ["--use-keyring", "use-keyring", true], + ["--no-use-keyring", "use-keyring", false], + ["--useKeyring", "useKeyring", true], + ["--no-useKeyring", "useKeyring", false], + ] as const)("records %s as boolean telemetry", async (flag, key, enabled) => { + await runCf(["auth", "login", flag]); + + expect(telemetryEvents[0]).toMatchObject({ + name: "cf command started", + properties: { + command: "auth login", + sanitizedArgs: { [key]: enabled }, + }, + }); + }); + + it.each(["--help", "--dry-run"])( + "does not change the preference with %s", + async (flag) => { + await runCf(["auth", "login", "--use-keyring", flag]); + + expect(readUserPreferences().keyring_enabled).toBeUndefined(); + expect(sharedAuth.login).not.toHaveBeenCalled(); + } + ); + + it("rejects --profile before changing the preference", async () => { + await expect( + runCf(["auth", "login", "--use-keyring", "--profile", "work"]) + ).rejects.toThrow("cf auth create "); + + expect(readUserPreferences().keyring_enabled).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts b/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts index b8cc0f950e..aeef51682f 100644 --- a/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts +++ b/packages/cli/src/__tests__/commands/hand-written-metadata.test.ts @@ -87,6 +87,18 @@ describe("hand-written command metadata", () => { ).toBe(false); }); + it("publishes the keyring login option without overriding saved preferences", () => { + const option = commands + .get("cf auth login") + ?.options?.find((entry) => entry.name === "use-keyring"); + + expect(option).toMatchObject({ + type: "boolean", + required: false, + }); + expect(option).not.toHaveProperty("default"); + }); + it("includes descriptions for root hand-written command groups", () => { expect(metadata.descriptions?.auth).toBe( "Manage authentication and profiles" diff --git a/packages/cli/src/commands/_generated/_meta/commands.json b/packages/cli/src/commands/_generated/_meta/commands.json index 0ee54d94d1..b8257bbe70 100644 --- a/packages/cli/src/commands/_generated/_meta/commands.json +++ b/packages/cli/src/commands/_generated/_meta/commands.json @@ -18995,6 +18995,12 @@ "required": false, "description": "The set of OAuth scopes to request" }, + { + "name": "use-keyring", + "type": "boolean", + "required": false, + "description": "Encrypt OAuth credentials using the OS keychain (persisted across invocations; use --no-use-keyring to disable)" + }, { "name": "dry-run", "type": "boolean", diff --git a/packages/cli/src/commands/_generated/_meta/hand-written-commands.json b/packages/cli/src/commands/_generated/_meta/hand-written-commands.json index 98f89405b9..720504fc5f 100644 --- a/packages/cli/src/commands/_generated/_meta/hand-written-commands.json +++ b/packages/cli/src/commands/_generated/_meta/hand-written-commands.json @@ -508,6 +508,12 @@ "required": false, "description": "The set of OAuth scopes to request" }, + { + "name": "use-keyring", + "type": "boolean", + "required": false, + "description": "Encrypt OAuth credentials using the OS keychain (persisted across invocations; use --no-use-keyring to disable)" + }, { "name": "dry-run", "type": "boolean", diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 5d6bef3554..23262b385e 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -5,6 +5,7 @@ import { isOAuthLoggedIn, login, readAuthCredentials, + setKeyringPreference, setProfile, } from "../../lib/oauth/index.js"; import { hint, info, theme, warning } from "../../lib/ui/index.js"; @@ -18,12 +19,13 @@ interface LoginArgs extends CommonYargsOptions { browser?: boolean; device?: boolean; scopes?: string[]; + "use-keyring"?: boolean; } const MAX_INLINE_SCOPES = 5; export const loginTelemetryClassification: ArgClassification = { - safeFlags: ["force", "browser", "device"], + safeFlags: ["force", "browser", "device", "use-keyring", "useKeyring"], shortFlagAliases: { f: { canonical: "force", type: "boolean" }, }, @@ -57,6 +59,11 @@ const loginCommand: CommandModule = { type: "string", array: true, description: "The set of OAuth scopes to request", + }) + .option("use-keyring", { + type: "boolean", + description: + "Encrypt OAuth credentials using the OS keychain (persisted across invocations; use --no-use-keyring to disable)", }) as Argv; }, @@ -80,6 +87,12 @@ const loginCommand: CommandModule = { setProfile("default"); + // Apply the preference before any credential reads, including the + // already-logged-in check, so opting out can bypass an unavailable backend. + if (argv.useKeyring !== undefined) { + setKeyringPreference(argv.useKeyring); + } + if (!argv.force && isOAuthLoggedIn()) { // Verify the stored tokens actually work, refreshing if needed. const validToken = await getValidToken(); diff --git a/packages/cli/src/commands/auth/meta.ts b/packages/cli/src/commands/auth/meta.ts index 2af31d13f3..ada57d8276 100644 --- a/packages/cli/src/commands/auth/meta.ts +++ b/packages/cli/src/commands/auth/meta.ts @@ -39,6 +39,13 @@ export default [ required: false, description: "The set of OAuth scopes to request", }, + { + name: "use-keyring", + type: "boolean", + required: false, + description: + "Encrypt OAuth credentials using the OS keychain (persisted across invocations; use --no-use-keyring to disable)", + }, { name: "dry-run", type: "boolean", diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index eda1cfc46a..0464d2f2fa 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -2,7 +2,7 @@ * cf's OAuth wiring. * * The shared package owns cf's identity, scopes, callback handling, credential - * storage, refresh, and future keyring support. This module only supplies cf's + * storage, refresh, and keyring support. This module only supplies cf's * logger and prompt primitives, then preserves the façade consumed by commands. */ import { isCancel } from "@clack/core"; @@ -10,6 +10,7 @@ import * as clack from "@clack/prompts"; import { createCfAuth, createCfProfileStore, + setKeyringPreference as setSharedKeyringPreference, validateScopeKeys, type AuthContext, } from "@cloudflare/workers-auth/cf"; @@ -225,6 +226,14 @@ export function setProfile(profile: string): void { auth.setProfile(profile); } +/** Apply cf's global credential-storage preference through the shared auth layer. */ +export function setKeyringPreference(enabled: boolean): void { + setSharedKeyringPreference(enabled, { + logger, + getCredentialStore: () => auth.getCredentialStore(), + }); +} + /** Return the auth profile selected for the current invocation. */ export function getActiveProfile(): string { return auth.getActiveProfile(); From 6b351f5ae2d986e0dffa9bd137fe7faf9d12f329 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 14:31:36 +0100 Subject: [PATCH 15/15] test: support temporary mode in keyring auth mock --- packages/cli/src/__tests__/commands/auth/keyring-login.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts index a15c542647..525cc2624e 100644 --- a/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts +++ b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts @@ -24,6 +24,7 @@ const telemetryEvents = vi.hoisted( vi.mock("@cloudflare/workers-auth/cf", async (importOriginal) => ({ ...(await importOriginal>()), createCfAuth: () => ({ + setTemporaryAllowed: vi.fn(), setProfile: (profile: string) => { sharedAuth.activeProfile = profile; },