diff --git a/.changeset/temporary-accounts.md b/.changeset/temporary-accounts.md new file mode 100644 index 0000000000..64f78bfead --- /dev/null +++ b/.changeset/temporary-accounts.md @@ -0,0 +1,11 @@ +--- +"cf": minor +--- + +Support temporary accounts with `--temporary-account` + +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, authentication checks, and dry runs never create accounts. `cf auth logout` +clears cached temporary accounts. diff --git a/AGENTS.md b/AGENTS.md index aed4fe580c..b90552671f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -664,24 +664,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`. 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. 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 '.[]'`. @@ -700,7 +702,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..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`), `--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. 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. @@ -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 082129c5d9..7404109764 100644 --- a/packages/cli/generator/plugin/args.ts +++ b/packages/cli/generator/plugin/args.ts @@ -262,7 +262,9 @@ 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(kebab(flag)) + ? 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 dbfc51401a..434030db97 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__/commands/auth/keyring-login.test.ts b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts index cfe97909c9..0e93c3ad7d 100644 --- a/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts +++ b/packages/cli/src/__tests__/commands/auth/keyring-login.test.ts @@ -22,6 +22,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__/commands/auth/logout.test.ts b/packages/cli/src/__tests__/commands/auth/logout.test.ts index ee10957d73..c8f33c8be5 100644 --- a/packages/cli/src/__tests__/commands/auth/logout.test.ts +++ b/packages/cli/src/__tests__/commands/auth/logout.test.ts @@ -30,36 +30,31 @@ describe("cf auth logout", () => { ); }); - it("explains how to log in when not logged in", async () => { + it("reports when not logged in", async () => { await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: undefined }); expect(std.out).toBe(""); - expect(std.err).toMatchInlineSnapshot(` - "● You are not currently logged in. To authenticate, run: - - cf auth login" - `); + expect(std.err).toBe("Not logged in, exiting..."); }); it("points at the environment token when only that is set", async () => { await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: "env-token" }); expect(std.out).toBe(""); - expect(std.err).toMatchInlineSnapshot( - `"● No cf OAuth credentials found, but CLOUDFLARE_API_TOKEN is set. Unset it to log out."` + expect(std.err).toContain( + "You are logged in with an API Token. Unset the CLOUDFLARE_API_TOKEN in the environment to log out." ); }); - it("confirms which file the OAuth tokens were removed from", async () => { + it("removes the OAuth tokens and confirms logout", async () => { createDefaultProfile(); std.setStderrTTY(true); await runCf(["auth", "logout"], { CLOUDFLARE_API_TOKEN: undefined }); expect(std.out).toBe(""); - expect(std.err).toContain( - "✓ Removed OAuth tokens from /home/.config/cloudflare/config/default.json" - ); + expect(readAuthConfigFile("default")).toBeUndefined(); + expect(std.err).toContain("Successfully logged out."); }); it("keeps the OAuth tokens while the environment token is set", async () => { diff --git a/packages/cli/src/__tests__/commands/previews-deploy.test.ts b/packages/cli/src/__tests__/commands/previews-deploy.test.ts index dcd52bdb03..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, }; }); 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..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,6 +116,15 @@ describe("deriveArgsFromOp query flags", () => { }); }); + 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( search(), @@ -339,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: [ @@ -423,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__/lib/auth-token.test.ts b/packages/cli/src/__tests__/lib/auth-token.test.ts index 8c5c93daca..6657e57a04 100644 --- a/packages/cli/src/__tests__/lib/auth-token.test.ts +++ b/packages/cli/src/__tests__/lib/auth-token.test.ts @@ -1,9 +1,17 @@ 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() })); -vi.mock("../../lib/oauth/index.js", () => ({ getValidToken: getOAuthToken })); +const { getOAuthToken, isOAuthLoggedIn } = vi.hoisted(() => ({ + getOAuthToken: vi.fn(), + isOAuthLoggedIn: vi.fn(), +})); +vi.mock("../../lib/oauth/index.js", () => ({ + getValidToken: getOAuthToken, + isOAuthLoggedIn, + isTemporaryAccountRequested: () => false, +})); beforeEach(() => { for (const key of [ @@ -15,6 +23,7 @@ beforeEach(() => { vi.stubEnv(key, undefined); } getOAuthToken.mockReset(); + isOAuthLoggedIn.mockReset().mockReturnValue(false); }); describe("getAuthToken", () => { @@ -39,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/build-output.test.ts b/packages/cli/src/__tests__/lib/build-output.test.ts index 5766b63e86..22f84db42c 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( + '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.' + ) + ); + }); + }); + 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 f4ef120f3b..495cafec46 100644 --- a/packages/cli/src/__tests__/lib/context.test.ts +++ b/packages/cli/src/__tests__/lib/context.test.ts @@ -9,18 +9,26 @@ import { getZoneId, resolveAccountIdSilent, } from "../../lib/context.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"; 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: () => ({ getOrSelectAccountId: getOrSelectAccountIdMock, + requireAuth: requireAuthMock, + setTemporaryAllowed: vi.fn(), + requireApiToken: () => ({ apiToken: "temporary-token" }), getActiveAccountId: getActiveAccountIdMock, fetchAllAccounts: fetchAllAccountsMock, + getAPIToken: getAPITokenMock, }), createCfProfileStore: vi.fn(), validateScopeKeys: vi.fn(), @@ -39,9 +47,12 @@ describe("context", () => { beforeEach(() => { vi.restoreAllMocks(); getOrSelectAccountIdMock.mockReset(); + requireAuthMock.mockReset(); + setTemporaryAccountRequested(false); getActiveAccountIdMock.mockReset(); fetchAllAccountsMock.mockReset(); fetchAllAccountsMock.mockResolvedValue([]); + getAPITokenMock.mockReset(); clearLoadedProjectSettings(); vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", undefined); vi.stubEnv("CLOUDFLARE_ZONE_ID", undefined); @@ -49,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"); @@ -70,6 +100,19 @@ describe("context", () => { }); }); + it("uses a temporary account instead of configured accounts", async () => { + writeProjectSettings({ accountId: "settings-acct" }); + setTemporaryAccountRequested(true); + requireAuthMock.mockResolvedValue("temporary-account"); + + await expect(resolveAccountIdSilent()).resolves.toBeUndefined(); + await expect(getAccountId()).resolves.toBe("temporary-account"); + 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..1fa1bdb86e 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,52 @@ describe("handleError", () => { expect(handleError(err)).toBe(err); }); + it("recommends a temporary account when credentials are missing", () => { + handleError(new MissingCredentialsError("No authentication token found.")); + expect(stderr()).toContain("--temporary-account"); + + errSpy.mockClear(); + handleError( + Object.assign( + new UserError("Authentication required; wording may change.", { + telemetryMessage: "cfetch auth login required", + }), + { reason: "no-credentials-non-interactive" } + ) + ); + expect(stderr()).toContain("--temporary-account"); + }); + + it("does not recommend a temporary account when OAuth credentials expire", () => { + errSpy.mockClear(); + handleError( + 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"); + expect(stderr()).not.toContain("--temporary-account"); + }); + + it("does not recommend a temporary account for network or unrelated errors", () => { + 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" } + ) + ); + 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..9905e5b027 --- /dev/null +++ b/packages/cli/src/__tests__/temporary-accounts.test.ts @@ -0,0 +1,589 @@ +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +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 { + readAuthCredentials, + requireTemporaryAccount, + setTemporaryAccountRequested, +} 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(() => {}); + 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("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; + 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("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"); + setTemporaryAccountRequested(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"); + vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); + setTemporaryAccountRequested(true); + try { + await expect(getAuthToken()).resolves.toBe(apiToken); + await expect(getAccountId()).resolves.toBe(accountId); + } finally { + setTemporaryAccountRequested(false); + } + }); + + 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 }); + } + + 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); + } + ); + }); + + 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( + "This command's dry run cannot be used with --temporary-account. Remove --dry-run to run the command for real." + ); + + 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( + 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.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(); + 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 stderr = captureStderr(); + + await runCf(["auth", "logout"], env); + + expect(existsSync(cachePath())).toBe(false); + expect(stderr()).toContain("Cleared temporary preview 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)", + 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/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( + 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 8a3a2f3de9..62fb9320f6 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -1,12 +1,4 @@ -import { getAuthFromEnv } from "@cloudflare/workers-auth"; -import { - getActiveProfile, - getConfigPath, - isOAuthLoggedIn, - logout, - setProfile, -} from "../../lib/oauth/index.js"; -import { printSuccess } from "../../lib/output.js"; +import { getActiveProfile, logout } from "../../lib/oauth/index.js"; import { theme } from "../../lib/ui/index.js"; import { log, printCommands } from "../../lib/ui/log.js"; import { assertNoProfileFlag } from "./profiles.js"; @@ -33,33 +25,7 @@ const logoutCommand: CommandModule = { printCommands(`cf auth delete ${activeProfile}`); } - setProfile("default"); - - const hasOAuthTokens = isOAuthLoggedIn(); - const hasEnvToken = - getAuthFromEnv({ allowGlobalAuthKey: false }) !== undefined; - - if (!hasOAuthTokens) { - if (hasEnvToken) { - log.info( - `No cf OAuth credentials found, but ${theme.code("CLOUDFLARE_API_TOKEN")} is set. Unset it to log out.` - ); - } else { - log.info("You are not currently logged in. To authenticate, run:"); - printCommands("cf auth login"); - } - return; - } - await logout("default"); - - // workers-auth keeps the stored tokens, and says why, while env - // credentials are set. - if (hasEnvToken) { - return; - } - - printSuccess(`Removed OAuth tokens from ${theme.muted(getConfigPath())}`); }, }; diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 52c1ce3253..6c0abfdd88 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, @@ -5,19 +6,20 @@ import { readAuthCredentials, } from "../../lib/oauth/index.js"; import { formatOutput } from "../../lib/output.js"; -import type { ArgumentsCamelCase, Argv, CommandModule } from "yargs"; +import { hint } from "../../lib/ui/index.js"; +import type { CommandModule } from "yargs"; -interface WhoamiArgs {} - -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): Promise => { + if (argv["temporary-account"] || argv["event-code"] !== undefined) { + throw new Error( + "--temporary-account and --event-code are not supported by cf auth whoami." + ); + } - handler: async (_argv: ArgumentsCamelCase): Promise => { // Determine the authentication source. const envToken = process.env.CLOUDFLARE_API_TOKEN; const stored = readAuthCredentials(); @@ -37,6 +39,13 @@ const whoamiCommand: CommandModule = { token = await getAuthToken(); } catch { formatOutput({ authenticated: false, error: "Not logged in" }); + if (authSource === "none" && detectAgentContext().isAgentic) { + console.error( + hint( + '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`.' + ) + ); + } return; } diff --git a/packages/cli/src/commands/deploy/shared.ts b/packages/cli/src/commands/deploy/shared.ts index e401b261b7..7fbea4cfc9 100644 --- a/packages/cli/src/commands/deploy/shared.ts +++ b/packages/cli/src/commands/deploy/shared.ts @@ -15,14 +15,14 @@ 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, selectBuildOutputWorker, + 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 { @@ -130,6 +130,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( @@ -137,17 +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 authToken = argv["dry-run"] ? "" : await getAuthToken(); - const accountId = argv["dry-run"] - ? undefined - : (rootConfig.accountId ?? - (await getAccountId({ - skipProjectSettings: true, - complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - }))); + const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); + 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 f34ae51ef9..ca1ead50eb 100644 --- a/packages/cli/src/commands/previews/deploy.ts +++ b/packages/cli/src/commands/previews/deploy.ts @@ -15,15 +15,15 @@ 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, parseWorkerConfig, selectBuildOutputWorker, + 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"; @@ -102,20 +102,22 @@ 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"] + ); const worker = selectBuildOutputWorker(output.workers, argv.worker); const { wranglerConfig, builtConfig } = parseWorkerConfig( worker, output.rootConfig ); - const authToken = await getAuthToken(); - const accountId = - output.rootConfig.accountId ?? - (await getAccountId({ - isPreview: true, - skipProjectSettings: true, - complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - })); + const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); + 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 e63d82272e..8d9c97da58 100644 --- a/packages/cli/src/commands/workers/triggers/deploy.ts +++ b/packages/cli/src/commands/workers/triggers/deploy.ts @@ -5,14 +5,14 @@ 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, selectBuildOutputWorker, + 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"; @@ -87,18 +87,20 @@ 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 accountId = argv["dry-run"] - ? undefined - : (rootConfig.accountId ?? - (await getAccountId({ - skipProjectSettings: true, - complianceRegion: getCloudflareComplianceRegion(wranglerConfig), - }))); + const complianceRegion = getCloudflareComplianceRegion(wranglerConfig); + 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 51499a2858..e064827b09 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -74,6 +74,33 @@ 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: + "Specify the event code to use when creating 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 +524,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 +649,15 @@ export function buildCli(rawArgs: string[], options: BuildCliOptions = {}) { throw new Error("--persist-to can only be used with --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 && !argv["temporary-account"]) { + throw new Error("--event-code requires --temporary-account."); + } + if ( resolvedCommand !== undefined && shouldApplyCloudflareDotEnv(resolvedCommand, argv.local) @@ -630,7 +668,12 @@ 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, setTemporaryAccountRequested } = + await import("./lib/oauth/index.js"); + setTemporaryAccountRequested( + argv["temporary-account"], + eventCode === undefined ? undefined : { eventCode } + ); const profile = resolveProfile(argv.profile); printActiveProfileLine(profile); } diff --git a/packages/cli/src/lib/auth-token.ts b/packages/cli/src/lib/auth-token.ts index 68b572ec9e..60b3e66e92 100644 --- a/packages/cli/src/lib/auth-token.ts +++ b/packages/cli/src/lib/auth-token.ts @@ -1,17 +1,31 @@ import { getAuthFromEnv } from "@cloudflare/workers-auth"; -import { getValidToken as getOAuthToken } from "./oauth/index.js"; +import { MissingCredentialsError } from "./errors.js"; +import { + getValidToken as getOAuthToken, + isOAuthLoggedIn, + isTemporaryAccountRequested, + requireTemporaryAccount, +} 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 { +export async function getAuthToken( + options?: Parameters[0] +): Promise { + if (isTemporaryAccountRequested()) { + return (await requireTemporaryAccount(options)).apiToken; + } + // Environment credential (scoped API token only). const envAuth = getAuthFromEnv({ allowGlobalAuthKey: false }); if (envAuth && "apiToken" in envAuth) { @@ -23,14 +37,17 @@ export async function getAuthToken(): Promise { 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 Error( - `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` - ); +For API tokens, visit: https://dash.cloudflare.com/profile/api-tokens`); } diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 8a98ec5842..8e2e2956bf 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -3,6 +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 { 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"; @@ -285,6 +286,10 @@ export function createCloudflareClientWithToken( export async function resolveAccountIdForHelp( deadline: number ): Promise { + if (isTemporaryAccountRequested()) { + return undefined; + } + const stored = await resolveAccountIdSilent(); if (stored) { return stored; @@ -362,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. */ @@ -417,6 +423,12 @@ const LOCAL_MODE_PLACEHOLDER_TOKEN = "cf-local-mode-no-auth"; export async function createCommandClient( args: CommandClientArgs ): Promise { + if (args.dryRun && isTemporaryAccountRequested()) { + throw new Error( + "This command's dry run cannot be used with --temporary-account. Remove --dry-run to run the command for real." + ); + } + const baseURL = getCloudflareApiBaseUrl({ compliance_region: await getComplianceRegion(), }); 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 2aed7a0d66..f8bbb1c7d8 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( + `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.` + ); + } +} + /** Selects a Build Output Worker for every workflow that consumes one. */ export const buildOutputWorkerOption = { type: "string", 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..2ea50f1878 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,20 @@ 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, isTemporaryAccountRequested } = + await import("./oauth/index.js"); + // Configured accounts don't apply to a temporary account, and provisioning + // one is not silent. + if (isTemporaryAccountRequested()) { + return undefined; + } 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 +106,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 +116,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 { + getOrSelectAccountId, + isTemporaryAccountRequested, + requireTemporaryAccount, + } = await import("./oauth/index.js"); + if (isTemporaryAccountRequested()) { + return (await requireTemporaryAccount(options)).accountId; + } + 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..68c0e06fbc 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 {} + +/** + * 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) { + return true; + } + // Structural matching also works when the shared package is bundled separately. + return ( + "telemetryMessage" in error && + error.telemetryMessage === "cfetch auth login required" && + "reason" in error && + error.reason === "no-credentials-non-interactive" + ); +} + /** * 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 claim URL." + ) + ); + } return error; } diff --git a/packages/cli/src/lib/oauth/index.ts b/packages/cli/src/lib/oauth/index.ts index bd16666d68..0464d2f2fa 100644 --- a/packages/cli/src/lib/oauth/index.ts +++ b/packages/cli/src/lib/oauth/index.ts @@ -18,9 +18,11 @@ 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"; +import type { TemporaryAccountRequest } from "@cloudflare/workers-auth"; const logger: AuthContext["logger"] = { debug: (...args) => { @@ -85,16 +87,84 @@ const auth = createCfAuth({ error instanceof NonInteractiveSelectionError, }); +let temporaryAccountRequested = false; + +/** + * 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 setTemporaryAccountRequested( + requested: boolean, + request?: TemporaryAccountRequest +): void { + temporaryAccountRequested = requested; + auth.setTemporaryAllowed(requested, request); +} + +/** 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 (!isTemporaryAccountRequested()) { + throw new Error("Temporary accounts require --temporary-account."); + } + + try { + const accountId = await auth.requireAuth({ + compliance_region: + options?.complianceRegion ?? (await getComplianceRegion(options)), + }); + 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. + 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, 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; + } } /** @@ -143,7 +213,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/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..1c2b293ae1 100644 --- a/packages/wrangler-tests/README.md +++ b/packages/wrangler-tests/README.md @@ -17,9 +17,9 @@ 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. +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 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..998e355181 --- /dev/null +++ b/packages/wrangler-tests/src/__tests__/deploy/core.test.ts @@ -0,0 +1,529 @@ +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`) 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(); + 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:"); + }); + + // 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( + 'Temporary accounts can\'t be used when a specific account ID ("configured-account-id") is set in your config file.' + ); + + 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, + }) => { + 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(accountId?: string): Promise { + await seed({ + ".cloudflare/output/v0/config.json": JSON.stringify({ + buildContext: { isPreview: false }, + accountId, + }), + ".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..43591b264a --- /dev/null +++ b/packages/wrangler-tests/src/__tests__/helpers/mock-temporary-account.ts @@ -0,0 +1,108 @@ +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. + */ +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. + */ +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. + * Records creation request bodies and headers 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..3b11f0af95 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,31 @@ 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", () => { + // 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"); }); describe("invalid command", () => { diff --git a/packages/wrangler-tests/src/__tests__/logout.test.ts b/packages/wrangler-tests/src/__tests__/logout.test.ts index f9726313e4..ed595da989 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"; @@ -27,26 +31,38 @@ function cfAuthFilePath(): string { describe("logout", () => { runInTempDir(); const std = mockConsoleMethods(); - // The clack mock drops log lines, so cf's own messages can't be asserted - // here. The cf package's auth tests check them with real clack. - // 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).toBe(""); + expect(std.err).toContain("Not logged in, exiting..."); + }); + + 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).toBe(""); + 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 on stderr 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).toBe(""); + expect(std.err).toContain( + "You are logged in with an API Token. Unset the CLOUDFLARE_API_TOKEN in the environment to log out." + ); }); // Ported now that token paths resolve via `homedir()` at call time @@ -83,7 +99,10 @@ describe("logout", () => { }) ); + writeTemporaryAccountCache({ account: {}, claim: {} }); + expect(existsSync(authFile)).toBe(true); + expect(existsSync(temporaryAccountCachePath())).toBe(true); await runWrangler("auth logout", { CLOUDFLARE_API_TOKEN: undefined }); @@ -93,6 +112,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..b892500409 100644 --- a/packages/wrangler-tests/src/__tests__/whoami.test.ts +++ b/packages/wrangler-tests/src/__tests__/whoami.test.ts @@ -83,6 +83,25 @@ describe("whoami", () => { }); }); + it("should suggest a temporary preview account when not authenticated", async ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); + vi.stubEnv("CLAUDECODE", "1"); + await runWrangler("auth whoami"); + expect(JSON.parse(std.out)).toMatchObject({ authenticated: false }); + // cf gives agents instructions on stderr so stdout stays parseable JSON. + expect(std.err).toContain( + '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`" + ); + }); + it("should output JSON with API token auth type", async ({ expect }) => { vi.stubEnv("CLOUDFLARE_API_TOKEN", "123456789"); await runWrangler("auth whoami"); 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,