Skip to content

Commit 10255ea

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 913495d commit 10255ea

File tree

5 files changed

+128
-88
lines changed

5 files changed

+128
-88
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/dev/remote.tsx

+21-15
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
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 { printBundleSize } from "../bundle-reporter";
56
import { createWorkerPreview } from "../create-worker-preview";
67
import useInspector from "../inspect";
78
import { logger } from "../logger";
89
import { usePreviewServer } from "../proxy";
910
import { syncAssets } from "../sites";
10-
import { requireApiToken, requireAuth } from "../user";
11+
import { ChooseAccount, requireApiToken } from "../user";
1112
import type { CfPreviewToken } from "../create-worker-preview";
1213
import type { AssetPaths } from "../sites";
1314
import type { CfModule, CfWorkerInit, CfScriptFormat } from "../worker";
@@ -33,12 +34,13 @@ export function Remote(props: {
3334
zone: string | undefined;
3435
host: string | undefined;
3536
}) {
37+
const [accountId, setAccountId] = useState(props.accountId);
3638
const previewToken = useWorker({
3739
name: props.name,
3840
bundle: props.bundle,
3941
format: props.format,
4042
modules: props.bundle ? props.bundle.modules : [],
41-
accountId: props.accountId,
43+
accountId,
4244
bindings: props.bindings,
4345
assetPaths: props.assetPaths,
4446
isWorkersSite: props.isWorkersSite,
@@ -67,7 +69,16 @@ export function Remote(props: {
6769
port: props.inspectorPort,
6870
logToTerminal: true,
6971
});
70-
return null;
72+
73+
const errorHandler = useErrorHandler();
74+
75+
return !accountId ? (
76+
<ChooseAccount
77+
isInteractive={true}
78+
onSelect={(selectedAccountId) => setAccountId(selectedAccountId)}
79+
onError={(err) => errorHandler(err)}
80+
></ChooseAccount>
81+
) : null;
7182
}
7283

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

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

119130
if (!bundle || !format) return;
@@ -124,11 +135,6 @@ export function useWorker(props: {
124135
logger.log("⎔ Detected changes, restarted server.");
125136
}
126137

127-
// Ensure we have an account id, even if it means logging in here.
128-
accountIdRef.current = await requireAuth({
129-
account_id: accountIdRef.current,
130-
});
131-
132138
const content = await readFile(bundle.path, "utf-8");
133139

134140
// TODO: For Dev we could show the reporter message in the interactive box.
@@ -138,7 +144,7 @@ export function useWorker(props: {
138144
);
139145

140146
const assets = await syncAssets(
141-
accountIdRef.current,
147+
accountId,
142148
// When we're using the newer service environments, we wouldn't
143149
// have added the env name on to the script name. However, we must
144150
// include it in the kv namespace name regardless (since there's no
@@ -189,7 +195,7 @@ export function useWorker(props: {
189195
await createWorkerPreview(
190196
init,
191197
{
192-
accountId: accountIdRef.current,
198+
accountId,
193199
apiToken: requireApiToken(),
194200
},
195201
{
@@ -214,7 +220,7 @@ export function useWorker(props: {
214220
"Error: You need to register a workers.dev subdomain before running the dev command in remote mode";
215221
const solutionMessage =
216222
"You can either enable local mode by pressing l, or register a workers.dev subdomain here:";
217-
const onboardingLink = `https://dash.cloudflare.com/${accountIdRef.current}/workers/onboarding`;
223+
const onboardingLink = `https://dash.cloudflare.com/${accountId}/workers/onboarding`;
218224
logger.error(
219225
`${errorMessage}\n${solutionMessage}\n${onboardingLink}`
220226
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Text } from "ink";
2+
import SelectInput from "ink-select-input";
3+
import React, { useEffect, useRef, useState } from "react";
4+
import { fetchListResult } from "../cfetch";
5+
import { logger } from "../logger";
6+
import { getCloudflareAccountIdFromEnv } from "./env-vars";
7+
8+
export type ChooseAccountItem = {
9+
id: string;
10+
name: string;
11+
};
12+
13+
export function ChooseAccount(props: {
14+
isInteractive: boolean;
15+
onSelect: (accountId: string) => void;
16+
onError: (error: Error) => void;
17+
}) {
18+
const [accounts, setAccounts] = useState<ChooseAccountItem[]>([]);
19+
const getAccountsPromiseRef =
20+
useRef<Promise<{ account: ChooseAccountItem }[]>>();
21+
22+
useEffect(() => {
23+
async function selectAccount() {
24+
const accountIdFromEnv = getCloudflareAccountIdFromEnv();
25+
if (accountIdFromEnv) {
26+
props.onSelect(accountIdFromEnv);
27+
} else {
28+
getAccountsPromiseRef.current ??= fetchListResult<{
29+
account: ChooseAccountItem;
30+
}>(`/memberships`);
31+
const response = await getAccountsPromiseRef.current;
32+
if (response.length === 0) {
33+
props.onError(
34+
new Error(
35+
"Failed to automatically retrieve account IDs for the logged in user.\n" +
36+
"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."
37+
)
38+
);
39+
} else if (response.length === 1) {
40+
props.onSelect(response[0].account.id);
41+
} else if (props.isInteractive) {
42+
setAccounts(response.map((x) => x.account));
43+
} else {
44+
props.onError(
45+
new Error(
46+
"More than one account available but unable to select one in non-interactive mode.\n" +
47+
`Please set the appropriate \`account_id\` in your \`wrangler.toml\` file.\n` +
48+
`Available accounts are ("<name>" - "<id>"):\n` +
49+
response
50+
.map((x) => ` "${x.account.name}" - "${x.account.id}")`)
51+
.join("\n")
52+
)
53+
);
54+
}
55+
}
56+
}
57+
selectAccount().catch((err) => props.onError(err));
58+
}, [props]);
59+
60+
return accounts.length > 0 ? (
61+
<>
62+
<Text bold>Select an account from below:</Text>
63+
<SelectInput
64+
items={accounts.map((item) => ({
65+
key: item.id,
66+
label: item.name,
67+
value: item,
68+
}))}
69+
onSelect={(item) => {
70+
logger.log(`Using account: "${item.value.name} - ${item.value.id}"`);
71+
props.onSelect(item.value.id);
72+
}}
73+
/>
74+
</>
75+
) : null;
76+
}

packages/wrangler/src/user/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./user";
22
export * from "./env-vars";
3+
export * from "./choose-account";

packages/wrangler/src/user/user.tsx

+17-73
Original file line numberDiff line numberDiff line change
@@ -215,23 +215,18 @@ import url from "node:url";
215215
import { TextEncoder } from "node:util";
216216
import TOML from "@iarna/toml";
217217
import { HostURL } from "@webcontainer/env";
218-
import { render, Text } from "ink";
219-
import SelectInput from "ink-select-input";
218+
import { render } from "ink";
220219
import Table from "ink-table";
221220
import React from "react";
222221
import { fetch } from "undici";
223-
import { fetchListResult } from "../cfetch";
224222
import { purgeConfigCaches } from "../config-cache";
225223
import { logger } from "../logger";
226224
import openInBrowser from "../open-in-browser";
227225
import { parseTOML, readFileSync } from "../parse";
228-
import {
229-
getCloudflareAccountIdFromEnv,
230-
getCloudflareAPITokenFromEnv,
231-
} from "./env-vars";
226+
import { ChooseAccount } from "./choose-account";
227+
import { getCloudflareAPITokenFromEnv } from "./env-vars";
232228
import { generateAuthUrl } from "./generate-auth-url";
233229
import { generateRandomState } from "./generate-random-state";
234-
import type { Item as SelectInputItem } from "ink-select-input/build/SelectInput";
235230
import type { ParsedUrlQuery } from "node:querystring";
236231

237232
/**
@@ -1073,72 +1068,21 @@ export async function getAccountId(
10731068
): Promise<string | undefined> {
10741069
const apiToken = getAPIToken();
10751070
if (!apiToken) return;
1076-
1077-
const accountIdFromEnv = getCloudflareAccountIdFromEnv();
1078-
if (accountIdFromEnv) {
1079-
return accountIdFromEnv;
1080-
}
1081-
1082-
let accounts: { account: ChooseAccountItem }[] = [];
1083-
let accountId: string | undefined;
1084-
accounts = await fetchListResult<{
1085-
account: ChooseAccountItem;
1086-
}>(`/memberships`);
1087-
if (accounts.length === 0) {
1088-
throw new Error(
1089-
"Failed to automatically retrieve account IDs for the logged in user.\n" +
1090-
"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."
1091-
);
1092-
}
1093-
if (accounts.length === 1) {
1094-
accountId = accounts[0].account.id;
1095-
} else if (isInteractive) {
1096-
accountId = await new Promise((resolve) => {
1097-
const accountIds = accounts.map((x) => x.account);
1098-
const { unmount } = render(
1099-
<ChooseAccount
1100-
accounts={accountIds}
1101-
onSelect={async (selected) => {
1102-
resolve(selected.value.id);
1103-
unmount();
1104-
}}
1105-
/>
1106-
);
1107-
});
1108-
} else {
1109-
throw new Error(
1110-
"More than one account available but unable to select one in non-interactive mode.\n" +
1111-
`Please set the appropriate \`account_id\` in your \`wrangler.toml\` file.\n` +
1112-
`Available accounts are ("<name>" - "<id>"):\n` +
1113-
accounts
1114-
.map((x) => ` "${x.account.name}" - "${x.account.id}")`)
1115-
.join("\n")
1116-
);
1117-
}
1118-
return accountId;
1119-
}
1120-
1121-
type ChooseAccountItem = {
1122-
id: string;
1123-
name: string;
1124-
};
1125-
export function ChooseAccount(props: {
1126-
accounts: ChooseAccountItem[];
1127-
onSelect: (item: SelectInputItem<ChooseAccountItem>) => void;
1128-
}) {
1129-
return (
1130-
<>
1131-
<Text bold>Select an account from below:</Text>
1132-
<SelectInput
1133-
items={props.accounts.map((item) => ({
1134-
key: item.id,
1135-
label: item.name,
1136-
value: item,
1137-
}))}
1138-
onSelect={props.onSelect}
1071+
return await new Promise((resolve, reject) => {
1072+
const { unmount } = render(
1073+
<ChooseAccount
1074+
isInteractive={isInteractive}
1075+
onSelect={async (selected) => {
1076+
resolve(selected);
1077+
unmount();
1078+
}}
1079+
onError={(err) => {
1080+
reject(err);
1081+
unmount();
1082+
}}
11391083
/>
1140-
</>
1141-
);
1084+
);
1085+
});
11421086
}
11431087

11441088
/**

0 commit comments

Comments
 (0)