Skip to content

Commit 2476264

Browse files
fix: do not crash in wrangler dev if user has multiple accounts
When a user has multiple accounts we show a prompt to allow the user to select which they should use. This was broken in `wrangler dev` as we were trying to start a new ink.js app (to show the prompt) from inside a running ink.js app (the UI for `wrangler dev`). This fix refactors the `ChooseAccount` component so that it can be used directly within another component. Fixes #1258
1 parent 0d6098c commit 2476264

15 files changed

+203
-203
lines changed

.changeset/proud-cougars-sell.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
fix: do not crash in `wrangler dev` if user has multiple accounts
6+
7+
When a user has multiple accounts we show a prompt to allow the user to select which they should use.
8+
This was broken in `wrangler dev` as we were trying to start a new ink.js app (to show the prompt)
9+
from inside a running ink.js app (the UI for `wrangler dev`).
10+
11+
This fix refactors the `ChooseAccount` component so that it can be used directly within another component.
12+
13+
Fixes #1258

packages/wrangler/src/__tests__/helpers/mock-oauth-flow.ts

+9-24
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import fetchMock from "jest-fetch-mock";
22
import { Request } from "undici";
3-
import { getCloudflareApiBaseUrl } from "../../cfetch";
43
import openInBrowser from "../../open-in-browser";
4+
import { setMockResponse } from "./mock-cfetch";
55
import { mockHttpServer } from "./mock-http-server";
66

7+
export function mockGetMemberships(
8+
accounts: { id: string; account: { id: string; name: string } }[]
9+
) {
10+
setMockResponse("/memberships", "GET", () => {
11+
return accounts;
12+
});
13+
}
14+
715
// the response to send when wrangler wants an oauth grant
816
let oauthGrantResponse: GrantResponseOptions | "timeout" = {};
917

@@ -86,28 +94,6 @@ export const mockOAuthFlow = () => {
8694
return outcome;
8795
};
8896

89-
const mockGetMemberships = (args: {
90-
success: boolean;
91-
result: { id: string; account: { id: string; name: string } }[];
92-
}) => {
93-
const outcome = {
94-
actual: new Request("https://example.org"),
95-
expected: new Request(`${getCloudflareApiBaseUrl()}/memberships`, {
96-
method: "GET",
97-
}),
98-
};
99-
100-
fetchMock.mockIf(outcome.expected.url, async (req) => {
101-
outcome.actual = req;
102-
return {
103-
status: 200,
104-
body: JSON.stringify(args),
105-
};
106-
});
107-
108-
return outcome;
109-
};
110-
11197
const mockGrantAccessToken = ({
11298
respondWith,
11399
}: {
@@ -173,7 +159,6 @@ export const mockOAuthFlow = () => {
173159
};
174160

175161
return {
176-
mockGetMemberships,
177162
mockGrantAccessToken,
178163
mockGrantAuthorization,
179164
mockOAuthServerCallback,

packages/wrangler/src/__tests__/helpers/mock-stdin.ts

+5
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,9 @@ class MockStdIn {
100100
}
101101
return this.chunks.shift() ?? null;
102102
}
103+
104+
/**
105+
* Used by Ink.js to set the encoding of the stdout.
106+
*/
107+
setEncoding() {}
103108
}

packages/wrangler/src/__tests__/jest.setup.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ jest.mock("../dev/dev", () => {
7777
jest.mock("../open-in-browser");
7878

7979
// Mock the functions involved in getAuthURL so we don't take snapshots of the constantly changing URL.
80-
jest.mock("../generate-auth-url", () => {
80+
jest.mock("../user/generate-auth-url", () => {
8181
return {
8282
generateRandomState: jest.fn().mockImplementation(() => "MOCK_STATE_PARAM"),
8383
generateAuthUrl: jest
@@ -100,7 +100,7 @@ jest.mock("../generate-auth-url", () => {
100100
};
101101
});
102102

103-
jest.mock("../generate-random-state", () => {
103+
jest.mock("../user/generate-random-state", () => {
104104
return {
105105
generateRandomState: jest.fn().mockImplementation(() => "MOCK_STATE_PARAM"),
106106
};

packages/wrangler/src/__tests__/publish.test.ts

+15-25
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { mockConsoleMethods, normalizeSlashes } from "./helpers/mock-console";
1515
import { mockConfirm } from "./helpers/mock-dialogs";
1616
import { useMockIsTTY } from "./helpers/mock-istty";
1717
import { mockKeyListRequest } from "./helpers/mock-kv";
18-
import { mockOAuthFlow } from "./helpers/mock-oauth-flow";
18+
import { mockGetMemberships, mockOAuthFlow } from "./helpers/mock-oauth-flow";
1919
import { runInTempDir } from "./helpers/run-in-tmp";
2020
import { runWrangler } from "./helpers/run-wrangler";
2121
import { writeWorkerSource } from "./helpers/write-worker-source";
@@ -36,7 +36,6 @@ describe("publish", () => {
3636
mockOAuthServerCallback,
3737
mockGrantAccessToken,
3838
mockGrantAuthorization,
39-
mockGetMemberships,
4039
} = mockOAuthFlow();
4140

4241
beforeEach(() => {
@@ -167,10 +166,7 @@ describe("publish", () => {
167166
mockSubDomainRequest();
168167
mockUploadWorkerRequest();
169168
mockOAuthServerCallback();
170-
mockGetMemberships({
171-
success: true,
172-
result: [],
173-
});
169+
mockGetMemberships([]);
174170

175171
await runWrangler("publish index.js");
176172

@@ -195,13 +191,10 @@ describe("publish", () => {
195191
mockSubDomainRequest();
196192
mockUploadWorkerRequest();
197193
mockOAuthServerCallback();
198-
mockGetMemberships({
199-
success: true,
200-
result: [
201-
{ id: "IG-88", account: { id: "1701", name: "enterprise" } },
202-
{ id: "R2-D2", account: { id: "nx01", name: "enterprise-nx" } },
203-
],
204-
});
194+
mockGetMemberships([
195+
{ id: "IG-88", account: { id: "1701", name: "enterprise" } },
196+
{ id: "R2-D2", account: { id: "nx01", name: "enterprise-nx" } },
197+
]);
205198

206199
await expect(runWrangler("publish index.js")).rejects
207200
.toMatchInlineSnapshot(`
@@ -226,13 +219,10 @@ describe("publish", () => {
226219
mockSubDomainRequest();
227220
mockUploadWorkerRequest();
228221
mockOAuthServerCallback();
229-
mockGetMemberships({
230-
success: true,
231-
result: [
232-
{ id: "IG-88", account: { id: "1701", name: "enterprise" } },
233-
{ id: "R2-D2", account: { id: "nx01", name: "enterprise-nx" } },
234-
],
235-
});
222+
mockGetMemberships([
223+
{ id: "IG-88", account: { id: "1701", name: "enterprise" } },
224+
{ id: "R2-D2", account: { id: "nx01", name: "enterprise-nx" } },
225+
]);
236226

237227
await expect(runWrangler("publish index.js")).rejects.toThrowError();
238228

@@ -255,15 +245,15 @@ describe("publish", () => {
255245
mockSubDomainRequest();
256246
mockUploadWorkerRequest();
257247
mockOAuthServerCallback();
258-
mockGetMemberships({
259-
success: false,
260-
result: [],
261-
});
248+
mockGetMemberships([]);
262249

263250
await expect(runWrangler("publish index.js")).rejects.toThrowError();
264251

265252
expect(std.err).toMatchInlineSnapshot(`
266-
"X [ERROR] Failed to automatically retrieve account IDs for the logged in user. In a non-interactive environment, it is mandatory to specify an account ID, either by assigning its value to CLOUDFLARE_ACCOUNT_ID, or as \`account_id\` in your \`wrangler.toml\` file.
253+
"X [ERROR] Failed to automatically retrieve account IDs for the logged in user.
254+
255+
In a non-interactive environment, it is mandatory to specify an account ID, either by assigning
256+
its value to CLOUDFLARE_ACCOUNT_ID, or as \`account_id\` in your \`wrangler.toml\` file.
267257
268258
"
269259
`);

packages/wrangler/src/__tests__/secret.test.ts

+21-28
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@ import {
99
clearConfirmMocks,
1010
clearPromptMocks,
1111
} from "./helpers/mock-dialogs";
12-
import { mockOAuthFlow } from "./helpers/mock-oauth-flow";
12+
import { mockGetMemberships } from "./helpers/mock-oauth-flow";
1313
import { useMockStdin } from "./helpers/mock-stdin";
1414
import { runInTempDir } from "./helpers/run-in-tmp";
1515
import { runWrangler } from "./helpers/run-wrangler";
1616

1717
describe("wrangler secret", () => {
1818
const std = mockConsoleMethods();
19-
const { mockGetMemberships } = mockOAuthFlow();
2019

2120
runInTempDir();
2221
mockAccountId();
@@ -213,15 +212,12 @@ describe("wrangler secret", () => {
213212
mockAccountId({ accountId: null });
214213

215214
it("should error if a user has no account", async () => {
216-
mockGetMemberships({
217-
success: false,
218-
result: [],
219-
});
220-
await expect(
221-
runWrangler("secret put the-key --name script-name")
222-
).rejects.toThrowErrorMatchingInlineSnapshot(
223-
`"Failed to automatically retrieve account IDs for the logged in user. In a non-interactive environment, it is mandatory to specify an account ID, either by assigning its value to CLOUDFLARE_ACCOUNT_ID, or as \`account_id\` in your \`wrangler.toml\` file."`
224-
);
215+
mockGetMemberships([]);
216+
await expect(runWrangler("secret put the-key --name script-name"))
217+
.rejects.toThrowErrorMatchingInlineSnapshot(`
218+
"Failed to automatically retrieve account IDs for the logged in user.
219+
In a non-interactive environment, it is mandatory to specify an account ID, either by assigning its value to CLOUDFLARE_ACCOUNT_ID, or as \`account_id\` in your \`wrangler.toml\` file."
220+
`);
225221
});
226222

227223
it("should use the account from wrangler.toml", async () => {
@@ -244,23 +240,20 @@ describe("wrangler secret", () => {
244240
});
245241

246242
it("should error if a user has multiple accounts, and has not specified an account in wrangler.toml", async () => {
247-
mockGetMemberships({
248-
success: true,
249-
result: [
250-
{
251-
id: "1",
252-
account: { id: "account-id-1", name: "account-name-1" },
253-
},
254-
{
255-
id: "2",
256-
account: { id: "account-id-2", name: "account-name-2" },
257-
},
258-
{
259-
id: "3",
260-
account: { id: "account-id-3", name: "account-name-3" },
261-
},
262-
],
263-
});
243+
mockGetMemberships([
244+
{
245+
id: "1",
246+
account: { id: "account-id-1", name: "account-name-1" },
247+
},
248+
{
249+
id: "2",
250+
account: { id: "account-id-2", name: "account-name-2" },
251+
},
252+
{
253+
id: "3",
254+
account: { id: "account-id-3", name: "account-name-3" },
255+
},
256+
]);
264257

265258
await expect(runWrangler("secret put the-key --name script-name"))
266259
.rejects.toThrowErrorMatchingInlineSnapshot(`

packages/wrangler/src/config-cache.ts

-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ export const saveToConfigCache = <T>(
3737
configCacheLocation,
3838
JSON.stringify({ ...existingValues, ...newValues }, null, 2)
3939
);
40-
showCacheMessage();
4140
};
4241

4342
export const purgeConfigCaches = () => {

packages/wrangler/src/dev/remote.tsx

+21-15
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { readFile } from "node:fs/promises";
22
import path from "node:path";
3-
import { useState, useEffect, useRef } from "react";
3+
import React, { useState, useEffect, useRef } from "react";
4+
import { useErrorHandler } from "react-error-boundary";
45
import { createWorkerPreview } from "../create-worker-preview";
56
import useInspector from "../inspect";
67
import { logger } from "../logger";
78
import { usePreviewServer } from "../proxy";
89
import { syncAssets } from "../sites";
9-
import { requireApiToken, requireAuth } from "../user";
10+
import { ChooseAccount, requireApiToken } from "../user";
1011
import type { CfPreviewToken } from "../create-worker-preview";
1112
import type { AssetPaths } from "../sites";
1213
import type { CfModule, CfWorkerInit, CfScriptFormat } from "../worker";
@@ -32,12 +33,13 @@ export function Remote(props: {
3233
zone: string | undefined;
3334
host: string | undefined;
3435
}) {
36+
const [accountId, setAccountId] = useState(props.accountId);
3537
const previewToken = useWorker({
3638
name: props.name,
3739
bundle: props.bundle,
3840
format: props.format,
3941
modules: props.bundle ? props.bundle.modules : [],
40-
accountId: props.accountId,
42+
accountId,
4143
bindings: props.bindings,
4244
assetPaths: props.assetPaths,
4345
isWorkersSite: props.isWorkersSite,
@@ -66,7 +68,16 @@ export function Remote(props: {
6668
port: props.inspectorPort,
6769
logToTerminal: true,
6870
});
69-
return null;
71+
72+
const errorHandler = useErrorHandler();
73+
74+
return !accountId ? (
75+
<ChooseAccount
76+
isInteractive={true}
77+
onSelect={(selectedAccountId) => setAccountId(selectedAccountId)}
78+
onError={(err) => errorHandler(err)}
79+
></ChooseAccount>
80+
) : null;
7081
}
7182

7283
export function useWorker(props: {
@@ -106,13 +117,13 @@ export function useWorker(props: {
106117
// something's "happened" in our system; We make a ref and
107118
// mark it once we log our initial message. Refs are vars!
108119
const startedRef = useRef(false);
109-
// This ref holds the actual accountId being used, the `accountId` prop could be undefined
110-
// as it is only what is retrieved from the wrangler.toml config.
111-
const accountIdRef = useRef(accountId);
112120

113121
useEffect(() => {
114122
const abortController = new AbortController();
115123
async function start() {
124+
if (accountId === undefined) {
125+
return;
126+
}
116127
setToken(undefined); // reset token in case we're re-running
117128

118129
if (!bundle || !format) return;
@@ -123,13 +134,8 @@ export function useWorker(props: {
123134
logger.log("⎔ Detected changes, restarted server.");
124135
}
125136

126-
// Ensure we have an account id, even if it means logging in here.
127-
accountIdRef.current = await requireAuth({
128-
account_id: accountIdRef.current,
129-
});
130-
131137
const assets = await syncAssets(
132-
accountIdRef.current,
138+
accountId,
133139
// When we're using the newer service environments, we wouldn't
134140
// have added the env name on to the script name. However, we must
135141
// include it in the kv namespace name regardless (since there's no
@@ -182,7 +188,7 @@ export function useWorker(props: {
182188
await createWorkerPreview(
183189
init,
184190
{
185-
accountId: accountIdRef.current,
191+
accountId,
186192
apiToken: requireApiToken(),
187193
},
188194
{
@@ -207,7 +213,7 @@ export function useWorker(props: {
207213
"Error: You need to register a workers.dev subdomain before running the dev command in remote mode";
208214
const solutionMessage =
209215
"You can either enable local mode by pressing l, or register a workers.dev subdomain here:";
210-
const onboardingLink = `https://dash.cloudflare.com/${accountIdRef.current}/workers/onboarding`;
216+
const onboardingLink = `https://dash.cloudflare.com/${accountId}/workers/onboarding`;
211217
logger.error(
212218
`${errorMessage}\n${solutionMessage}\n${onboardingLink}`
213219
);

packages/wrangler/src/pages.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1855,7 +1855,7 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
18551855
account_id: accountId,
18561856
});
18571857

1858-
render(<Table data={data}></Table>);
1858+
render(<Table data={data}></Table>, { patchConsole: false });
18591859
}
18601860
)
18611861
.command(
@@ -2030,7 +2030,7 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
20302030
account_id: accountId,
20312031
});
20322032

2033-
render(<Table data={data}></Table>);
2033+
render(<Table data={data}></Table>, { patchConsole: false });
20342034
}
20352035
)
20362036
.command({

0 commit comments

Comments
 (0)