Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/temporary-accounts.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 16 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
penalosa marked this conversation as resolved.

`--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 '.[]'`.

Expand All @@ -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

Expand Down
8 changes: 4 additions & 4 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/generator/plugin/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
penalosa marked this conversation as resolved.
}

function parameterFlagName(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const telemetryEvents = vi.hoisted(
vi.mock("@cloudflare/workers-auth/cf", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createCfAuth: () => ({
setTemporaryAllowed: vi.fn(),
setProfile: (profile: string) => {
sharedAuth.activeProfile = profile;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const telemetryEvents = vi.hoisted(
vi.mock("@cloudflare/workers-auth/cf", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createCfAuth: () => ({
setTemporaryAllowed: vi.fn(),
setProfile: (profile: string) => {
sharedAuth.activeProfile = profile;
},
Expand Down
19 changes: 7 additions & 12 deletions packages/cli/src/__tests__/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cwd>/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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
],
},
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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"]);
});
Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/__tests__/lib/auth-token.test.ts
Original file line number Diff line number Diff line change
@@ -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 [
Expand All @@ -15,6 +23,7 @@ beforeEach(() => {
vi.stubEnv(key, undefined);
}
getOAuthToken.mockReset();
isOAuthLoggedIn.mockReset().mockReturnValue(false);
});

describe("getAuthToken", () => {
Expand All @@ -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");
});

Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/__tests__/lib/build-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BuildOutputConfigError,
parseWorkerConfig,
selectBuildOutputWorker,
validateBuildOutputAccount,
validateBuildOutputMode,
} from "../../lib/build-output.js";

Expand Down Expand Up @@ -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<string, string>) {
await seed({
Expand Down
Loading
Loading