diff --git a/.changeset/bundle-private-deploy-helpers-deps.md b/.changeset/bundle-private-deploy-helpers-deps.md new file mode 100644 index 0000000000..b0b6df960f --- /dev/null +++ b/.changeset/bundle-private-deploy-helpers-deps.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/deploy-helpers": patch +--- + +Bundle private internal dependencies in deploy helpers + +`@cloudflare/deploy-helpers` no longer declares private workspace packages as runtime dependencies, so installing the package from npm does not require unpublished internal packages. diff --git a/.changeset/graduate-autoconfig-stable.md b/.changeset/graduate-autoconfig-stable.md new file mode 100644 index 0000000000..0f6c26675d --- /dev/null +++ b/.changeset/graduate-autoconfig-stable.md @@ -0,0 +1,9 @@ +--- +"wrangler": minor +--- + +Graduate autoconfig from experimental to stable + +The `--experimental-autoconfig` and `--x-autoconfig` deploy CLI flags have been replaced with `--autoconfig`. + +Note that the `--autoconfig` flag defaults to `true` and that it can be used to disable Wrangler's auto-configuration logic by setting it to `false` via `--autoconfig=false` or `--no-autoconfig` diff --git a/.changeset/suggest-skills-after-commands.md b/.changeset/suggest-skills-after-commands.md new file mode 100644 index 0000000000..c6a3f529f2 --- /dev/null +++ b/.changeset/suggest-skills-after-commands.md @@ -0,0 +1,9 @@ +--- +"wrangler": minor +--- + +Suggest Cloudflare skills installation after commands instead of before + +The automatic prompt to install Cloudflare skills for detected AI coding agents no longer runs before every Wrangler command. Instead, Wrangler now suggests installing skills, when appropriate, after some commands complete successfully. Commands that output JSON suppress the suggestion to keep their output clean. The `--install-skills` flag remains available on all commands to explicitly run the skills installation flow before the command executes, without prompting. + +As before, Wrangler asks the skills installation question at most once. The skills install metadata file is now written before the confirmation prompt is shown, so even if the user interrupts the process (e.g. CTRL+C, closing the terminal) during the prompt, the question is recorded as unanswered and will not reappear on subsequent runs. diff --git a/.changeset/temporary-preview-accounts.md b/.changeset/temporary-preview-accounts.md new file mode 100644 index 0000000000..a8778d3767 --- /dev/null +++ b/.changeset/temporary-preview-accounts.md @@ -0,0 +1,8 @@ +--- +"wrangler": minor +"@cloudflare/workers-auth": minor +--- + +Add a `--temporary` flag that creates and uses a temporary Cloudflare preview account when you have no credentials, instead of starting the OAuth login flow. + +It's registered only on the commands the short-lived account token can serve — Workers (`deploy`, `versions upload`, and related commands), KV, D1, Hyperdrive, Queues, and certificate commands — and is for unauthenticated use only: passing it while already authenticated (OAuth, `CLOUDFLARE_API_TOKEN`, or a global API key) errors rather than silently ignoring the flag. Before provisioning, Wrangler handles Cloudflare's Terms of Service and Privacy Policy (interactive terminals prompt for `yes`; non-interactive shells print a notice and continue). Wrangler then runs with the short-lived token and prints a claim URL so the account can be claimed before it expires. The cached account is cleared on successful login or logout. diff --git a/.changeset/tidy-clouds-config.md b/.changeset/tidy-clouds-config.md new file mode 100644 index 0000000000..8bbbb35fc1 --- /dev/null +++ b/.changeset/tidy-clouds-config.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/deploy-helpers": minor +--- + +Expose Cloudflare config conversion utilities from deploy helpers + +`@cloudflare/deploy-helpers` now re-exports `ConfigSchema` and `convertToWranglerConfig` from the internal `@cloudflare/config` package, so consumers can parse and convert Cloudflare config files without depending on the unpublished package directly. diff --git a/packages/config/tsdown.config.ts b/packages/config/tsdown.config.ts index 8892471379..3a4a9d4f91 100644 --- a/packages/config/tsdown.config.ts +++ b/packages/config/tsdown.config.ts @@ -9,4 +9,8 @@ export default defineConfig({ outDir: "dist", dts: true, tsconfig: "tsconfig.json", + // Keep zod external so consumers that bundle this package (wrangler, + // deploy-helpers, vite-plugin) share a single zod copy instead of inlining + // one per consumed entry point. + external: [/^zod(\/.*)?$/], }); diff --git a/packages/deploy-helpers/package.json b/packages/deploy-helpers/package.json index 4ea4e1ca94..fdad76607c 100644 --- a/packages/deploy-helpers/package.json +++ b/packages/deploy-helpers/package.json @@ -38,8 +38,6 @@ }, "dependencies": { "@cloudflare/cli-shared-helpers": "workspace:*", - "@cloudflare/containers-shared": "workspace:*", - "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-utils": "workspace:*", "blake3-wasm": "2.1.5", "chalk": "catalog:default", @@ -51,6 +49,9 @@ "undici": "catalog:default" }, "devDependencies": { + "@cloudflare/config": "workspace:*", + "@cloudflare/containers-shared": "workspace:*", + "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cspotcode/source-map-support": "0.8.1", "@types/command-exists": "^1.2.0", @@ -63,7 +64,16 @@ "ts-dedent": "^2.2.0", "tsup": "8.3.0", "typescript": "catalog:default", - "vitest": "catalog:default" + "vitest": "catalog:default", + "zod": "^4.4.3" + }, + "peerDependencies": { + "zod": "^4.4.3" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } }, "volta": { "extends": "../../package.json" diff --git a/packages/deploy-helpers/scripts/deps.ts b/packages/deploy-helpers/scripts/deps.ts index 709957e156..125466ebbd 100644 --- a/packages/deploy-helpers/scripts/deps.ts +++ b/packages/deploy-helpers/scripts/deps.ts @@ -8,9 +8,7 @@ export const EXTERNAL_DEPENDENCIES = [ // Workspace packages kept external so consumers share a single copy of // types and runtime code (e.g. ParseError instanceof checks). "@cloudflare/cli-shared-helpers", - "@cloudflare/containers-shared", "@cloudflare/workers-utils", - "@cloudflare/workers-shared", "miniflare", // These are externalized to avoid duplication in wrangler's bundle, @@ -22,4 +20,9 @@ export const EXTERNAL_DEPENDENCIES = [ "p-queue", "pretty-bytes", "undici", + + // Externalized so wrangler bundles a single shared zod copy instead of + // inlining one via this package. Declared as a peerDependency (the + // consumer provides zod). + "zod", ]; diff --git a/packages/deploy-helpers/src/config.ts b/packages/deploy-helpers/src/config.ts new file mode 100644 index 0000000000..8af0fec85e --- /dev/null +++ b/packages/deploy-helpers/src/config.ts @@ -0,0 +1,2 @@ +import { ConfigSchema, convertToWranglerConfig } from "@cloudflare/config"; +export { ConfigSchema, convertToWranglerConfig }; diff --git a/packages/deploy-helpers/src/index.ts b/packages/deploy-helpers/src/index.ts index a550c25e13..ff8a37e85c 100644 --- a/packages/deploy-helpers/src/index.ts +++ b/packages/deploy-helpers/src/index.ts @@ -1,4 +1,5 @@ export * from "./shared/types"; +export { ConfigSchema, convertToWranglerConfig } from "./config"; export { initDeployHelpersContext } from "./shared/context"; export { default as deploy } from "./deploy/deploy"; export type { DeployCallbacks } from "./deploy/deploy"; diff --git a/packages/deploy-helpers/tsup.config.ts b/packages/deploy-helpers/tsup.config.ts index a6cb0dcb92..f1c8fb887b 100644 --- a/packages/deploy-helpers/tsup.config.ts +++ b/packages/deploy-helpers/tsup.config.ts @@ -19,6 +19,11 @@ export default defineConfig(() => [ tsconfig: "tsconfig.json", metafile: true, sourcemap: process.env.SOURCEMAPS !== "false", + noExternal: [ + "@cloudflare/config", + "@cloudflare/containers-shared", + /^@cloudflare\/workers-shared(\/.*)?$/, + ], external: [ /^@cloudflare\//, "blake3-wasm", @@ -30,6 +35,9 @@ export default defineConfig(() => [ "dotenv", "command-exists", "esbuild", + // Keep zod external so wrangler (the only consumer) bundles a single + // shared copy rather than inlining one here. + /^zod(\/.*)?$/, ], }, ]); diff --git a/packages/workers-auth/src/auth-config-file.ts b/packages/workers-auth/src/auth-config-file.ts deleted file mode 100644 index d83460f304..0000000000 --- a/packages/workers-auth/src/auth-config-file.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * The data that may be read from the on-disk user auth config file. - */ -export interface UserAuthConfig { - oauth_token?: string; - refresh_token?: string; - expiration_time?: string; - scopes?: string[]; - /** @deprecated - this field was only provided by the deprecated v1 `wrangler config` command. */ - api_token?: string; -} - -/** - * Pluggable persistence for the user auth config. - * - * This package does not ship a default implementation — the consumer injects - * one via {@link OAuthFlowContext.storage} (and into {@link getAPIToken} / - * {@link readStoredAuthState}). Wrangler's default reads/writes a TOML file - * under the global Wrangler config directory; other CLIs can use a different - * location and/or serialization format (e.g. a JSONC file under a different - * CLI's XDG config directory). - */ -export interface AuthConfigStorage { - /** - * Read and parse the stored auth config. - * @throws if the backing store is missing or cannot be parsed. Callers treat - * a throw as "not logged in via local OAuth". - */ - read(): UserAuthConfig; - /** Serialize and persist the auth config. */ - write(config: UserAuthConfig): void; - /** Remove the backing store (used on logout). */ - clear(): void; - /** Human-readable location of the backing store, for display and warnings. */ - path(): string; -} diff --git a/packages/workers-auth/src/config-file/auth.ts b/packages/workers-auth/src/config-file/auth.ts new file mode 100644 index 0000000000..2b4e7123e6 --- /dev/null +++ b/packages/workers-auth/src/config-file/auth.ts @@ -0,0 +1,15 @@ +import type { ConfigStorage } from "."; + +/** + * The data that may be read from the on-disk user auth config file. + */ +export interface UserAuthConfig { + oauth_token?: string; + refresh_token?: string; + expiration_time?: string; + scopes?: string[]; + /** @deprecated - this field was only provided by the deprecated v1 `wrangler config` command. */ + api_token?: string; +} + +export type AuthConfigStorage = ConfigStorage; diff --git a/packages/workers-auth/src/config-file/index.ts b/packages/workers-auth/src/config-file/index.ts new file mode 100644 index 0000000000..ef48032625 --- /dev/null +++ b/packages/workers-auth/src/config-file/index.ts @@ -0,0 +1,16 @@ +/** + * Pluggable persistence for a typed config blob + */ +export interface ConfigStorage { + /** + * Read and parse the stored config. + * @throws if the backing store is missing or cannot be parsed. + */ + read(): T; + /** Serialize and persist the config. */ + write(config: T): void; + /** Remove the backing store; returns whether anything existed beforehand. */ + clear(): boolean; + /** Human-readable location of the backing store, for display and warnings. */ + path(): string; +} diff --git a/packages/workers-auth/src/config-file/temporary.ts b/packages/workers-auth/src/config-file/temporary.ts new file mode 100644 index 0000000000..880ca2edd8 --- /dev/null +++ b/packages/workers-auth/src/config-file/temporary.ts @@ -0,0 +1,19 @@ +import type { ConfigStorage } from "."; + +/** + * A short-lived "temporary preview account" + */ +export type TemporaryPreviewAccount = { + account: { + id: string; + name: string; + apiToken: string; + expiresAt: string; + }; + claim: { + url: string; + expiresAt: string; + }; +}; + +export type TemporaryAccountStorage = ConfigStorage; diff --git a/packages/workers-auth/src/context.ts b/packages/workers-auth/src/context.ts index b1c66348b1..f581a47e67 100644 --- a/packages/workers-auth/src/context.ts +++ b/packages/workers-auth/src/context.ts @@ -1,7 +1,25 @@ -import type { AuthConfigStorage } from "./auth-config-file"; +import type { AuthConfigStorage } from "./config-file/auth"; +import type { TemporaryAccountStorage } from "./config-file/temporary"; import type { generateAuthUrl as defaultGenerateAuthUrl } from "./generate-auth-url"; import type { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; +/** + * The dependencies the OAuth flow needs to mint/reuse a short-lived "temporary + * preview account" + */ +export interface OAuthFlowTemporaryContext { + /** Persistence backend for the cached temporary preview account. */ + storage: TemporaryAccountStorage; + /** + * Hook to customise the terms-acceptance interactive prompt + * - question: the question to ask a user in interactive mode. + * return answer === "yes" (must be the literal string) + * - notice: the notice to print on stderr if in non-interactive mode + * always return true + */ + prompt: (question: string, notice: string) => Promise; +} + /** * The branded OAuth consent pages the provider redirects the browser to after * the user grants or denies consent. @@ -90,6 +108,18 @@ export interface OAuthFlowContext { */ storage: AuthConfigStorage; + /** + * Whether the flow's credential resolvers (`getAPIToken` / `requireApiToken`) + * should honour the global API key + email pair in addition to scoped API + * tokens. + */ + allowGlobalAuthKey: boolean; + + /** + * Dependencies for minting/reusing a temporary preview account. + */ + temporary: OAuthFlowTemporaryContext | undefined; + /** * Override the OAuth authorize URL generator. Used by tests to produce a * deterministic URL for snapshot testing. Defaults to the standard diff --git a/packages/workers-auth/src/credentials.ts b/packages/workers-auth/src/credentials.ts index 79efa25c22..b90912a099 100644 --- a/packages/workers-auth/src/credentials.ts +++ b/packages/workers-auth/src/credentials.ts @@ -2,8 +2,8 @@ import { getEnvironmentVariableFactory, UserError, } from "@cloudflare/workers-utils"; -import { type AuthConfigStorage } from "./auth-config-file"; import { readStoredAuthState } from "./state"; +import type { AuthConfigStorage } from "./config-file/auth"; import type { OAuthFlowLogger } from "./context"; import type { ApiCredentials } from "@cloudflare/workers-utils"; diff --git a/packages/workers-auth/src/flow.ts b/packages/workers-auth/src/flow.ts index 2336920415..ed8d3901e9 100644 --- a/packages/workers-auth/src/flow.ts +++ b/packages/workers-auth/src/flow.ts @@ -19,13 +19,19 @@ import { import dedent from "ts-dedent"; import { fetch } from "undici"; import { getOauthToken } from "./callback-server"; +import { getAPIToken, requireApiToken } from "./credentials"; import { getRevokeUrlFromEnv } from "./env-vars"; import { generateAuthUrl as defaultGenerateAuthUrl } from "./generate-auth-url"; import { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; import { readStoredAuthState, type OAuthFlowState } from "./state"; +import { getOrCreateTemporaryPreviewAccount } from "./temporary"; import { exchangeRefreshTokenForAccessToken } from "./token-exchange"; +import type { TemporaryPreviewAccount } from "./config-file/temporary"; import type { OAuthFlowContext } from "./context"; -import type { ComplianceConfig } from "@cloudflare/workers-utils"; +import type { + ApiCredentials, + ComplianceConfig, +} from "@cloudflare/workers-utils"; /** * Reason why {@link OAuthFlowAPI.loginOrRefreshIfRequired} could not @@ -124,18 +130,50 @@ export interface OAuthFlowAPI { getOAuthTokenFromLocalState(): Promise; /** - * Whether the stored OAuth access token has expired and a refresh is - * required before it can be used. Returns `false` when env credentials are - * present (per `ctx.hasEnvCredentials`), because the stored OAuth state is - * not consulted in that case. + * Resolve API credentials, preferring an active temporary preview account + * (when one has been latched via {@link activateTemporaryAccount}) over the + * env / stored-OAuth resolution performed by the shared credential resolver. + * + * Returns `undefined` when no credentials are available. + */ + getAPIToken(): ApiCredentials | undefined; + + /** + * Like {@link getAPIToken}, but throws a `UserError` when no credentials are + * available. + */ + requireApiToken(): ApiCredentials; + + /** + * Establish whether `--temporary` is permitted for this invocation. Called + * once at command dispatch by the consumer. Also drops any temporary account + * latched by a previous dispatch, so that — when multiple commands share a + * process (e.g. in tests) — each invocation starts a fresh temporary session. + * No-op when the flow was created without a `temporary` context. + */ + setTemporaryAllowed(allowed: boolean): void; + + /** + * Whether `--temporary` is permitted for this invocation (see + * {@link setTemporaryAllowed}). Always `false` without a `temporary` context. + */ + isTemporaryAllowed(): boolean; + + /** + * The temporary preview account latched for this invocation, or `undefined`. + * Only set after {@link activateTemporaryAccount} has run. */ - isRefreshNeeded(): boolean; + getActiveTemporaryAccount(): TemporaryPreviewAccount | undefined; /** - * Trigger an OAuth refresh-token rotation. Persists the new access/refresh - * tokens to disk on success. Returns `false` on any failure. + * The sole creator of the temporary-account latch: mint a fresh temporary + * preview account (or reuse a cached one), latch it for this invocation, and + * return it. Requires a `temporary` context. */ - refreshToken(): Promise; + activateTemporaryAccount(): Promise<{ + account: TemporaryPreviewAccount; + cached: boolean; + }>; } /** @@ -157,6 +195,9 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI { typeof ctx.clientId === "function" ? ctx.clientId() : ctx.clientId; const consent = ctx.consent; + let temporaryAllowed = false; + let activeTemporaryAccount: TemporaryPreviewAccount | undefined; + const redirectUrl = new URL(ctx.redirectUri); const defaultCallbackHost = redirectUrl.hostname; const defaultCallbackPort = Number(redirectUrl.port); @@ -217,6 +258,7 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI { ctx.logger.log(`Successfully logged in.`); + clearTemporaryAccount(); ctx.purgeOnLoginOrLogout?.(); return true; @@ -321,6 +363,8 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI { } async function logout(): Promise { + const clearedTemporary = clearTemporaryAccount(); + if (ctx.hasEnvCredentials()) { // Env credentials override any login details, so we cannot log out. ctx.logger.log( @@ -335,7 +379,11 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI { storage, }).refreshToken; if (!storedRefreshToken) { - ctx.logger.log("Not logged in, exiting..."); + ctx.logger.log( + clearedTemporary + ? "Cleared temporary preview account." + : "Not logged in, exiting..." + ); return; } @@ -383,12 +431,77 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI { return stored.accessToken?.value; } + function getAPITokenInternal(): ApiCredentials | undefined { + if (activeTemporaryAccount) { + return { apiToken: activeTemporaryAccount.account.apiToken }; + } + + return getAPIToken({ + storage, + warningLogger: ctx.logger, + allowGlobalAuthKey: ctx.allowGlobalAuthKey, + }); + } + + function requireApiTokenInternal(): ApiCredentials { + if (activeTemporaryAccount) { + return { apiToken: activeTemporaryAccount.account.apiToken }; + } + + return requireApiToken({ + storage, + warningLogger: ctx.logger, + allowGlobalAuthKey: ctx.allowGlobalAuthKey, + }); + } + + function setTemporaryAllowed(allowed: boolean): void { + temporaryAllowed = allowed && ctx.temporary !== undefined; + activeTemporaryAccount = undefined; + } + + function isTemporaryAllowed(): boolean { + return temporaryAllowed; + } + + function getActiveTemporaryAccount(): TemporaryPreviewAccount | undefined { + return activeTemporaryAccount; + } + + async function activateTemporaryAccount(): Promise<{ + account: TemporaryPreviewAccount; + cached: boolean; + }> { + if (!ctx.temporary) { + throw new UserError( + "Temporary preview accounts are not supported by this CLI.", + { telemetryMessage: "user temporary account unsupported" } + ); + } + + const result = await getOrCreateTemporaryPreviewAccount({ + ...ctx.temporary, + logger: ctx.logger, + }); + activeTemporaryAccount = result.account; + return result; + } + + function clearTemporaryAccount(): boolean { + activeTemporaryAccount = undefined; + return ctx.temporary?.storage.clear() ?? false; + } + return { login, logout, loginOrRefreshIfRequired, getOAuthTokenFromLocalState, - isRefreshNeeded, - refreshToken, + getAPIToken: getAPITokenInternal, + requireApiToken: requireApiTokenInternal, + setTemporaryAllowed, + isTemporaryAllowed, + getActiveTemporaryAccount, + activateTemporaryAccount, }; } diff --git a/packages/workers-auth/src/index.ts b/packages/workers-auth/src/index.ts index 1a5d5055cb..b4a480e906 100644 --- a/packages/workers-auth/src/index.ts +++ b/packages/workers-auth/src/index.ts @@ -6,88 +6,37 @@ // `getAPIToken` resolver), or to inject deterministic implementations into // tests. -export type { AuthConfigStorage, UserAuthConfig } from "./auth-config-file"; +export type { ConfigStorage } from "./config-file"; +export type { AuthConfigStorage, UserAuthConfig } from "./config-file/auth"; export { - getAPIToken, getAuthFromEnv, getCloudflareAPITokenFromEnv, getCloudflareGlobalAuthEmailFromEnv, getCloudflareGlobalAuthKeyFromEnv, - requireApiToken, } from "./credentials"; -export type { GetAPITokenOptions, GetAuthFromEnvOptions } from "./credentials"; export { clearAccessCaches, domainUsesAccess, getAccessHeaders, - getCloudflareAccessHeaders, } from "./access"; -export type { - OAuthConsentPages, - OAuthFlowContext, - OAuthFlowLogger, -} from "./context"; - -export { - getAccessClientIdFromEnv, - getAccessClientSecretFromEnv, - getAuthDomainFromEnv, - getAuthUrlFromEnv, - getCfAuthorizationTokenFromEnv, - getRevokeUrlFromEnv, - getTokenUrlFromEnv, -} from "./env-vars"; - -export { - ErrorAccessDenied, - ErrorAccessTokenResponse, - ErrorAuthenticationGrant, - ErrorInvalidClient, - ErrorInvalidGrant, - ErrorInvalidJson, - ErrorInvalidRequest, - ErrorInvalidReturnedStateParam, - ErrorInvalidScope, - ErrorInvalidToken, - ErrorNoAuthCode, - ErrorOAuth2, - ErrorServerError, - ErrorTemporarilyUnavailable, - ErrorUnauthorizedClient, - ErrorUnknown, - ErrorUnsupportedGrantType, - ErrorUnsupportedResponseType, - toErrorClass, -} from "./errors"; +export { getAuthUrlFromEnv } from "./env-vars"; export { createOAuthFlow } from "./flow"; export type { LoginOrRefreshFailureReason, LoginOrRefreshResult, LoginProps, - OAuthFlowAPI, } from "./flow"; export { generateAuthUrl } from "./generate-auth-url"; export { generateRandomState } from "./generate-random-state"; - -export { - base64urlEncode, - generatePKCECodes, - PKCE_CHARSET, - RECOMMENDED_CODE_VERIFIER_LENGTH, - RECOMMENDED_STATE_LENGTH, -} from "./pkce"; -export type { PKCECodes } from "./pkce"; +export { TEMPORARY_TERMS_NOTICE, TEMPORARY_TERMS_PROMPT } from "./temporary"; +export { PKCE_CHARSET } from "./pkce"; export { readStoredAuthState } from "./state"; -export type { - AccessToken, - OAuthFlowState, - RefreshToken, - StoredAuthState, -} from "./state"; + +export type { TemporaryPreviewAccount } from "./config-file/temporary"; diff --git a/packages/workers-auth/src/pow.ts b/packages/workers-auth/src/pow.ts new file mode 100644 index 0000000000..f0b45f8169 --- /dev/null +++ b/packages/workers-auth/src/pow.ts @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; + +export const POW_PROTOCOL_VERSION = 1; + +// Upper bound on the total work (k*g hashes) we'll solve. Solve time is +// proportional to k*g, so bounding the product caps it regardless of how the +// server splits difficulty. The provisioning service tops out around k=8000, +// g=2000 (16M); this leaves headroom for a legitimate server-side bump while +// still bounding a buggy or hostile challenge to a finite solve. +export const POW_MAX_ITERATIONS = 64_000_000; + +export interface PowChallenge { + challengeToken: string; + seed: string; + k: number; + g: number; +} + +export interface PowSolution { + challengeToken: string; + solution: { checkpoints: string }; +} + +// Sequential SHA-256 chain (spec §5.2): h0 = SHA256(seed), then k segments of g +// hashes, recording a checkpoint at each segment boundary. Inherently +// sequential, so it can't be parallelised. +function solvePow(seed: Buffer, k: number, g: number): Buffer[] { + const checkpoints: Buffer[] = new Array(k + 1); + let h = createHash("sha256").update(seed).digest(); + checkpoints[0] = h; + for (let j = 0; j < k; j++) { + for (let i = 0; i < g; i++) { + h = createHash("sha256").update(h).digest(); + } + checkpoints[j + 1] = h; + } + return checkpoints; +} + +// Standard base64 of the concatenated (k+1)*32 bytes, matching the worker +// verifier's decode. +function encodeCheckpoints(checkpoints: Buffer[]): string { + return Buffer.concat(checkpoints).toString("base64"); +} + +export function solveChallenge(challenge: PowChallenge): PowSolution { + const checkpoints = solvePow( + Buffer.from(challenge.seed, "base64url"), + challenge.k, + challenge.g + ); + return { + challengeToken: challenge.challengeToken, + solution: { checkpoints: encodeCheckpoints(checkpoints) }, + }; +} diff --git a/packages/workers-auth/src/state.ts b/packages/workers-auth/src/state.ts index 8d1c5e7225..e3b4011619 100644 --- a/packages/workers-auth/src/state.ts +++ b/packages/workers-auth/src/state.ts @@ -1,4 +1,4 @@ -import type { AuthConfigStorage, UserAuthConfig } from "./auth-config-file"; +import type { AuthConfigStorage, UserAuthConfig } from "./config-file/auth"; import type { OAuthFlowLogger } from "./context"; export interface RefreshToken { diff --git a/packages/workers-auth/src/temporary.ts b/packages/workers-auth/src/temporary.ts new file mode 100644 index 0000000000..169cff6964 --- /dev/null +++ b/packages/workers-auth/src/temporary.ts @@ -0,0 +1,277 @@ +import { + COMPLIANCE_REGION_CONFIG_PUBLIC, + FatalError, + getCloudflareApiBaseUrl, + UserError, +} from "@cloudflare/workers-utils"; +import { fetch } from "undici"; +import { POW_MAX_ITERATIONS, solveChallenge } from "./pow"; +import type { + TemporaryAccountStorage, + TemporaryPreviewAccount, +} from "./config-file/temporary"; +import type { OAuthFlowLogger } from "./context"; +import type { PowSolution } from "./pow"; + +export const TEMPORARY_TERMS_URLS = { + termsOfService: "https://www.cloudflare.com/terms/", + privacyPolicy: "https://www.cloudflare.com/privacypolicy/", +} as const; + +export const TEMPORARY_TERMS_PROMPT = `You must accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}) in order to continue. By typing "yes", you agree to these terms. Type "yes" to continue.`; +export const TEMPORARY_TERMS_NOTICE = `Continuing means you accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}).`; + +const TEMPORARY_TERMS_ERROR = `You must accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}) to use --temporary.`; + +type TemporaryAccountPayload = { + account?: { + id?: string; + name?: string; + type?: string; + apiToken?: string; + tokenId?: string; + expiresAt?: string; + }; + claim?: { + token?: string; + url?: string; + expiresAt?: string; + }; +}; + +type TemporaryAccountResponse = { + result?: TemporaryAccountPayload; +}; + +function getTemporaryPreviewUrl(): string { + return `${getCloudflareApiBaseUrl(COMPLIANCE_REGION_CONFIG_PUBLIC)}/provisioning/previews`; +} + +function getTemporaryPreviewChallengeUrl(): string { + return `${getTemporaryPreviewUrl()}/challenge`; +} + +function isFutureTimestamp(timestamp: string): boolean { + const parsed = Date.parse(timestamp); + return !Number.isNaN(parsed) && parsed > Date.now(); +} + +/** + * Read the cached temporary preview account from the injected storage, + * validating that it has all required fields and that neither the account nor + * the claim has expired. Returns `undefined` when there is no usable cache. + */ +export function getCachedTemporaryPreviewAccount( + storage: TemporaryAccountStorage +): TemporaryPreviewAccount | undefined { + let temporaryPreviewAccount: TemporaryPreviewAccount | undefined; + try { + temporaryPreviewAccount = storage.read(); + } catch { + return undefined; + } + + if (!temporaryPreviewAccount) { + return undefined; + } + + if ( + !temporaryPreviewAccount.account?.id || + !temporaryPreviewAccount.account?.apiToken || + !temporaryPreviewAccount.account?.name || + !temporaryPreviewAccount.claim?.url || + !isFutureTimestamp(temporaryPreviewAccount.account?.expiresAt ?? "") || + !isFutureTimestamp(temporaryPreviewAccount.claim?.expiresAt ?? "") + ) { + return undefined; + } + + return temporaryPreviewAccount; +} + +type PowChallengeResponse = { + result?: { + challengeToken?: string; + seed?: string; + k?: number; + g?: number; + }; +}; + +// Requests a proof-of-work challenge and solves it. The challenge is required: +// any failure aborts provisioning. +async function requestPowSolution( + logger: OAuthFlowLogger +): Promise { + const response = await fetch(getTemporaryPreviewChallengeUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + if (!response.ok) { + throw new FatalError( + `Failed to request a proof-of-work challenge (${response.status} ${response.statusText}).`, + { telemetryMessage: "deploy temporary account challenge failed" } + ); + } + + let body: PowChallengeResponse; + try { + body = (await response.json()) as PowChallengeResponse; + } catch { + throw new FatalError( + `Failed to request a proof-of-work challenge. Received an invalid response (${response.status} ${response.statusText}).`, + { + telemetryMessage: "deploy temporary account challenge invalid response", + } + ); + } + + const { challengeToken, seed, k, g } = body.result ?? {}; + if ( + challengeToken === undefined || + seed === undefined || + k === undefined || + g === undefined + ) { + throw new FatalError( + "Failed to request a proof-of-work challenge because the response was missing required fields.", + { telemetryMessage: "deploy temporary account challenge incomplete" } + ); + } + + if ( + !Number.isInteger(k) || + !Number.isInteger(g) || + k <= 0 || + g <= 0 || + k * g > POW_MAX_ITERATIONS || + Buffer.from(seed, "base64url").length !== 32 + ) { + throw new FatalError( + "The proof-of-work challenge is not supported by this version of Wrangler.", + { + telemetryMessage: + "deploy temporary account challenge difficulty unsupported", + } + ); + } + + logger.log("Solving proof-of-work challenge…"); + return solveChallenge({ challengeToken, seed, k, g }); +} + +/** + * Provision a brand new temporary preview account from the public provisioning + * endpoint + */ +export async function createTemporaryPreviewAccount( + logger: OAuthFlowLogger +): Promise { + const pow = await requestPowSolution(logger); + + const response = await fetch(getTemporaryPreviewUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + termsOfService: TEMPORARY_TERMS_URLS.termsOfService, + privacyPolicy: TEMPORARY_TERMS_URLS.privacyPolicy, + acceptTermsOfService: "yes", + challengeToken: pow.challengeToken, + solution: pow.solution, + }), + }); + + if (!response.ok) { + throw new FatalError( + `Failed to create a temporary preview account (${response.status} ${response.statusText}).`, + { telemetryMessage: "deploy temporary account create failed" } + ); + } + + let responseBody: TemporaryAccountResponse; + + try { + responseBody = (await response.json()) as TemporaryAccountResponse; + } catch { + throw new FatalError( + `Failed to create a temporary preview account. Received an invalid response (${response.status} ${response.statusText}).`, + { telemetryMessage: "deploy temporary account invalid response" } + ); + } + + const previewAccount = responseBody.result; + const accountId = previewAccount?.account?.id; + const accountName = previewAccount?.account?.name; + const apiToken = previewAccount?.account?.apiToken; + const accountExpiresAt = previewAccount?.account?.expiresAt; + const claimUrl = previewAccount?.claim?.url; + const claimExpiresAt = previewAccount?.claim?.expiresAt; + + if ( + accountId === undefined || + accountName === undefined || + apiToken === undefined || + accountExpiresAt === undefined || + claimUrl === undefined || + claimExpiresAt === undefined + ) { + throw new FatalError( + "Failed to create a temporary preview account because the response was missing required fields.", + { telemetryMessage: "deploy temporary account response incomplete" } + ); + } + + return { + account: { + id: accountId, + name: accountName, + apiToken, + expiresAt: accountExpiresAt, + }, + claim: { + url: claimUrl, + expiresAt: claimExpiresAt, + }, + }; +} + +/** + * Return the cached temporary preview account if one is still valid, otherwise + * mint a fresh one (running `beforeCreate` first, e.g. a terms-acceptance gate) + * and persist it to the injected storage. + */ +export async function getOrCreateTemporaryPreviewAccount(options: { + storage: TemporaryAccountStorage; + prompt: (question: string, notice: string) => Promise; + logger: OAuthFlowLogger; +}): Promise<{ + account: TemporaryPreviewAccount; + cached: boolean; +}> { + const cachedPreviewAccount = getCachedTemporaryPreviewAccount( + options.storage + ); + if (cachedPreviewAccount) { + return { account: cachedPreviewAccount, cached: true }; + } + + const termsAccepted = await options.prompt( + TEMPORARY_TERMS_PROMPT, + TEMPORARY_TERMS_NOTICE + ); + + if (!termsAccepted) { + throw new UserError(TEMPORARY_TERMS_ERROR, { + telemetryMessage: "user temporary terms not accepted", + }); + } + + const temporaryPreviewAccount = await createTemporaryPreviewAccount( + options.logger + ); + options.storage.write(temporaryPreviewAccount); + + return { account: temporaryPreviewAccount, cached: false }; +} diff --git a/packages/workers-auth/src/token-exchange.ts b/packages/workers-auth/src/token-exchange.ts index 1521ac3b1a..ef703a1581 100644 --- a/packages/workers-auth/src/token-exchange.ts +++ b/packages/workers-auth/src/token-exchange.ts @@ -27,7 +27,7 @@ import { } from "./errors"; import { generatePKCECodes, RECOMMENDED_STATE_LENGTH } from "./pkce"; import { readStoredAuthState, type OAuthFlowState } from "./state"; -import type { AuthConfigStorage } from "./auth-config-file"; +import type { AuthConfigStorage } from "./config-file/auth"; import type { OAuthFlowContext } from "./context"; import type { generateAuthUrl as defaultGenerateAuthUrl } from "./generate-auth-url"; import type { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; diff --git a/packages/workers-auth/tests/pow.test.ts b/packages/workers-auth/tests/pow.test.ts new file mode 100644 index 0000000000..29475c378d --- /dev/null +++ b/packages/workers-auth/tests/pow.test.ts @@ -0,0 +1,57 @@ +import { createHash } from "node:crypto"; +import { describe, it } from "vitest"; +import { solveChallenge } from "../src/pow"; + +const CHECKPOINT_BYTES = 32; + +function sha256(input: Buffer): Buffer { + return createHash("sha256").update(input).digest(); +} + +describe("proof-of-work solver", () => { + it("produces a contiguous checkpoint ladder anchored at the seed", ({ + expect, + }) => { + const seed = Buffer.alloc(32, 7); + const k = 4; + const g = 5; + + const { solution } = solveChallenge({ + challengeToken: "token", + seed: seed.toString("base64url"), + k, + g, + }); + + const flat = Buffer.from(solution.checkpoints, "base64"); + expect(flat.length).toBe((k + 1) * CHECKPOINT_BYTES); + + const checkpoints: Buffer[] = []; + for (let i = 0; i <= k; i++) { + checkpoints.push( + flat.subarray(i * CHECKPOINT_BYTES, (i + 1) * CHECKPOINT_BYTES) + ); + } + + // C[0] == SHA256(seed), and each segment chains g hashes into the next + // checkpoint — the same relation the worker verifier spot-checks. + expect(checkpoints[0].equals(sha256(seed))).toBe(true); + for (let j = 0; j < k; j++) { + let h = checkpoints[j]; + for (let i = 0; i < g; i++) { + h = sha256(h); + } + expect(h.equals(checkpoints[j + 1])).toBe(true); + } + }); + + it("passes the challenge token through unchanged", ({ expect }) => { + const { challengeToken } = solveChallenge({ + challengeToken: "abc.def", + seed: Buffer.alloc(32).toString("base64url"), + k: 1, + g: 1, + }); + expect(challengeToken).toBe("abc.def"); + }); +}); diff --git a/packages/wrangler/src/__tests__/agent-memory.test.ts b/packages/wrangler/src/__tests__/agent-memory.test.ts index 5dca762457..2d73332684 100644 --- a/packages/wrangler/src/__tests__/agent-memory.test.ts +++ b/packages/wrangler/src/__tests__/agent-memory.test.ts @@ -44,7 +44,7 @@ describe("agent-memory help", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -73,7 +73,7 @@ describe("agent-memory help", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/agents-skills-install.test.ts b/packages/wrangler/src/__tests__/agents-skills-install.test.ts index f233cb09de..7627ed3e05 100644 --- a/packages/wrangler/src/__tests__/agents-skills-install.test.ts +++ b/packages/wrangler/src/__tests__/agents-skills-install.test.ts @@ -6,16 +6,18 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { detectAgenticEnvironment } from "am-i-vibing"; import ci from "ci-info"; import { http, HttpResponse } from "msw"; +import prompts from "prompts"; import { afterEach, beforeEach, describe, test, vi } from "vitest"; +import { + skillInstallPromptMessageAfterWranglerCommandHandler, + type runSkillsInstallFlow as RunFlowFnType, + type telemetryCurrentAgentSkillsInstalled as TelemetryFnType, +} from "../agents-skills-install"; import { sendMetricsEvent } from "../metrics/send-event"; import { mockConsoleMethods } from "./helpers/mock-console"; import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; import { useMockIsTTY } from "./helpers/mock-istty"; import { msw } from "./helpers/msw"; -import type { - maybeInstallCloudflareSkillsGlobally as InstallFnType, - telemetryCurrentAgentSkillsInstalled as TelemetryFnType, -} from "../agents-skills-install"; import type * as SendEventModule from "../metrics/send-event"; // Undo the global no-op mock from vitest.setup.ts so we test the real implementation @@ -98,11 +100,13 @@ function readMetadataFile(): Record { * Re-imports the agents-skills-install module with a fresh module graph. * This is necessary because tests need a clean module state after mocks * are reconfigured per test. + * + * @returns The `runSkillsInstallFlow` function from a fresh module import. */ -async function freshImport(): Promise { +async function freshImport(): Promise { vi.resetModules(); const mod = await import("../agents-skills-install"); - return mod.maybeInstallCloudflareSkillsGlobally; + return mod.runSkillsInstallFlow; } /** @@ -129,11 +133,15 @@ function createAgentDir(dirName: string): void { mkdirSync(path.join(os.homedir(), dirName), { recursive: true }); } -describe("maybeInstallCloudflareSkillsGlobally", () => { +describe("runSkillsInstallFlow with force-install prompt", () => { runInTempDir(); const std = mockConsoleMethods(); const { setIsTTY } = useMockIsTTY(); + /** The prompt message used by the --install-skills global flag. */ + const installPromptMessage = (agents: string[]) => + `Wrangler detected the following AI coding agents: ${agents.join(", ")}. Would you like to install Cloudflare skills for them?`; + beforeEach(() => { setIsTTY(true); mockRosieAgents.mockResolvedValue(DEFAULT_AGENTS); @@ -149,9 +157,38 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); + + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); + + expect(mockRosieAgents).not.toHaveBeenCalled(); + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).not.toHaveBeenCalled(); + }); + + test("skips silently when metadata file has accepted='unanswered' (user interrupted prompt)", async ({ + expect, + }) => { + writeMetadataFile({ + version: 1, + accepted: "unanswered", + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: "/fake/.claude/skills" }, + }, + ], + }); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); expect(mockRosieAgents).not.toHaveBeenCalled(); expect(mockRosieInstall).not.toHaveBeenCalled(); @@ -175,9 +212,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { ], installFailed: false, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); expect(mockRosieAgents).not.toHaveBeenCalled(); expect(mockRosieInstall).not.toHaveBeenCalled(); @@ -195,14 +235,15 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { test("force=true ignores existing metadata file", async ({ expect }) => { writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); expect(mockRosieInstall).toHaveBeenCalledWith("cloudflare/skills", { global: true, agent: ["claude"], lockfile: false, + onLog: expect.any(Function), }); expect(std.out).toContain( "Successfully installed Cloudflare skills for: Claude Code." @@ -220,9 +261,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { installPath: null, }, ]); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); expect(mockRosieInstall).not.toHaveBeenCalled(); expect(sendMetricsEvent).toHaveBeenCalledWith( @@ -236,9 +280,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { mockRosieAgents.mockResolvedValueOnce([]); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); expect(mockRosieInstall).not.toHaveBeenCalled(); expect(sendMetricsEvent).toHaveBeenCalledWith( @@ -252,10 +299,10 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { mockRosieInstall.mockRejectedValueOnce(new Error("network failure")); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); // force=true so we don't need to mock the confirm dialog - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); expect(std.warn).toContain( "Failed to install Cloudflare skills: network failure" @@ -274,9 +321,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { mockRosieAgents.mockRejectedValueOnce(new Error("WASM load failed")); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); expect(mockRosieInstall).not.toHaveBeenCalled(); expect(sendMetricsEvent).toHaveBeenCalledWith( @@ -293,9 +343,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { vi.mocked(ci).isCI = true; - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); // Verify neither agent detection nor install was attempted expect(mockRosieAgents).not.toHaveBeenCalled(); @@ -311,14 +364,15 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { vi.mocked(ci).isCI = true; - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); expect(mockRosieInstall).toHaveBeenCalledWith("cloudflare/skills", { global: true, agent: ["claude"], lockfile: false, + onLog: expect.any(Function), }); }); @@ -326,9 +380,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { setIsTTY(false); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); // Nothing has been logged expect(std.out).toEqual(""); @@ -351,9 +408,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { text: expect.stringContaining("Claude Code") as unknown as string, result: false, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); // must not log a success message when the user declined expect(std.out).not.toContain( @@ -381,14 +441,18 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { text: expect.stringContaining("Claude Code") as unknown as string, result: true, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); expect(mockRosieInstall).toHaveBeenCalledWith("cloudflare/skills", { global: true, agent: ["claude"], lockfile: false, + onLog: expect.any(Function), }); expect(std.out).toContain( @@ -412,22 +476,126 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { ); }); + test("writes metadata with accepted='unanswered' before showing the confirm prompt", async ({ + expect, + }) => { + // Intercept the prompts call to inspect the metadata file state at + // the moment the confirmation prompt is displayed to the user. + vi.mocked(prompts).mockImplementationOnce(() => { + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe("unanswered"); + expect(metadata.detectedAgents).toEqual([ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: "/fake/.claude/skills" }, + }, + ]); + return Promise.resolve({ value: true }); + }); + const runSkillsInstallFlow = await freshImport(); + + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); + + // After the flow completes, the final metadata should reflect the + // user's actual answer, overwriting the "unanswered" marker. + const finalMetadata = readMetadataFile(); + expect(finalMetadata.accepted).toBe(true); + }); + test("force=true installs skills without prompting", async ({ expect }) => { // No mockConfirm — if a prompt fires, the test will fail with "Unexpected call to prompts" - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); expect(mockRosieInstall).toHaveBeenCalledWith("cloudflare/skills", { global: true, agent: ["claude"], lockfile: false, + onLog: expect.any(Function), }); expect(std.out).toContain( "Successfully installed Cloudflare skills for: Claude Code." ); }); + + test("force=true does not write 'unanswered' metadata before installing", async ({ + expect, + }) => { + const runSkillsInstallFlow = await freshImport(); + + await runSkillsInstallFlow({ force: true }); + + // The final metadata should be accepted=true, never "unanswered" + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe(true); + }); + }); + + describe("telemetry command property", () => { + test("includes command in skills_install_completed when provided", async ({ + expect, + }) => { + const runSkillsInstallFlow = await freshImport(); + + await runSkillsInstallFlow({ force: true, command: "deploy" }); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_completed", + { + agents: [ + { + name: "Claude Code", + rosie: { + id: "claude", + globalPath: "/fake/.claude/skills", + }, + }, + ], + command: "deploy", + }, + {} + ); + }); + + test("includes command in skills_install_skipped when provided", async ({ + expect, + }) => { + mockRosieAgents.mockResolvedValueOnce([]); + const runSkillsInstallFlow = await freshImport(); + + await runSkillsInstallFlow({ + force: false, + command: "dev", + promptMessage: installPromptMessage, + }); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "No supported agents detected", command: "dev" }, + {} + ); + }); + + test("omits command from metrics when not provided", async ({ expect }) => { + mockRosieAgents.mockResolvedValueOnce([]); + const runSkillsInstallFlow = await freshImport(); + + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "No supported agents detected" }, + {} + ); + }); }); describe("multiple agents", () => { @@ -452,14 +620,18 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { text: expect.stringContaining("Claude Code") as unknown as string, result: true, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); expect(mockRosieInstall).toHaveBeenCalledWith("cloudflare/skills", { global: true, agent: ["claude", "cursor"], lockfile: false, + onLog: expect.any(Function), }); expect(std.out).toContain( @@ -475,9 +647,9 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { mockRosieInstall.mockRejectedValueOnce( new Error("tarball download failed") ); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); expect(std.warn).toContain( "Failed to install Cloudflare skills: tarball download failed" @@ -519,9 +691,9 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { failedAgents: ["cursor"], installedInstruction: null, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); // Success message should only mention succeeded agents expect(std.out).toContain( @@ -547,9 +719,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { text: expect.stringContaining("Claude Code") as unknown as string, result: true, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); const metadata = readMetadataFile(); expect(metadata.accepted).toBe(true); @@ -570,9 +745,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { text: expect.stringContaining("Claude Code") as unknown as string, result: false, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); const metadata = readMetadataFile(); expect(metadata.accepted).toBe(false); @@ -585,9 +763,12 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { text: expect.stringContaining("Claude Code") as unknown as string, result: false, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(false); + await runSkillsInstallFlow({ + force: false, + promptMessage: installPromptMessage, + }); const metadata = readMetadataFile(); expect(metadata.installFailed).toBeUndefined(); @@ -597,9 +778,9 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { expect, }) => { mockRosieInstall.mockRejectedValueOnce(new Error("download failed")); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); const metadata = readMetadataFile(); expect(metadata.installFailed).toBe(true); @@ -621,9 +802,9 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { failedAgents: ["cursor"], installedInstruction: null, }); - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); - await maybeInstallCloudflareSkillsGlobally(true); + await runSkillsInstallFlow({ force: true }); const metadata = readMetadataFile(); expect(metadata.accepted).toBe(true); @@ -633,14 +814,209 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { test("sets installFailed to false when all agents succeed", async ({ expect, }) => { - const maybeInstallCloudflareSkillsGlobally = await freshImport(); + const runSkillsInstallFlow = await freshImport(); + + await runSkillsInstallFlow({ force: true }); + + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe(true); + expect(metadata.installFailed).toBe(false); + }); + }); +}); + +describe("runSkillsInstallFlow with custom prompt message", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + beforeEach(() => { + setIsTTY(true); + mockRosieAgents.mockResolvedValue(DEFAULT_AGENTS); + mockRosieInstall.mockResolvedValue(DEFAULT_INSTALL_RESULT); + }); + + afterEach(() => { + clearDialogs(); + }); + + describe("skip conditions", () => { + test("skips silently when metadata file exists", async ({ expect }) => { + writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" }); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(mockRosieAgents).not.toHaveBeenCalled(); + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).not.toHaveBeenCalled(); + }); + + test("skips silently when metadata file records a decline", async ({ + expect, + }) => { + writeMetadataFile({ accepted: false, date: "2025-01-01T00:00:00Z" }); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(mockRosieAgents).not.toHaveBeenCalled(); + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).not.toHaveBeenCalled(); + }); + + test("skips silently when metadata file has accepted='unanswered'", async ({ + expect, + }) => { + writeMetadataFile({ + version: 1, + accepted: "unanswered", + date: "2025-01-01T00:00:00Z", + }); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(mockRosieAgents).not.toHaveBeenCalled(); + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).not.toHaveBeenCalled(); + }); - await maybeInstallCloudflareSkillsGlobally(true); + test("skips in CI", async ({ expect }) => { + vi.mocked(ci).isCI = true; + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "Running in CI" }, + {} + ); + }); + + test("skips in non-interactive terminal", async ({ expect }) => { + setIsTTY(false); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(std.out).toEqual(""); + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "Non-interactive terminal" }, + {} + ); + }); + + test("skips when no agents are detected", async ({ expect }) => { + mockRosieAgents.mockResolvedValueOnce([]); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "No supported agents detected" }, + {} + ); + }); + }); + + describe("prompt message", () => { + test("uses the caller-provided prompt message", async ({ expect }) => { + mockConfirm({ + text: "Before you go, Wrangler detected AI coding agents that may not be best configured to work with Cloudflare: Claude Code. Would you like Wrangler to automatically install Cloudflare skills for the best experience?", + result: false, + }); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "User declined" }, + {} + ); + }); + }); + + describe("user prompt interaction", () => { + test("installs skills when user accepts", async ({ expect }) => { + mockConfirm({ + text: expect.stringContaining( + "Would you like Wrangler to automatically install Cloudflare skills" + ) as unknown as string, + result: true, + }); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(mockRosieInstall).toHaveBeenCalledWith("cloudflare/skills", { + global: true, + agent: ["claude"], + lockfile: false, + onLog: expect.any(Function), + }); + + expect(std.out).toContain( + "Successfully installed Cloudflare skills for: Claude Code." + ); const metadata = readMetadataFile(); expect(metadata.accepted).toBe(true); expect(metadata.installFailed).toBe(false); }); + + test("writes metadata with accepted=false when user declines", async ({ + expect, + }) => { + mockConfirm({ + text: expect.stringContaining( + "Would you like Wrangler to automatically install Cloudflare skills" + ) as unknown as string, + result: false, + }); + const flow = await freshImport(); + + await flow({ + force: false, + promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, + }); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe(false); + }); }); }); @@ -922,6 +1298,40 @@ describe("telemetryCurrentAgentSkillsInstalled", () => { expect(result).toBe("manual"); }); + test("resolves to 'manual' when metadata has accepted='unanswered' (user interrupted prompt)", async ({ + expect, + }) => { + vi.mocked(detectAgenticEnvironment).mockReturnValue({ + isAgentic: true, + id: "claude-code", + name: "Claude Code", + type: "agent", + }); + createAgentDir(".claude"); + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + const claudeGlobalSkillsPath = path.join(os.homedir(), ".claude", "skills"); + writeMetadataFile({ + version: 1, + accepted: "unanswered", + date: new Date().toISOString(), + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeGlobalSkillsPath }, + }, + ], + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"]); + const telemetryCurrentAgentSkillsInstalled = await freshTelemetryImport(); + + const result = await telemetryCurrentAgentSkillsInstalled(); + + // "unanswered" must not be treated as "accepted" — skills were never + // installed by Wrangler, so the correct status is "manual". + expect(result).toBe("manual"); + }); + test("uses cached GitHub API response within TTL", async ({ expect }) => { vi.mocked(detectAgenticEnvironment).mockReturnValue({ isAgentic: true, diff --git a/packages/wrangler/src/__tests__/ai.test.ts b/packages/wrangler/src/__tests__/ai.test.ts index dabc64d70b..febc9ab929 100644 --- a/packages/wrangler/src/__tests__/ai.test.ts +++ b/packages/wrangler/src/__tests__/ai.test.ts @@ -32,7 +32,7 @@ describe("ai help", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -65,7 +65,7 @@ describe("ai help", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -89,7 +89,7 @@ describe("ai help", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -112,7 +112,7 @@ describe("ai help", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/autoconfig/run.test.ts b/packages/wrangler/src/__tests__/autoconfig/run.test.ts index 4d5134bfb2..369a4aeeeb 100644 --- a/packages/wrangler/src/__tests__/autoconfig/run.test.ts +++ b/packages/wrangler/src/__tests__/autoconfig/run.test.ts @@ -92,21 +92,38 @@ describe("autoconfig (deploy)", () => { clearOutputFilePath(); }); - it("should not check for autoconfig when `deploy` is run with `--x-autoconfig=false`", async ({ + it("should not run autoconfig when `deploy` is run with `--no-autoconfig`", async ({ expect, }) => { writeWorkerSource(); writeWranglerConfig({ main: "index.js" }); const getDetailsSpy = vi.spyOn(details, "getDetailsForAutoConfig"); - await runDeploy(expect, `--x-autoconfig=false`); + const runSpy = vi.spyOn(run, "runAutoConfig"); + + await runDeploy(expect, `--no-autoconfig`); expect(getDetailsSpy).not.toHaveBeenCalled(); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("should not run autoconfig when `deploy` is run with `--autoconfig=false`", async ({ + expect, + }) => { + writeWorkerSource(); + writeWranglerConfig({ main: "index.js" }); + const getDetailsSpy = vi.spyOn(details, "getDetailsForAutoConfig"); + const runSpy = vi.spyOn(run, "runAutoConfig"); + + await runDeploy(expect, `--autoconfig=false`); + + expect(getDetailsSpy).not.toHaveBeenCalled(); + expect(runSpy).not.toHaveBeenCalled(); }); it("should check for autoconfig with flag", async ({ expect }) => { const getDetailsSpy = vi.spyOn(details, "getDetailsForAutoConfig"); - await runDeploy(expect, "--x-autoconfig"); + await runDeploy(expect, "--autoconfig"); expect(getDetailsSpy).toHaveBeenCalled(); }); @@ -128,7 +145,7 @@ describe("autoconfig (deploy)", () => { ); const runSpy = vi.spyOn(run, "runAutoConfig"); - await runDeploy(expect, "--x-autoconfig"); + await runDeploy(expect, "--autoconfig"); expect(getDetailsSpy).toHaveBeenCalled(); expect(runSpy).toHaveBeenCalled(); @@ -149,7 +166,7 @@ describe("autoconfig (deploy)", () => { ); const runSpy = vi.spyOn(run, "runAutoConfig"); - await runDeploy(expect, "--x-autoconfig"); + await runDeploy(expect, "--autoconfig"); expect(getDetailsSpy).toHaveBeenCalled(); expect(runSpy).not.toHaveBeenCalled(); @@ -182,7 +199,7 @@ describe("autoconfig (deploy)", () => { }); // Should not throw - just return early - await runWrangler("deploy --x-autoconfig"); + await runWrangler("deploy --autoconfig"); // Should show warning about Pages project expect(std.warn).toContain( diff --git a/packages/wrangler/src/__tests__/cert.test.ts b/packages/wrangler/src/__tests__/cert.test.ts index cc599b0c7c..292f3cafc8 100644 --- a/packages/wrangler/src/__tests__/cert.test.ts +++ b/packages/wrangler/src/__tests__/cert.test.ts @@ -506,7 +506,7 @@ describe("wrangler", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index 4ac66c5ae5..1a2869d85c 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts @@ -86,7 +86,7 @@ describe("cloudchamber create", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts index a4372a58c9..afe443c07b 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts @@ -43,7 +43,7 @@ describe("cloudchamber curl", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts index 2b52b86a4f..07e89f24c2 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts @@ -39,7 +39,7 @@ describe("cloudchamber delete", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts index 9f7b8a5ec5..9b70aa1bbc 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts @@ -41,7 +41,7 @@ describe("cloudchamber image", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -194,7 +194,7 @@ describe("cloudchamber image list", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS @@ -453,7 +453,7 @@ describe("cloudchamber image delete", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts index 2a3af29565..60f9b89687 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts @@ -39,7 +39,7 @@ describe("cloudchamber list", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts index 4eb5d9e33b..ef93b78d15 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts @@ -54,7 +54,7 @@ describe("cloudchamber modify", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/containers/delete.test.ts b/packages/wrangler/src/__tests__/containers/delete.test.ts index 54a79c1373..2a3e043d38 100644 --- a/packages/wrangler/src/__tests__/containers/delete.test.ts +++ b/packages/wrangler/src/__tests__/containers/delete.test.ts @@ -40,7 +40,7 @@ describe("containers delete", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/containers/images.test.ts b/packages/wrangler/src/__tests__/containers/images.test.ts index ef4abc9ed7..ec7f89ac05 100644 --- a/packages/wrangler/src/__tests__/containers/images.test.ts +++ b/packages/wrangler/src/__tests__/containers/images.test.ts @@ -47,7 +47,7 @@ describe("containers images list", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS @@ -220,7 +220,7 @@ describe("containers images delete", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/containers/info.test.ts b/packages/wrangler/src/__tests__/containers/info.test.ts index c2fccf56a1..78131f9a44 100644 --- a/packages/wrangler/src/__tests__/containers/info.test.ts +++ b/packages/wrangler/src/__tests__/containers/info.test.ts @@ -39,7 +39,7 @@ describe("containers info", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/containers/instances.test.ts b/packages/wrangler/src/__tests__/containers/instances.test.ts index 0cb5a7cfcc..1fc917aac5 100644 --- a/packages/wrangler/src/__tests__/containers/instances.test.ts +++ b/packages/wrangler/src/__tests__/containers/instances.test.ts @@ -111,7 +111,7 @@ describe("containers instances", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/containers/list.test.ts b/packages/wrangler/src/__tests__/containers/list.test.ts index 6b1e8cbfc7..d5a777e622 100644 --- a/packages/wrangler/src/__tests__/containers/list.test.ts +++ b/packages/wrangler/src/__tests__/containers/list.test.ts @@ -53,7 +53,7 @@ describe("containers list", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/containers/push.test.ts b/packages/wrangler/src/__tests__/containers/push.test.ts index 8ebcf9e7d6..dd71749479 100644 --- a/packages/wrangler/src/__tests__/containers/push.test.ts +++ b/packages/wrangler/src/__tests__/containers/push.test.ts @@ -50,7 +50,7 @@ describe("containers push", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 8059656617..a9999887f6 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -40,7 +40,7 @@ describe("containers registries --help", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/containers/ssh.test.ts b/packages/wrangler/src/__tests__/containers/ssh.test.ts index 6bd34615c6..5b95c584fe 100644 --- a/packages/wrangler/src/__tests__/containers/ssh.test.ts +++ b/packages/wrangler/src/__tests__/containers/ssh.test.ts @@ -79,7 +79,7 @@ describe("containers ssh", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean] OPTIONS diff --git a/packages/wrangler/src/__tests__/d1/d1.test.ts b/packages/wrangler/src/__tests__/d1/d1.test.ts index 601fac3c4f..981f5c42a4 100644 --- a/packages/wrangler/src/__tests__/d1/d1.test.ts +++ b/packages/wrangler/src/__tests__/d1/d1.test.ts @@ -34,7 +34,7 @@ describe("d1", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -74,7 +74,7 @@ describe("d1", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -101,7 +101,7 @@ describe("d1", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); @@ -126,7 +126,7 @@ describe("d1", () => { -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] -h, --help Show help [boolean] - --install-skills Install Cloudflare agents skills, if not already present, without asking the user for confirmation [boolean] [default: false] + --install-skills Install Cloudflare skills for detected AI coding agents before running the command [boolean] [default: false] -v, --version Show version number [boolean]" `); }); diff --git a/packages/wrangler/src/__tests__/d1/execute.test.ts b/packages/wrangler/src/__tests__/d1/execute.test.ts index 5713aaffaf..3eb7080ec7 100644 --- a/packages/wrangler/src/__tests__/d1/execute.test.ts +++ b/packages/wrangler/src/__tests__/d1/execute.test.ts @@ -33,7 +33,9 @@ describe("execute", () => { await expect( runWrangler("d1 execute db --command 'select 1;' --remote") ).rejects.toThrowError( - `In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work. Please go to https://developers.cloudflare.com/fundamentals/api/get-started/create-token/ for instructions on how to create an api token, and assign its value to CLOUDFLARE_API_TOKEN.` + `In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work. Please go to https://developers.cloudflare.com/fundamentals/api/get-started/create-token/ for instructions on how to create an api token, and assign its value to CLOUDFLARE_API_TOKEN. + +To continue without logging in, rerun this command with \`--temporary\`. Wrangler will use a temporary account and print a claim URL.` ); }); diff --git a/packages/wrangler/src/__tests__/deploy/core.test.ts b/packages/wrangler/src/__tests__/deploy/core.test.ts index 0f4ea1fd99..589cd3d661 100644 --- a/packages/wrangler/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler/src/__tests__/deploy/core.test.ts @@ -1,6 +1,11 @@ import * as fs from "node:fs"; import { readFile } from "node:fs/promises"; import * as path from "node:path"; +import { + TEMPORARY_TERMS_NOTICE, + TEMPORARY_TERMS_PROMPT, +} from "@cloudflare/workers-auth"; +import { getGlobalWranglerConfigPath } from "@cloudflare/workers-utils"; import { runInTempDir, writeWranglerConfig, @@ -19,7 +24,7 @@ import { writeAuthConfigFile } from "../../user"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockAuthDomain } from "../helpers/mock-auth-domain"; import { mockConsoleMethods } from "../helpers/mock-console"; -import { clearDialogs, mockConfirm } from "../helpers/mock-dialogs"; +import { clearDialogs, mockConfirm, mockPrompt } from "../helpers/mock-dialogs"; import { useMockIsTTY } from "../helpers/mock-istty"; import { mockExchangeRefreshTokenForAccessToken, @@ -89,6 +94,32 @@ describe("deploy", () => { mockGrantAccessToken, mockDomainUsesAccess, } = mockOAuthFlow(); + const temporaryPreviewAccountUrl = + "https://api.cloudflare.com/client/v4/provisioning/previews"; + + // Mocks the proof-of-work challenge minting expects. Small k/g so the + // solve is instant. + function mockTemporaryPreviewChallenge( + url = `${temporaryPreviewAccountUrl}/challenge` + ) { + msw.use( + http.post(url, () => + HttpResponse.json({ + success: true, + result: { + challengeToken: "challenge-token", + seed: Buffer.alloc(32, 1).toString("base64url"), + k: 2, + g: 2, + s: 16, + expiresAt: 9999999999, + }, + errors: [], + messages: [], + }) + ) + ); + } beforeEach(() => { vi.stubGlobal("setTimeout", (fn: () => void) => { @@ -535,7 +566,7 @@ describe("deploy", () => { `); }); - it("should error helpfully if pages_build_output_dir is set in wrangler.toml when --x-autoconfig=false", async ({ + it("should error helpfully if pages_build_output_dir is set in wrangler.toml when --no-autoconfig", async ({ expect, }) => { writeWranglerConfig({ @@ -543,7 +574,7 @@ describe("deploy", () => { name: "test-name", }); await expect( - runWrangler("deploy --x-autoconfig=false") + runWrangler("deploy --no-autoconfig") ).rejects.toThrowErrorMatchingInlineSnapshot( ` [Error: It looks like you've run a Workers-specific command in a Pages project. @@ -552,7 +583,7 @@ describe("deploy", () => { ); }); - it("should error helpfully if pages_build_output_dir is set in wrangler.toml and --x-autoconfig is provided", async ({ + it("should error helpfully if pages_build_output_dir is set in wrangler.toml", async ({ expect, }) => { mockConfirm({ @@ -564,13 +595,13 @@ describe("deploy", () => { pages_build_output_dir: "public", name: "test-name", }); - await expect(runWrangler("deploy --x-autoconfig")).rejects.toThrowError(); + await expect(runWrangler("deploy")).rejects.toThrowError(); expect(std.warn).toContain( "It seems that you have run `wrangler deploy` on a Pages project, `wrangler pages deploy` should be used instead." ); }); - it("should attempt to run the autoconfig flow when pages_build_output_dir and (--x-autoconfig is used)", async ({ + it("should attempt to run the autoconfig flow when pages_build_output_dir", async ({ expect, }) => { writeWranglerConfig({ @@ -603,7 +634,7 @@ describe("deploy", () => { result: false, }); - await runWrangler("deploy --x-autoconfig"); + await runWrangler("deploy"); expect(getDetailsForAutoConfigSpy).toHaveBeenCalled(); @@ -612,7 +643,7 @@ describe("deploy", () => { ); }); - it("in non-interactive mode, attempts to deploy a Pages project when --x-autoconfig is used", async ({ + it("in non-interactive mode, attempts to deploy a Pages project using autoconfig", async ({ expect, }) => { setIsTTY(false); @@ -642,7 +673,7 @@ describe("deploy", () => { // The command will fail later due to missing entry-point, but we can still verify // that the deployment of the (Pages) project was attempted - await expect(runWrangler("deploy --x-autoconfig")).rejects.toThrow(); + await expect(runWrangler("deploy")).rejects.toThrow(); expect(getDetailsForAutoConfigSpy).toHaveBeenCalled(); @@ -774,6 +805,95 @@ describe("deploy", () => { expect(std.err).toMatchInlineSnapshot(`""`); }); + it("creates a temporary preview account in interactive mode when --temporary is passed", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest("test-sub-domain", true, false); + mockUploadWorkerRequest({ + expectedAccountId: "preview-account-id", + }); + + let previewAccountRequests = 0; + let contentTypeHeader: string | null = null; + mockTemporaryPreviewChallenge(); + msw.use( + http.get( + "*/accounts/preview-account-id/workers/services/:scriptName", + () => { + return HttpResponse.json( + createFetchResult({ + default_environment: { + script: { last_deployed_from: "wrangler" }, + }, + }) + ); + } + ), + http.post(temporaryPreviewAccountUrl, async ({ request }) => { + previewAccountRequests += 1; + contentTypeHeader = request.headers.get("Content-Type"); + return HttpResponse.json({ + success: true, + result: { + account: { + id: "preview-account-id", + name: "Preview Account Alpha", + type: "standard", + apiToken: "preview-account-token", + tokenId: "preview-token-id", + expiresAt: "2027-01-01T00:00:00.000Z", + }, + claim: { + token: "claim-token", + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + expiresAt: "2027-01-02T00:00:00.000Z", + }, + }, + errors: [], + messages: [], + }); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).resolves.toBeUndefined(); + + expect(previewAccountRequests).toBe(1); + expect(contentTypeHeader).toBe("application/json"); + expect(std.out).not.toContain("Attempting to login via OAuth..."); + expect(std.out).toContain("Temporary account ready:"); + }); + + it("aborts in interactive mode when the terms are not accepted", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "no" }); + writeWranglerConfig(); + writeWorkerSource(); + + let previewAccountRequests = 0; + msw.use( + http.post(temporaryPreviewAccountUrl, async () => { + previewAccountRequests += 1; + return HttpResponse.json({}); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).rejects.toThrowError( + /You must accept Cloudflare's Terms of Service .* to use --temporary\./ + ); + + expect(previewAccountRequests).toBe(0); + }); + describe("with an alternative auth domain", () => { mockAuthDomain({ domain: "dash.staging.cloudflare.com" }); @@ -863,7 +983,424 @@ describe("deploy", () => { expect(std.err).toMatchInlineSnapshot(`""`); }); - describe("non-TTY", () => { + it("throws when the user is already authenticated and requests a temporary account", async ({ + expect, + }) => { + setIsTTY(false); + writeAuthConfigFile({ api_token: "cached-api-token" }); + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest(); + mockUploadWorkerRequest(); + + let previewAccountRequests = 0; + msw.use( + http.post(temporaryPreviewAccountUrl, async () => { + previewAccountRequests += 1; + return HttpResponse.json({}); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).rejects.toThrowError( + /You're already authenticated with Cloudflare, so `--temporary` can't be used\./ + ); + + expect(previewAccountRequests).toBe(0); + expect(std.out).not.toContain("Temporary account ready:"); + }); + + describe("with temporary preview accounts", () => { + it("creates a temporary preview account in non-interactive mode after printing terms notice", async ({ + expect, + }) => { + setIsTTY(false); + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest("test-sub-domain", true, false); + mockUploadWorkerRequest({ + expectedAccountId: "preview-account-id", + }); + + let previewRequestBody: unknown; + let previewContentType: string | null = null; + mockTemporaryPreviewChallenge(); + msw.use( + http.get( + "*/accounts/preview-account-id/workers/services/:scriptName", + () => { + return HttpResponse.json( + createFetchResult({ + default_environment: { + script: { last_deployed_from: "wrangler" }, + }, + }) + ); + } + ), + http.post(temporaryPreviewAccountUrl, async ({ request }) => { + previewContentType = request.headers.get("Content-Type"); + previewRequestBody = await request.json(); + return HttpResponse.json({ + success: true, + result: { + account: { + id: "preview-account-id", + name: "Preview Account Alpha", + type: "standard", + apiToken: "preview-account-token", + tokenId: "preview-token-id", + expiresAt: "2027-01-01T00:00:00.000Z", + }, + claim: { + token: "claim-token", + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + expiresAt: "2027-01-02T00:00:00.000Z", + }, + }, + errors: [], + messages: [], + }); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).resolves.toBeUndefined(); + + const globalTemporaryAccountPath = path.join( + getGlobalWranglerConfigPath(), + "wrangler-temporary-account.toml" + ); + const localTemporaryAccountPath = path.join( + process.cwd(), + ".wrangler", + "cache", + "wrangler-temporary-account.toml" + ); + + expect(previewContentType).toBe("application/json"); + expect(previewRequestBody).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 } = previewRequestBody as { + solution: { checkpoints: string }; + }; + // k+1 checkpoints of 32 bytes each (challenge mock uses k=2). + expect(Buffer.from(solution.checkpoints, "base64").length).toBe( + (2 + 1) * 32 + ); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).not.toContain("Attempting to login via OAuth..."); + expect(std.out).toContain(TEMPORARY_TERMS_NOTICE); + expect(std.out).toContain("Temporary account ready:"); + expect(std.out).toContain("Account: Preview Account Alpha (created)"); + expect(std.out).toContain("Claim within:"); + expect(fs.existsSync(globalTemporaryAccountPath)).toBe(true); + expect(fs.existsSync(localTemporaryAccountPath)).toBe(false); + if (process.platform !== "win32") { + expect(fs.statSync(globalTemporaryAccountPath).mode & 0o777).toBe( + 0o600 + ); + } + expect( + TOML.parse(fs.readFileSync(globalTemporaryAccountPath, "utf-8")) + ).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 — it would be refreshed on the next deploy — so + // `--temporary` must refuse rather than provision a throwaway + // account alongside it. + writeAuthConfigFile({ + oauth_token: "expired-token", + refresh_token: "expired-refresh-token", + expiration_time: new Date(Date.now() - 1000).toISOString(), + }); + writeWranglerConfig(); + writeWorkerSource(); + + let previewAccountRequests = 0; + msw.use( + http.post(temporaryPreviewAccountUrl, async () => { + previewAccountRequests += 1; + return HttpResponse.json({}); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).rejects.toThrowError( + /You're already authenticated with Cloudflare, so `--temporary` can't be used\./ + ); + + expect(previewAccountRequests).toBe(0); + expect(std.out).not.toContain("Temporary account ready:"); + }); + + it("provisions the preview account against the staging API and caches it per-environment", async ({ + expect, + }) => { + vi.stubEnv("WRANGLER_API_ENVIRONMENT", "staging"); + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest("test-sub-domain", true, false); + mockUploadWorkerRequest({ + expectedAccountId: "preview-account-id", + expectedBaseUrl: "api.staging.cloudflare.com", + }); + + let stagingPreviewRequests = 0; + mockTemporaryPreviewChallenge( + "https://api.staging.cloudflare.com/client/v4/provisioning/previews/challenge" + ); + msw.use( + http.get( + "*/accounts/preview-account-id/workers/services/:scriptName", + () => { + return HttpResponse.json( + createFetchResult({ + default_environment: { + script: { last_deployed_from: "wrangler" }, + }, + }) + ); + } + ), + http.post( + "https://api.staging.cloudflare.com/client/v4/provisioning/previews", + async () => { + stagingPreviewRequests++; + return HttpResponse.json({ + success: true, + result: { + account: { + id: "preview-account-id", + name: "Preview Account Alpha", + type: "standard", + apiToken: "preview-account-token", + tokenId: "preview-token-id", + expiresAt: "2027-01-01T00:00:00.000Z", + }, + claim: { + token: "claim-token", + url: "https://dash.staging.cloudflare.com/claim-preview?claimToken=claim-token", + expiresAt: "2027-01-02T00:00:00.000Z", + }, + }, + errors: [], + messages: [], + }); + } + ) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).resolves.toBeUndefined(); + + const stagingTemporaryAccountPath = path.join( + getGlobalWranglerConfigPath(), + "wrangler-temporary-account.staging.toml" + ); + const productionTemporaryAccountPath = path.join( + getGlobalWranglerConfigPath(), + "wrangler-temporary-account.toml" + ); + + expect(stagingPreviewRequests).toBe(1); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Temporary account ready:"); + expect(std.out).toContain("Account: Preview Account Alpha (created)"); + expect(fs.existsSync(stagingTemporaryAccountPath)).toBe(true); + expect(fs.existsSync(productionTemporaryAccountPath)).toBe(false); + }); + + it("reuses a cached temporary preview account for later temporary deploys", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest("test-sub-domain", true, false); + mockUploadWorkerRequest({ + expectedAccountId: "preview-account-id", + }); + + let previewAccountRequests = 0; + mockTemporaryPreviewChallenge(); + msw.use( + http.get( + "*/accounts/preview-account-id/workers/services/:scriptName", + () => { + return HttpResponse.json( + createFetchResult({ + default_environment: { + script: { last_deployed_from: "wrangler" }, + }, + }) + ); + } + ), + http.get( + "*/accounts/preview-account-id/workers/scripts/:scriptName/subdomain", + () => { + return HttpResponse.json( + createFetchResult({ enabled: true, previews_enabled: true }) + ); + } + ), + http.post( + "*/accounts/preview-account-id/workers/scripts/:scriptName/subdomain", + () => { + return HttpResponse.json( + createFetchResult({ enabled: true, previews_enabled: true }) + ); + } + ), + http.get( + "*/accounts/preview-account-id/workers/scripts/:scriptName/deployments", + () => { + return HttpResponse.json(createFetchResult({ deployments: [] })); + } + ), + http.post(temporaryPreviewAccountUrl, async () => { + previewAccountRequests += 1; + return HttpResponse.json({ + success: true, + result: { + account: { + id: "preview-account-id", + name: "Preview Account Alpha", + type: "standard", + apiToken: "preview-account-token", + tokenId: "preview-token-id", + expiresAt: "2027-01-01T00:00:00.000Z", + }, + claim: { + token: "claim-token", + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + expiresAt: "2027-01-02T00:00:00.000Z", + }, + }, + errors: [], + messages: [], + }); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).resolves.toBeUndefined(); + await expect( + runWrangler("deploy index.js --temporary") + ).resolves.toBeUndefined(); + + expect(previewAccountRequests).toBe(1); + expect(std.out).toContain("Temporary account ready:"); + expect(std.out).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" }); + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest("test-sub-domain", true, false); + mockUploadWorkerRequest({ + expectedAccountId: "preview-account-id", + }); + + // Plant a cache file that satisfies the top-level shape check but + // is missing the nested `account` and `claim` objects. Prior to + // the optional-chaining fix this crashed with a TypeError when + // reading `.account.expiresAt`. + const cachePath = path.join( + getGlobalWranglerConfigPath(), + "wrangler-temporary-account.toml" + ); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync( + cachePath, + TOML.stringify({ temporaryPreviewAccount: {} }) + ); + + let previewAccountRequests = 0; + mockTemporaryPreviewChallenge(); + msw.use( + http.get( + "*/accounts/preview-account-id/workers/services/:scriptName", + () => { + return HttpResponse.json( + createFetchResult({ + default_environment: { + script: { last_deployed_from: "wrangler" }, + }, + }) + ); + } + ), + http.post(temporaryPreviewAccountUrl, async () => { + previewAccountRequests += 1; + return HttpResponse.json({ + success: true, + result: { + account: { + id: "preview-account-id", + name: "Preview Account Alpha", + type: "standard", + apiToken: "preview-account-token", + tokenId: "preview-token-id", + expiresAt: "2027-01-01T00:00:00.000Z", + }, + claim: { + token: "claim-token", + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + expiresAt: "2027-01-02T00:00:00.000Z", + }, + }, + errors: [], + messages: [], + }); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).resolves.toBeUndefined(); + + expect(previewAccountRequests).toBe(1); + expect(std.out).toContain("Account: Preview Account Alpha (created)"); + expect(TOML.parse(fs.readFileSync(cachePath, "utf-8"))).toMatchObject({ + account: { id: "preview-account-id" }, + claim: { + url: "https://dash.cloudflare.com/claim-preview?claimToken=claim-token", + }, + }); + }); + it("should not throw an error in non-TTY if 'CLOUDFLARE_API_TOKEN' & 'account_id' are in scope", async ({ expect, }) => { @@ -1005,12 +1542,72 @@ describe("deploy", () => { await expect(runWrangler("deploy index.js")).rejects.toThrowError(); - expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work. Please go to https://developers.cloudflare.com/fundamentals/api/get-started/create-token/ for instructions on how to create an api token, and assign its value to CLOUDFLARE_API_TOKEN. + expect(std.err).toContain( + "In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable" + ); + expect(std.err).toContain( + "To continue without logging in, rerun this command with" + ); + expect(std.err).toContain("--temporary"); + }); - " - `); + it("should fail clearly if the temporary preview account request fails", async ({ + expect, + }) => { + setIsTTY(true); + mockPrompt({ text: TEMPORARY_TERMS_PROMPT, result: "yes" }); + writeWranglerConfig(); + writeWorkerSource(); + + mockTemporaryPreviewChallenge(); + msw.use( + http.post(temporaryPreviewAccountUrl, async () => { + return new HttpResponse(null, { + status: 500, + statusText: "Internal Server Error", + }); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: 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" }); + writeWranglerConfig(); + writeWorkerSource(); + + let previewRequests = 0; + msw.use( + http.post( + `${temporaryPreviewAccountUrl}/challenge`, + () => + new HttpResponse(null, { + status: 500, + statusText: "Internal Server Error", + }) + ), + http.post(temporaryPreviewAccountUrl, async () => { + previewRequests += 1; + return HttpResponse.json({}); + }) + ); + + await expect( + runWrangler("deploy index.js --temporary") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Failed to request a proof-of-work challenge (500 Internal Server Error).]` + ); + expect(previewRequests).toBe(0); + }); + it("should throw error with no account ID provided and no members retrieved", async ({ expect, }) => { @@ -1433,7 +2030,7 @@ describe("deploy", () => { }; }); - await runWrangler("deploy --x-autoconfig --dry-run", { + await runWrangler("deploy --dry-run", { ...process.env, WRANGLER_OUTPUT_FILE_PATH: outputFile, }); diff --git a/packages/wrangler/src/__tests__/deploy/entry-points.test.ts b/packages/wrangler/src/__tests__/deploy/entry-points.test.ts index f981ec791d..e0301090de 100644 --- a/packages/wrangler/src/__tests__/deploy/entry-points.test.ts +++ b/packages/wrangler/src/__tests__/deploy/entry-points.test.ts @@ -340,7 +340,7 @@ export default{ expect(std.err).toMatchInlineSnapshot(`""`); }); - it("should not trigger autoconfig on `wrangler deploy