From 84d36e3b1b15260ee377e48a39395beccbbfecfd Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Mon, 16 Mar 2026 21:29:39 -0400 Subject: [PATCH 1/4] feat: support switching to a known user --- src/commands/switch/index.ts | 1 + src/commands/switch/switch.ts | 16 +++- tests/unit/commands/switch/switch.test.ts | 97 +++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/unit/commands/switch/switch.test.ts diff --git a/src/commands/switch/index.ts b/src/commands/switch/index.ts index 49c4a1b59e6..3f94c3d0c04 100644 --- a/src/commands/switch/index.ts +++ b/src/commands/switch/index.ts @@ -6,6 +6,7 @@ export const createSwitchCommand = (program: BaseCommand) => program .command('switch') .description('Switch your active Netlify account') + .option('--email ', 'Switch to the account matching this email address') .action(async (options: OptionValues, command: BaseCommand) => { const { switchCommand } = await import('./switch.js') await switchCommand(options, command) diff --git a/src/commands/switch/switch.ts b/src/commands/switch/switch.ts index 8ca42b8a6bb..6c2393fa295 100644 --- a/src/commands/switch/switch.ts +++ b/src/commands/switch/switch.ts @@ -7,7 +7,7 @@ import { login } from '../login/login.js' const LOGIN_NEW = 'I would like to login to a new account' -export const switchCommand = async (_options: OptionValues, command: BaseCommand) => { +export const switchCommand = async (options: OptionValues, command: BaseCommand) => { const availableUsersChoices = Object.values(command.netlify.globalConfig.get('users') || {}).reduce( (prev, current) => // @ts-expect-error TS(2769) FIXME: No overload matches this call. @@ -15,6 +15,20 @@ export const switchCommand = async (_options: OptionValues, command: BaseCommand {}, ) + if (options.email) { + const matchedAccount = Object.entries(availableUsersChoices as Record).find(([, label]) => + label.includes(options.email), + ) + if (matchedAccount) { + command.netlify.globalConfig.set('userId', matchedAccount[0]) + log('') + log(`You're now using ${chalk.bold(matchedAccount[1])}.`) + return + } + log(`No account found matching ${chalk.bold(options.email)}, showing all available accounts.`) + log('') + } + const { accountSwitchChoice } = await inquirer.prompt([ { type: 'list', diff --git a/tests/unit/commands/switch/switch.test.ts b/tests/unit/commands/switch/switch.test.ts new file mode 100644 index 00000000000..ca466f0fba7 --- /dev/null +++ b/tests/unit/commands/switch/switch.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +const { logMessages, mockPrompt, mockLogin } = vi.hoisted(() => ({ + logMessages: [] as string[], + mockPrompt: vi.fn(), + mockLogin: vi.fn(), +})) + +vi.mock('inquirer', () => ({ + default: { prompt: mockPrompt }, +})) + +vi.mock('../../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual('../../../../src/utils/command-helpers.js')), + log: (...args: string[]) => { + logMessages.push(args.join(' ')) + }, +})) + +vi.mock('../../../../src/commands/login/login.js', () => ({ + login: mockLogin, +})) + +import { switchCommand } from '../../../../src/commands/switch/switch.js' + +const users = { + 'user-1': { id: 'user-1', name: 'Alice', email: 'alice@example.com' }, + 'user-2': { id: 'user-2', name: 'Bob', email: 'bob@corp.com' }, +} + +const createCommand = (usersData = users) => { + const mockSet = vi.fn() + const command = { + netlify: { + globalConfig: { + get: vi.fn().mockReturnValue(usersData), + set: mockSet, + }, + }, + } + return { command, mockSet } +} + +describe('switchCommand', () => { + beforeEach(() => { + logMessages.length = 0 + vi.clearAllMocks() + }) + + test('--email auto-switches when a match is found', async () => { + const { command, mockSet } = createCommand() + + await switchCommand({ email: 'alice@example.com' }, command as any) + + expect(mockSet).toHaveBeenCalledWith('userId', 'user-1') + expect(logMessages.some((m) => m.includes('Alice'))).toBe(true) + expect(mockPrompt).not.toHaveBeenCalled() + }) + + test('--email falls through to prompt when no match is found', async () => { + const { command } = createCommand() + mockPrompt.mockResolvedValueOnce({ accountSwitchChoice: 'Bob (bob@corp.com)' }) + + await switchCommand({ email: 'nobody@example.com' }, command as any) + + expect(logMessages.some((m) => m.includes('No account found matching'))).toBe(true) + expect(mockPrompt).toHaveBeenCalled() + }) + + test('--email matches partial email strings', async () => { + const { command, mockSet } = createCommand() + + await switchCommand({ email: 'bob@corp' }, command as any) + + expect(mockSet).toHaveBeenCalledWith('userId', 'user-2') + expect(mockPrompt).not.toHaveBeenCalled() + }) + + test('without --email shows interactive prompt', async () => { + const { command, mockSet } = createCommand() + mockPrompt.mockResolvedValueOnce({ accountSwitchChoice: 'Alice (alice@example.com)' }) + + await switchCommand({}, command as any) + + expect(mockPrompt).toHaveBeenCalled() + expect(mockSet).toHaveBeenCalledWith('userId', 'user-1') + }) + + test('selecting login new triggers login flow', async () => { + const { command } = createCommand() + mockPrompt.mockResolvedValueOnce({ accountSwitchChoice: 'I would like to login to a new account' }) + + await switchCommand({}, command as any) + + expect(mockLogin).toHaveBeenCalledWith({ new: true }, command) + }) +}) From 70ef77645f34175680ee82689ab6f11711c1e9e2 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Mon, 16 Mar 2026 22:19:03 -0400 Subject: [PATCH 2/4] fix: linting --- docs/commands/switch.md | 1 + tests/unit/commands/switch/switch.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/commands/switch.md b/docs/commands/switch.md index 97f8c1bf0f4..8d08756d7c1 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -18,6 +18,7 @@ netlify switch **Flags** +- `email` (*string*) - Switch to the account matching this email address - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in diff --git a/tests/unit/commands/switch/switch.test.ts b/tests/unit/commands/switch/switch.test.ts index ca466f0fba7..8f50a709d8a 100644 --- a/tests/unit/commands/switch/switch.test.ts +++ b/tests/unit/commands/switch/switch.test.ts @@ -37,7 +37,7 @@ const createCommand = (usersData = users) => { set: mockSet, }, }, - } + } as unknown as Parameters[1] return { command, mockSet } } @@ -50,7 +50,7 @@ describe('switchCommand', () => { test('--email auto-switches when a match is found', async () => { const { command, mockSet } = createCommand() - await switchCommand({ email: 'alice@example.com' }, command as any) + await switchCommand({ email: 'alice@example.com' }, command) expect(mockSet).toHaveBeenCalledWith('userId', 'user-1') expect(logMessages.some((m) => m.includes('Alice'))).toBe(true) @@ -61,7 +61,7 @@ describe('switchCommand', () => { const { command } = createCommand() mockPrompt.mockResolvedValueOnce({ accountSwitchChoice: 'Bob (bob@corp.com)' }) - await switchCommand({ email: 'nobody@example.com' }, command as any) + await switchCommand({ email: 'nobody@example.com' }, command) expect(logMessages.some((m) => m.includes('No account found matching'))).toBe(true) expect(mockPrompt).toHaveBeenCalled() @@ -70,7 +70,7 @@ describe('switchCommand', () => { test('--email matches partial email strings', async () => { const { command, mockSet } = createCommand() - await switchCommand({ email: 'bob@corp' }, command as any) + await switchCommand({ email: 'bob@corp' }, command) expect(mockSet).toHaveBeenCalledWith('userId', 'user-2') expect(mockPrompt).not.toHaveBeenCalled() @@ -80,7 +80,7 @@ describe('switchCommand', () => { const { command, mockSet } = createCommand() mockPrompt.mockResolvedValueOnce({ accountSwitchChoice: 'Alice (alice@example.com)' }) - await switchCommand({}, command as any) + await switchCommand({}, command) expect(mockPrompt).toHaveBeenCalled() expect(mockSet).toHaveBeenCalledWith('userId', 'user-1') @@ -90,7 +90,7 @@ describe('switchCommand', () => { const { command } = createCommand() mockPrompt.mockResolvedValueOnce({ accountSwitchChoice: 'I would like to login to a new account' }) - await switchCommand({}, command as any) + await switchCommand({}, command) expect(mockLogin).toHaveBeenCalledWith({ new: true }, command) }) From ecad52de552ea3757ac8114c6419b117ceeefd99 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Tue, 17 Mar 2026 10:08:14 -0400 Subject: [PATCH 3/4] fix: use exact email matching --- src/commands/switch/switch.ts | 17 +++++++++-------- tests/unit/commands/switch/switch.test.ts | 9 +++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/commands/switch/switch.ts b/src/commands/switch/switch.ts index 6c2393fa295..14a7ba9da02 100644 --- a/src/commands/switch/switch.ts +++ b/src/commands/switch/switch.ts @@ -8,21 +8,22 @@ import { login } from '../login/login.js' const LOGIN_NEW = 'I would like to login to a new account' export const switchCommand = async (options: OptionValues, command: BaseCommand) => { - const availableUsersChoices = Object.values(command.netlify.globalConfig.get('users') || {}).reduce( + const users = (command.netlify.globalConfig.get('users') || {}) as Record< + string, + { id: string; name?: string; email: string } + > + const availableUsersChoices = Object.values(users).reduce>( (prev, current) => - // @ts-expect-error TS(2769) FIXME: No overload matches this call. Object.assign(prev, { [current.id]: current.name ? `${current.name} (${current.email})` : current.email }), {}, ) if (options.email) { - const matchedAccount = Object.entries(availableUsersChoices as Record).find(([, label]) => - label.includes(options.email), - ) - if (matchedAccount) { - command.netlify.globalConfig.set('userId', matchedAccount[0]) + const matchedUser = Object.values(users).find((user) => user.email === options.email) + if (matchedUser) { + command.netlify.globalConfig.set('userId', matchedUser.id) log('') - log(`You're now using ${chalk.bold(matchedAccount[1])}.`) + log(`You're now using ${chalk.bold(availableUsersChoices[matchedUser.id])}.`) return } log(`No account found matching ${chalk.bold(options.email)}, showing all available accounts.`) diff --git a/tests/unit/commands/switch/switch.test.ts b/tests/unit/commands/switch/switch.test.ts index 8f50a709d8a..9a0d14cf371 100644 --- a/tests/unit/commands/switch/switch.test.ts +++ b/tests/unit/commands/switch/switch.test.ts @@ -67,13 +67,14 @@ describe('switchCommand', () => { expect(mockPrompt).toHaveBeenCalled() }) - test('--email matches partial email strings', async () => { - const { command, mockSet } = createCommand() + test('--email does not match partial email strings', async () => { + const { command } = createCommand() + mockPrompt.mockResolvedValueOnce({ accountSwitchChoice: 'Bob (bob@corp.com)' }) await switchCommand({ email: 'bob@corp' }, command) - expect(mockSet).toHaveBeenCalledWith('userId', 'user-2') - expect(mockPrompt).not.toHaveBeenCalled() + expect(logMessages.some((m) => m.includes('No account found matching'))).toBe(true) + expect(mockPrompt).toHaveBeenCalled() }) test('without --email shows interactive prompt', async () => { From 09c692532c228558e8aa9386f2c2ec02b3a3db58 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Tue, 17 Mar 2026 13:47:15 -0400 Subject: [PATCH 4/4] fix: ts errors --- src/commands/switch/switch.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/commands/switch/switch.ts b/src/commands/switch/switch.ts index 14a7ba9da02..3683980d9ff 100644 --- a/src/commands/switch/switch.ts +++ b/src/commands/switch/switch.ts @@ -35,7 +35,6 @@ export const switchCommand = async (options: OptionValues, command: BaseCommand) type: 'list', name: 'accountSwitchChoice', message: 'Please select the account you want to use:', - // @ts-expect-error TS(2769) FIXME: No overload matches this call. choices: [...Object.entries(availableUsersChoices).map(([, val]) => val), LOGIN_NEW], }, ]) @@ -43,7 +42,6 @@ export const switchCommand = async (options: OptionValues, command: BaseCommand) if (accountSwitchChoice === LOGIN_NEW) { await login({ new: true }, command) } else { - // @ts-expect-error TS(2769) FIXME: No overload matches this call. const selectedAccount = Object.entries(availableUsersChoices).find( ([, availableUsersChoice]) => availableUsersChoice === accountSwitchChoice, )