Skip to content

Commit 137a97f

Browse files
committed
Add multi-account selection and switching for OAuth login
After authenticating via device flow, the CLI now fetches all accounts the user belongs to and prompts them to choose one. Account switching reuses the existing OAuth token to discover and add remote accounts without re-authenticating.
1 parent 05b8ae1 commit 137a97f

11 files changed

Lines changed: 387 additions & 133 deletions

File tree

src/commands/accounts/login.ts

Lines changed: 65 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { BaseFlags } from "../../types/cli.js";
1010
import { displayLogo } from "../../utils/logo.js";
1111
import openUrl from "../../utils/open-url.js";
1212
import { promptForConfirmation } from "../../utils/prompt-confirmation.js";
13+
import { slugifyAccountName } from "../../utils/slugify.js";
1314

14-
// Moved function definition outside the class
1515
function validateAndGetAlias(
1616
input: string,
1717
logFn: (msg: string) => void,
@@ -91,38 +91,62 @@ export default class AccountsLogin extends ControlBaseCommand {
9191
accessToken = oauthTokens.accessToken;
9292
}
9393

94-
// If no alias flag provided, prompt the user
95-
let { alias } = flags;
96-
if (!alias && !this.shouldOutputJson(flags)) {
97-
alias = await this.resolveAlias();
98-
} else if (!alias) {
99-
alias = "default";
100-
}
101-
10294
try {
10395
// Fetch account information
10496
const controlApi = new ControlApi({
10597
accessToken,
10698
controlHost: flags["control-host"],
10799
});
108100

109-
const { account, user } = await controlApi.getMe();
101+
const [{ user }, accounts] = await Promise.all([
102+
controlApi.getMe(),
103+
controlApi.getAccounts(),
104+
]);
105+
106+
let selectedAccountInfo: { id: string; name: string };
107+
108+
if (accounts.length === 1) {
109+
selectedAccountInfo = accounts[0];
110+
} else if (accounts.length > 1 && !this.shouldOutputJson(flags)) {
111+
const picked =
112+
await this.interactiveHelper.selectAccountFromApi(accounts);
113+
selectedAccountInfo = picked ?? accounts[0];
114+
} else {
115+
// Multiple accounts in JSON mode or empty (backward compat: use first)
116+
selectedAccountInfo = accounts[0];
117+
}
118+
119+
// Resolve alias AFTER account selection so we can default to account name
120+
let { alias } = flags;
121+
if (!alias && !this.shouldOutputJson(flags)) {
122+
alias = await this.resolveAlias(selectedAccountInfo.name);
123+
} else if (!alias) {
124+
alias = slugifyAccountName(selectedAccountInfo.name);
125+
}
110126

111127
// Store based on auth method
112128
if (oauthTokens) {
113129
this.configManager.storeOAuthTokens(alias, oauthTokens, {
114-
accountId: account.id,
115-
accountName: account.name,
130+
accountId: selectedAccountInfo.id,
131+
accountName: selectedAccountInfo.name,
116132
});
117133
} else {
118134
this.configManager.storeAccount(accessToken, alias, {
119-
accountId: account.id,
120-
accountName: account.name,
135+
accountId: selectedAccountInfo.id,
136+
accountName: selectedAccountInfo.name,
121137
tokenId: "unknown",
122138
userEmail: user.email,
123139
});
124140
}
125141

142+
// Persist control host so other commands (like switch) can use it
143+
if (flags["control-host"]) {
144+
this.configManager.setAccountControlHost(
145+
alias,
146+
flags["control-host"] as string,
147+
);
148+
}
149+
126150
// Switch to this account
127151
this.configManager.switchAccount(alias);
128152

@@ -233,8 +257,8 @@ export default class AccountsLogin extends ControlBaseCommand {
233257
const response: Record<string, unknown> = {
234258
account: {
235259
alias,
236-
id: account.id,
237-
name: account.name,
260+
id: selectedAccountInfo.id,
261+
name: selectedAccountInfo.name,
238262
user: { email: user.email },
239263
},
240264
authMethod: oauthTokens ? "oauth" : "token",
@@ -257,7 +281,7 @@ export default class AccountsLogin extends ControlBaseCommand {
257281
this.log(this.formatJsonOutput(response, flags));
258282
} else {
259283
this.log(
260-
`Successfully logged in to ${chalk.cyan(account.name)} (account ID: ${chalk.greenBright(account.id)})`,
284+
`Successfully logged in to ${chalk.cyan(selectedAccountInfo.name)} (account ID: ${chalk.greenBright(selectedAccountInfo.id)})`,
261285
);
262286
if (oauthTokens) {
263287
this.log(`Authenticated via OAuth (token auto-refreshes)`);
@@ -354,72 +378,52 @@ export default class AccountsLogin extends ControlBaseCommand {
354378
}
355379
}
356380

357-
private async resolveAlias(): Promise<string> {
358-
const accounts = this.configManager.listAccounts();
359-
const hasDefaultAccount = accounts.some(
360-
(account) => account.alias === "default",
381+
private async resolveAlias(accountName: string): Promise<string> {
382+
const defaultAlias = slugifyAccountName(accountName);
383+
const existingAccounts = this.configManager.listAccounts();
384+
const aliasExists = existingAccounts.some(
385+
(a) => a.alias === defaultAlias,
361386
);
362387

363-
if (hasDefaultAccount) {
364-
this.log("\nYou have not specified an alias for this account.");
365-
this.log(
366-
"If you continue without an alias, your existing default account configuration will be overwritten.",
367-
);
388+
if (aliasExists) {
368389
this.log(
369-
"To maintain multiple account profiles, please provide an alias.",
390+
`\nAn account with alias "${defaultAlias}" already exists and will be overwritten.`,
370391
);
371-
372-
const shouldProvideAlias = await promptForConfirmation(
373-
"Would you like to provide an alias for this account?",
392+
const shouldCustomize = await promptForConfirmation(
393+
"Would you like to use a different alias?",
374394
);
375-
376-
if (shouldProvideAlias) {
377-
return this.promptForAlias();
395+
if (shouldCustomize) {
396+
return this.promptForAlias(defaultAlias);
378397
}
379-
this.log(
380-
"No alias provided. The default account configuration will be overwritten.",
381-
);
382-
return "default";
398+
return defaultAlias;
383399
}
384400

385-
this.log("\nYou have not specified an alias for this account.");
386-
this.log(
387-
"Using an alias allows you to maintain multiple account profiles that you can switch between.",
388-
);
389-
390-
const shouldProvideAlias = await promptForConfirmation(
391-
"Would you like to provide an alias for this account?",
392-
);
393-
394-
if (shouldProvideAlias) {
395-
return this.promptForAlias();
396-
}
397-
this.log("No alias provided. This will be set as your default account.");
398-
return "default";
401+
return this.promptForAlias(defaultAlias);
399402
}
400403

401-
private promptForAlias(): Promise<string> {
404+
private promptForAlias(defaultAlias: string): Promise<string> {
402405
const rl = readline.createInterface({
403406
input: process.stdin,
404407
output: process.stdout,
405408
});
406409

407-
// Pass this.log as the logging function to the external validator
408410
const logFn = this.log.bind(this);
409411

410412
return new Promise((resolve) => {
411413
const askForAlias = () => {
412414
rl.question(
413-
'Enter an alias for this account (e.g. "dev", "production", "personal"): ',
414-
(alias) => {
415-
// Use the external validator function, passing the log function
416-
const validatedAlias = validateAndGetAlias(alias, logFn);
415+
`Enter an alias for this account [${defaultAlias}]: `,
416+
(input) => {
417+
// Accept default on empty input
418+
if (!input.trim()) {
419+
rl.close();
420+
resolve(defaultAlias);
421+
return;
422+
}
417423

418-
if (validatedAlias === null) {
419-
if (!alias.trim()) {
420-
logFn("Error: Alias cannot be empty"); // Use logFn here too
421-
}
424+
const validatedAlias = validateAndGetAlias(input, logFn);
422425

426+
if (validatedAlias === null) {
423427
askForAlias();
424428
} else {
425429
rl.close();

0 commit comments

Comments
 (0)