Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/gold-forks-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Cache chosen account in memory to avoid repeated prompts

When users have multiple accounts and no `node_modules` directory exists for file caching, Wrangler (run via `npx` and equivalent commands) would prompt for account selection multiple times during a single command. Now the selected account is also stored in process memory, preventing duplicate prompts and potential issues from inconsistent account choices.
15 changes: 15 additions & 0 deletions .changeset/large-cooks-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@cloudflare/cli": patch
"create-cloudflare": patch
"miniflare": patch
"@cloudflare/vitest-pool-workers": patch
"wrangler": patch
---

chore: update undici

The following dependency versions have been updated:

| Dependency | From | To |
| ---------- | ------ | ------ |
| undici | 7.14.0 | 7.18.2 |
20 changes: 0 additions & 20 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,23 +463,3 @@ When contributing to Wrangler, please refer to the [`STYLEGUIDE.md file`](STYLEG
## Releases

We generally cut Wrangler releases on Tuesday & Thursday each week. If you need a release cut outside of the regular cadence, please reach out to the [@cloudflare/wrangler-admins](https://github.com/orgs/cloudflare/teams/wrangler-admins) team.

### Hotfix releases

Only members of `@cloudflare/wrangler` can trigger a hotfix release. A hotfix release should be treated as a solution of last resort—before use, please first check whether the fix can be applied and released using the regular Version Packages release flow.

If a hotfix release of Wrangler, Miniflare, or C3 is required, you should:

- Prepare a hotfix release PR:

- Checkout the previous release of `workers-sdk`
- Apply the changes that should be in the hotfix
- Manually, increment the patch version of the packages that should be released as part of the hotfix
- Manually, update the changelog for the package(s) being released.

- Get approvals for that PR, and make sure CI checks are passing
- Manually trigger a hotfix release from that PR using the ["Release a hotfix"](https://github.com/cloudflare/workers-sdk/actions/workflows/hotfix-release.yml) GitHub action.
- Make sure you set the dist-tag to `latest`
- Optionally, you can first publish it to the `hotfix` dist-tag on NPM in order to verify the release.
- **[CRUCIAL]** Once the hotfix release is out and verified, merge the fixes into main before the next regular release of `workers-sdk`.
- Make sure that the version number of the released package(s) on `main` are the same as the versions that were released to ensure that any subsequent changesets will bump the version correctly for the next release.
96 changes: 96 additions & 0 deletions packages/wrangler/src/__tests__/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CI } from "../is-ci";
import {
getAccountFromCache,
getAccountId,
getAuthConfigFilePath,
getOAuthTokenFromLocalState,
loginOrRefreshIfRequired,
Expand All @@ -18,9 +20,11 @@ import {
writeAuthConfigFile,
} from "../user";
import { mockConsoleMethods } from "./helpers/mock-console";
import { mockSelect } from "./helpers/mock-dialogs";
import { useMockIsTTY } from "./helpers/mock-istty";
import {
mockExchangeRefreshTokenForAccessToken,
mockGetMemberships,
mockOAuthFlow,
} from "./helpers/mock-oauth-flow";
import {
Expand Down Expand Up @@ -505,4 +509,96 @@ describe("User", () => {
expect(token).toBeUndefined();
});
});

describe("account caching", () => {
beforeEach(() => {
vi.stubEnv("CLOUDFLARE_API_TOKEN", "test-api-token");
});

it("should only prompt for account selection once when getAccountId is called multiple times", async () => {
setIsTTY(true);

// Mock the memberships API to return multiple accounts
// Note: mockGetMemberships uses { once: true }, so we need to set it up for each expected call
// But since we're testing caching, the second call should NOT hit the API
mockGetMemberships([
{
id: "membership-1",
account: { id: "account-1", name: "Account One" },
},
{
id: "membership-2",
account: { id: "account-2", name: "Account Two" },
},
]);

// Mock the select dialog - should only be called once
mockSelect({
text: "Select an account",
result: "account-1",
});

// First call - should prompt for account selection
const firstAccountId = await getAccountId({});
expect(firstAccountId).toBe("account-1");

// Verify account is cached
const cachedAccount = getAccountFromCache();
expect(cachedAccount).toEqual({ id: "account-1", name: "Account One" });

// Second call - should use cached account, not prompt again
const secondAccountId = await getAccountId({});
expect(secondAccountId).toBe("account-1");

// Third call - should still use cached account
const thirdAccountId = await getAccountId({});
expect(thirdAccountId).toBe("account-1");

// If mockSelect was called more than once, the test would fail because
// we only set up one expectation and prompts mock throws on unexpected calls
});

it("should use account_id from config without prompting", async () => {
// When config has account_id, it should be used directly without prompting
const accountId = await getAccountId({ account_id: "config-account-id" });
expect(accountId).toBe("config-account-id");

// Cache should not be populated when using config account_id
const cachedAccount = getAccountFromCache();
expect(cachedAccount).toBeUndefined();
});

it("should cache account when only one account is available (no prompt needed)", async () => {
// Mock single account - no prompt needed
mockGetMemberships([
{
id: "membership-1",
account: { id: "single-account", name: "Only Account" },
},
]);

const accountId = await getAccountId({});
expect(accountId).toBe("single-account");

// Account should still be cached even without prompting
const cachedAccount = getAccountFromCache();
expect(cachedAccount).toEqual({
id: "single-account",
name: "Only Account",
});

// Set up another membership response for verification
// (won't be called because cache is used)
mockGetMemberships([
{
id: "membership-2",
account: { id: "different-account", name: "Different" },
},
]);

// Second call should use cache
const secondAccountId = await getAccountId({});
expect(secondAccountId).toBe("single-account");
});
});
});
10 changes: 3 additions & 7 deletions packages/wrangler/src/user/choose-account.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import { configFileName, UserError } from "@cloudflare/workers-utils";
import { fetchPagedListResult } from "../cfetch";
import { getCloudflareAccountIdFromEnv } from "./auth-variables";
import type { Account } from "./shared";
import type { ComplianceConfig } from "@cloudflare/workers-utils";

export type ChooseAccountItem = {
id: string;
name: string;
};

/**
* Infer a list of available accounts for the current user.
*/
export async function getAccountChoices(
complianceConfig: ComplianceConfig
): Promise<ChooseAccountItem[]> {
): Promise<Account[]> {
const accountIdFromEnv = getCloudflareAccountIdFromEnv();
if (accountIdFromEnv) {
return [{ id: accountIdFromEnv, name: "" }];
} else {
try {
const response = await fetchPagedListResult<{
account: ChooseAccountItem;
account: Account;
}>(complianceConfig, `/memberships`);
const accounts = response.map((r) => r.account);
if (accounts.length === 0) {
Expand Down
4 changes: 4 additions & 0 deletions packages/wrangler/src/user/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Details for one of the user's accounts
*/
export type Account = { id: string; name: string };
34 changes: 17 additions & 17 deletions packages/wrangler/src/user/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ import {
import { getAccountChoices } from "./choose-account";
import { generateAuthUrl } from "./generate-auth-url";
import { generateRandomState } from "./generate-random-state";
import type { ChooseAccountItem } from "./choose-account";
import type { Account } from "./shared";
import type { ComplianceConfig } from "@cloudflare/workers-utils";
import type { ParsedUrlQuery } from "node:querystring";
import type { Response } from "undici";
Expand Down Expand Up @@ -305,6 +305,7 @@ interface State extends AuthTokens {
hasAuthCodeBeenExchangedForAccessToken?: boolean;
stateQueryParam?: string;
scopes?: Scope[];
account?: Account;
}

/**
Expand Down Expand Up @@ -1289,9 +1290,7 @@ export async function getAccountId(
value: account.id,
})),
});
const account = accounts.find(
(a) => a.id === accountID
) as ChooseAccountItem;
const account = accounts.find((a) => a.id === accountID) as Account;
saveAccountToCache({ id: account.id, name: account.name });
return accountID;
} catch (e) {
Expand Down Expand Up @@ -1349,24 +1348,25 @@ export function requireApiToken(): ApiCredentials {
}

/**
* Save the given account details to a cache
* Saves the given account details to the filesystem cache as well as the local state
*
* @param account The account to save
*/
function saveAccountToCache(account: { id: string; name: string }): void {
saveToConfigCache<{ account: { id: string; name: string } }>(
"wrangler-account.json",
{ account }
);
function saveAccountToCache(account: Account): void {
localState.account = account;
saveToConfigCache<{ account: Account }>("wrangler-account.json", { account });
}

/**
* Fetch the given account details from a cache if available
* Retrieves the account details from either the local state or the filesystem cache (in that order)
*
* @returns The cached account if present, `undefined` otherwise
*/
export function getAccountFromCache():
| undefined
| { id: string; name: string } {
return getConfigCache<{ account: { id: string; name: string } }>(
"wrangler-account.json"
).account;
export function getAccountFromCache(): undefined | Account {
if (localState.account) {
return localState.account;
}
return getConfigCache<{ account: Account }>("wrangler-account.json").account;
}

/**
Expand Down
Loading
Loading