diff --git a/packages/catalyst/src/cli/commands/auth.spec.ts b/packages/catalyst/src/cli/commands/auth.spec.ts index 5ec3ff278..314dfc4f2 100644 --- a/packages/catalyst/src/cli/commands/auth.spec.ts +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -1,4 +1,4 @@ -import { password } from '@inquirer/prompts'; +import { confirm, input, password } from '@inquirer/prompts'; import { Command } from 'commander'; import { http, HttpResponse } from 'msw'; import { realpath } from 'node:fs/promises'; @@ -27,9 +27,13 @@ import { auth } from './auth'; vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + input: vi.fn(), password: vi.fn(), })); +const confirmMock = vi.mocked(confirm); +const inputMock = vi.mocked(input); const passwordMock = vi.mocked(password); let exitMock: MockInstance; @@ -163,25 +167,16 @@ describe('login', () => { ), ); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); passwordMock.mockResolvedValueOnce('manual-access-token'); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - expect(message).toContain('Try logging in manually'); - expect(opts).toMatchObject({ type: 'confirm' }); - - return Promise.resolve(true); - }) - .mockImplementationOnce(async (message, opts) => { - expect(message).toBe('Store hash:'); - expect(opts).toMatchObject({ type: 'text' }); - - return Promise.resolve('manual-store-hash'); - }); - await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(confirmMock.mock.calls[0]?.[0].message).toContain('Try logging in manually'); + expect(inputMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'Store hash:' })); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work")); expect(consola.success).toHaveBeenCalledWith('Logged in to store manual-store-hash.'); expect(exitMock).toHaveBeenCalledWith(0); @@ -190,8 +185,6 @@ describe('login', () => { expect(config.get('storeHash')).toBe('manual-store-hash'); expect(config.get('accessToken')).toBe('manual-access-token'); - - promptMock.mockRestore(); }); test('exits cleanly when user declines manual login fallback', async () => { @@ -202,7 +195,7 @@ describe('login', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); await program.parseAsync(['node', 'catalyst', 'auth', 'login']); @@ -211,8 +204,6 @@ describe('login', () => { 'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.', ); expect(exitMock).toHaveBeenCalledWith(0); - - promptMock.mockRestore(); }); test('fails when manual credentials cannot be validated', async () => { @@ -227,21 +218,16 @@ describe('login', () => { ), ); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); passwordMock.mockResolvedValueOnce('bad-token'); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce('manual-store-hash'); - await program.parseAsync(['node', 'catalyst', 'auth', 'login']); expect(consola.error).toHaveBeenCalledWith( expect.stringContaining('Could not validate credentials'), ); expect(exitMock).toHaveBeenCalledWith(1); - - promptMock.mockRestore(); }); test('rejects empty store hash during manual login', async () => { @@ -252,17 +238,13 @@ describe('login', () => { ), ); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(' '); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce(' '); await program.parseAsync(['node', 'catalyst', 'auth', 'login']); expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Store hash is required')); expect(exitMock).toHaveBeenCalledWith(1); - - promptMock.mockRestore(); }); test('rejects empty access token during manual login', async () => { @@ -273,19 +255,14 @@ describe('login', () => { ), ); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); passwordMock.mockResolvedValueOnce(' '); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce('manual-store-hash'); - await program.parseAsync(['node', 'catalyst', 'auth', 'login']); expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Access token is required')); expect(exitMock).toHaveBeenCalledWith(1); - - promptMock.mockRestore(); }); test('handles browser open failure gracefully', async () => { diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts index 664ad0b3c..04cf558cd 100644 --- a/packages/catalyst/src/cli/commands/channel.spec.ts +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -1,3 +1,4 @@ +import { confirm, select } from '@inquirer/prompts'; import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; @@ -13,12 +14,20 @@ import { program } from '../program'; import { channel } from './channel'; +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), +})); // `channel link` can trigger the interactive device-code login (browser + // spinner); stub both so the no-credentials path runs headless in tests. vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); // eslint-disable-next-line import/dynamic-import-chunkname vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); +const mockSelect = vi.mocked(select); +const mockConfirm = vi.mocked(confirm); + let exitMock: MockInstance; let tmpDir: string; @@ -122,10 +131,7 @@ describe('channel update', () => { ), ); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') - .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await program.parseAsync([ 'node', @@ -140,7 +146,7 @@ describe('channel update', () => { linkedProjectUuid, ]); - expect(promptMock).toHaveBeenCalledTimes(2); + expect(mockSelect).toHaveBeenCalledTimes(2); expect(putChannelId).toBe('2'); expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); expect(consola.success).toHaveBeenCalledWith( @@ -152,10 +158,7 @@ describe('channel update', () => { test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => { config.set('projectUuid', linkedProjectUuid); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') - .mockResolvedValueOnce('vanity.project-one.example.com'); + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com'); await program.parseAsync([ 'node', @@ -168,7 +171,7 @@ describe('channel update', () => { accessToken, ]); - expect(promptMock).toHaveBeenCalledTimes(2); + expect(mockSelect).toHaveBeenCalledTimes(2); expect(consola.success).toHaveBeenCalledWith( expect.stringContaining('https://vanity.project-one.example.com'), ); @@ -192,8 +195,6 @@ describe('channel update', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt'); - await program.parseAsync([ 'node', 'catalyst', @@ -211,7 +212,7 @@ describe('channel update', () => { 'override.example', ]); - expect(promptMock).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); expect(putChannelId).toBe('5'); expect(putBody).toEqual({ url: 'https://override.example' }); }); @@ -224,7 +225,7 @@ describe('channel update', () => { ); // First prompt: "Would you like to create one?" — user says no - vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + mockConfirm.mockResolvedValueOnce(false); await program.parseAsync([ 'node', @@ -248,9 +249,7 @@ describe('channel update', () => { ), ); - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') - .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await expect( program.parseAsync([ @@ -330,7 +329,7 @@ describe('channel link', () => { }), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + mockSelect.mockResolvedValueOnce(2); await program.parseAsync([ 'node', @@ -343,7 +342,7 @@ describe('channel link', () => { accessToken, ]); - expect(promptMock).toHaveBeenCalledTimes(1); + expect(mockSelect).toHaveBeenCalledTimes(1); expect(initChannelId).toBe('2'); // id 2 in the default channels handler is "Catalyst Storefront". expect(consola.success).toHaveBeenCalledWith( diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts index a1fc9e2c7..2c6313c18 100644 --- a/packages/catalyst/src/cli/commands/channel.ts +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -1,3 +1,4 @@ +import { select } from '@inquirer/prompts'; import { Command, InvalidArgumentError, Option } from 'commander'; import type Conf from 'conf'; import { colorize } from 'consola/utils'; @@ -200,17 +201,15 @@ Examples: return; } - const selected = await consola.prompt('Which channel would you like to link?', { - type: 'select', - options: sortChannelsByPlatform(channels).map((c) => ({ - label: c.name, - value: String(c.id), - hint: channelPlatformLabel(c.platform), + channelId = await select({ + message: 'Which channel would you like to link?', + choices: sortChannelsByPlatform(channels).map((c) => ({ + name: c.name, + value: c.id, + description: channelPlatformLabel(c.platform), })), - cancel: 'reject', }); - channelId = Number(selected); channelName = channels.find((c) => c.id === channelId)?.name; } @@ -241,7 +240,7 @@ Examples: consola.success( `Linked to channel ${label} and wrote ${colorize('cyanBright', '.env.local')}.`, ); - consola.log(`\nStart your storefront:\n\n ${colorize('yellow', 'pnpm run dev')}\n`); + consola.log(`Next steps:\n\n ${colorize('yellow', 'pnpm run dev')}`); process.exit(0); }); diff --git a/packages/catalyst/src/cli/commands/create.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts index 5f0cba544..70344f62b 100644 --- a/packages/catalyst/src/cli/commands/create.spec.ts +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -22,6 +22,7 @@ import { create } from './create'; vi.mock('child_process', () => ({ execSync: vi.fn() })); vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), input: vi.fn(), select: vi.fn(), })); diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts index 297591df6..3d213f70c 100644 --- a/packages/catalyst/src/cli/commands/create.ts +++ b/packages/catalyst/src/cli/commands/create.ts @@ -1,5 +1,5 @@ import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings'; -import { input, select } from '@inquirer/prompts'; +import { checkbox, input, select } from '@inquirer/prompts'; import { execSync } from 'child_process'; import { colorize } from 'consola/utils'; import { pathExistsSync } from 'fs-extra/esm'; @@ -100,30 +100,15 @@ async function handleChannelCreation( let additionalLocales: string[] = []; if (shouldAddAdditionalLocales) { - const localeOptions = availableLocales + const localeChoices = availableLocales .filter(({ value }) => value !== storefrontLocale) - .map(({ name, value }) => ({ label: name, value, hint: value })); - - // consola's multiselect returns the value strings at runtime, but its typed - // return is loose (the whole option array). Recursion + cast avoids the - // no-await-in-loop / no-constant-condition lint hits and re-prompts on overflow. - const pickLocales = async (): Promise => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const selected = (await consola.prompt( - 'Which additional languages would you like to add to your channel?', - { type: 'multiselect', options: localeOptions, cancel: 'reject' }, - )) as unknown as string[]; - - if (selected.length > 4) { - consola.warn('You can only select up to 4 additional languages. Please try again.'); - - return pickLocales(); - } - - return selected; - }; + .map(({ name, value }) => ({ name, value, description: value })); - additionalLocales = await pickLocales(); + additionalLocales = await checkbox({ + message: 'Which additional languages would you like to add to your channel?', + choices: localeChoices, + validate: (items) => items.length <= 4 || 'You can only select up to 4 additional languages.', + }); } const shouldInstallSampleData = await select({ @@ -487,15 +472,16 @@ Examples: } consola.success(`Created '${projectName}' at '${projectDir}'`); - consola.info('Next steps:'); - consola.info(colorize('yellow', ` cd ${projectName}/core && pnpm run dev`)); + + const steps = [`cd ${projectName}/core && pnpm run dev`]; if (useCommerceHosting) { - consola.info( - colorize( - 'yellow', - ` Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`, - ), + steps.push( + `Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`, ); } + + consola.log( + `Next steps:\n\n${steps.map((step) => ` ${colorize('yellow', step)}`).join('\n')}`, + ); }); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 431fe9c30..ea060352d 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -1,3 +1,4 @@ +import { confirm, input, select } from '@inquirer/prompts'; import AdmZip from 'adm-zip'; import { Command } from 'commander'; import { http, HttpResponse } from 'msw'; @@ -36,6 +37,12 @@ import { uploadBundleZip, } from './deploy'; +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + input: vi.fn(), + select: vi.fn(), +})); + // eslint-disable-next-line import/dynamic-import-chunkname vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); vi.mock('./build', async (importOriginal) => { @@ -111,7 +118,9 @@ beforeAll(async () => { beforeEach(() => { process.chdir(tmpDir); - vi.spyOn(consola, 'prompt').mockResolvedValue(true); + // Default the transformation-guard confirm to "yes" so tests that don't + // exercise it proceed past the guard. + vi.mocked(confirm).mockResolvedValue(true); }); afterEach(() => { @@ -350,9 +359,8 @@ describe('linked project verification', () => { config.set('storeHash', storeHash); config.set('accessToken', accessToken); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(projectUuid)); + // The project picker is a select that returns the chosen project UUID. + vi.mocked(select).mockResolvedValue(projectUuid); await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); @@ -362,8 +370,6 @@ describe('linked project verification', () => { expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); expect(config.get('projectUuid')).toBe(projectUuid); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('prompts for a project when none is linked yet', async () => { @@ -373,9 +379,8 @@ describe('linked project verification', () => { config.set('storeHash', storeHash); config.set('accessToken', accessToken); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(projectUuid)); + // The project picker is a select that returns the chosen project UUID. + vi.mocked(select).mockResolvedValue(projectUuid); await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); @@ -383,8 +388,6 @@ describe('linked project verification', () => { expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); expect(config.get('projectUuid')).toBe(projectUuid); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('offers to create when no projects exist on the store', async () => { @@ -400,24 +403,24 @@ describe('linked project verification', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message) => { - expect(message).toContain('There are not any hosting projects that you can link to yet'); - - return Promise.resolve(true); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return Promise.resolve('My New Project'); - }); + // No projects exist → a confirm ("create one?") then an input (project name). + // The transformation-guard confirm defaults to true via beforeEach. + vi.mocked(input).mockResolvedValue('My New Project'); await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + 'There are not any hosting projects that you can link to yet', + ), + }), + ); + expect(input).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('exits gracefully with guidance when user declines to create', async () => { @@ -433,9 +436,8 @@ describe('linked project verification', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + // Declining the "create one?" confirm throws NoLinkedProjectError. + vi.mocked(confirm).mockResolvedValue(false); await expect(program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run'])).rejects.toThrow( 'No infrastructure project linked', @@ -445,8 +447,6 @@ describe('linked project verification', () => { "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", ); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); }); @@ -756,9 +756,11 @@ describe('transformation guard', () => { '--dry-run', ]); - expect(consola.prompt).toHaveBeenCalledWith( - expect.stringContaining('not yet set up for Commerce Hosting deployments'), - expect.objectContaining({ type: 'confirm' }), + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining('not yet set up for Commerce Hosting deployments'), + }), ); expect(setupCommerceHosting).toHaveBeenCalledWith({ projectDir: dirname(tmpDir), @@ -771,7 +773,7 @@ describe('transformation guard', () => { test('exits gracefully when user declines to run setup', async () => { vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); - vi.mocked(consola.prompt).mockResolvedValueOnce(false); + vi.mocked(confirm).mockResolvedValueOnce(false); // In production, process.exit halts. In tests it's mocked, so we can only // verify the user-visible signals: the guidance log and the exit code. @@ -860,10 +862,10 @@ describe('--update-site-url', () => { ), ); - // Override the default consola.prompt stub (always-true in beforeEach) with - // the channel and hostname selections the interactive flow will ask for. - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') + // The interactive flow asks two selects in order: the channel (returns the + // numeric channel id) then the hostname (returns the hostname string). + vi.mocked(select) + .mockResolvedValueOnce(2) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await program.parseAsync(deployArgs(['--update-site-url'])); @@ -876,7 +878,7 @@ describe('--update-site-url', () => { }); test('places the freshly-deployed hostname first in the hostname prompt', async () => { - let hostnameOptions: Array<{ label: string; value: string }> | undefined; + let hostnameChoices: Array<{ name: string; value: string }> | undefined; // Project Two has no hostnames by default; use Project One whose handler // already returns the two seeded hostnames. Inject the freshly-deployed @@ -898,18 +900,20 @@ describe('--update-site-url', () => { ), ); - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') // channel - .mockImplementationOnce((_message, opts) => { + vi.mocked(select) + .mockResolvedValueOnce(2) // channel + .mockImplementationOnce((config) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + const cfg = config as unknown as { choices: Array<{ name: string; value: string }> }; + + hostnameChoices = cfg.choices; - return Promise.resolve('example.com'); + return Object.assign(Promise.resolve('example.com'), { cancel: () => undefined }); }); await program.parseAsync(deployArgs(['--update-site-url'])); - expect(hostnameOptions?.[0]).toMatchObject({ value: 'example.com' }); + expect(hostnameChoices?.[0]).toMatchObject({ value: 'example.com' }); }); test('does not call the channel site API when the flag is omitted', async () => { @@ -936,8 +940,8 @@ describe('--update-site-url', () => { ), ); - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') + vi.mocked(select) + .mockResolvedValueOnce(2) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await program.parseAsync(deployArgs(['--update-site-url'])); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 7cc6a2668..2474a3601 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -1,3 +1,4 @@ +import { confirm } from '@inquirer/prompts'; import AdmZip from 'adm-zip'; import { Command, Option } from 'commander'; import { colorize } from 'consola/utils'; @@ -466,10 +467,11 @@ Example: // here so first-run `catalyst deploy` works on a fresh self-hosted scaffold // without forcing the user to re-run after a separate setup step. if (!getProjectState().isTransformed) { - const shouldSetup = await consola.prompt( - 'Your project is not yet set up for Commerce Hosting deployments. Would you like to run the Commerce Hosting setup now?', - { type: 'confirm', initial: true }, - ); + const shouldSetup = await confirm({ + message: + 'Your project is not yet set up for Commerce Hosting deployments. Would you like to run the Commerce Hosting setup now?', + default: true, + }); if (!shouldSetup) { consola.info("When you're ready to deploy, re-run `catalyst deploy` to complete setup."); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 3822261cf..a1bde9383 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -1,4 +1,4 @@ -import { password } from '@inquirer/prompts'; +import { confirm, input, password, select } from '@inquirer/prompts'; import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; @@ -30,6 +30,9 @@ vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); vi.mock('@inquirer/prompts', () => ({ password: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), + select: vi.fn(), })); vi.mock('../lib/project-state', () => ({ @@ -41,6 +44,9 @@ vi.mock('../lib/install-dependencies', () => ({ })); const passwordMock = vi.mocked(password); +const confirmMock = vi.mocked(confirm); +const inputMock = vi.mocked(input); +const selectMock = vi.mocked(select); vi.mock('../lib/commerce-hosting', async (importOriginal) => { const actual = await importOriginal(); @@ -180,7 +186,7 @@ describe('project', () => { describe('project create', () => { test('prompts for name and creates project', async () => { - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('My New Project'); + inputMock.mockResolvedValue('My New Project'); await program.parseAsync([ 'node', @@ -194,9 +200,8 @@ describe('project create', () => { ]); expect(mockIdentify).toHaveBeenCalledWith(storeHash); - expect(consolaPromptMock).toHaveBeenCalledWith( - 'Enter a name for the new project:', - expect.any(Object), + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), ); expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); expect(consola.start).toHaveBeenCalledWith( @@ -210,8 +215,6 @@ describe('project create', () => { expect(config.get('projectUuid')).toBe('c23f5785-fd99-4a94-9fb3-945551623925'); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('runs interactive login when no credentials are provided', async () => { @@ -221,7 +224,7 @@ describe('project create', () => { delete process.env.CATALYST_STORE_HASH; delete process.env.CATALYST_ACCESS_TOKEN; - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('My New Project'); + inputMock.mockResolvedValueOnce('My New Project'); await program.parseAsync(['node', 'catalyst', 'project', 'create']); @@ -238,8 +241,6 @@ describe('project create', () => { expect(config.get('storeHash')).toBe('mock-store-hash'); expect(config.get('accessToken')).toBe('mock-access-token'); expect(config.get('projectUuid')).toBe(projectUuid3); - - consolaPromptMock.mockRestore(); }); test('falls back to manual login when the device flow fails', async () => { @@ -258,11 +259,10 @@ describe('project create', () => { passwordMock.mockResolvedValueOnce(accessToken); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(storeHash) - .mockResolvedValueOnce('My New Project'); + // Manual-login fallback: confirm() to retry manually, input() for the store + // hash, password() for the token, then input() for the new project name. + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce(storeHash).mockResolvedValueOnce('My New Project'); await program.parseAsync(['node', 'catalyst', 'project', 'create']); @@ -276,8 +276,6 @@ describe('project create', () => { expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('exits cleanly when the user aborts the manual login fallback', async () => { @@ -294,7 +292,8 @@ describe('project create', () => { ), ); - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + // Decline the manual-login fallback confirm() — aborts cleanly. + confirmMock.mockResolvedValueOnce(false); await program.parseAsync(['node', 'catalyst', 'project', 'create']); @@ -306,8 +305,6 @@ describe('project create', () => { ); expect(exitMock).toHaveBeenCalledWith(0); expect(config.get('projectUuid')).toBeUndefined(); - - consolaPromptMock.mockRestore(); }); test('propagates create project API errors', async () => { @@ -317,7 +314,7 @@ describe('project create', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('Duplicate'); + inputMock.mockResolvedValue('Duplicate'); await expect( program.parseAsync([ @@ -331,8 +328,6 @@ describe('project create', () => { accessToken, ]), ).rejects.toThrow('Failed to create project, is the name already in use?'); - - promptMock.mockRestore(); }); test('propagates 422 validation error', async () => { @@ -342,7 +337,7 @@ describe('project create', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('bad name'); + inputMock.mockResolvedValue('bad name'); await expect( program.parseAsync([ @@ -358,8 +353,6 @@ describe('project create', () => { ).rejects.toThrow( "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", ); - - promptMock.mockRestore(); }); }); @@ -379,15 +372,17 @@ describe('project list', () => { expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - expect(consola.log).toHaveBeenCalledWith('Project One (a23f5785-fd99-4a94-9fb3-945551623923)'); - expect(consola.log).toHaveBeenCalledWith( - expect.stringContaining('https://project-one.catalyst-sandbox.store'), - ); - expect(consola.log).toHaveBeenCalledWith( - expect.stringContaining('https://vanity.project-one.example.com'), - ); - expect(consola.log).toHaveBeenCalledWith('Project Two (b23f5785-fd99-4a94-9fb3-945551623924)'); - expect(consola.log).toHaveBeenCalledWith(' (not deployed)'); + + const output = vi + .mocked(consola.log) + .mock.calls.map(([msg]) => String(msg)) + .join('\n'); + + expect(output).toContain('Project One (a23f5785-fd99-4a94-9fb3-945551623923)'); + expect(output).toContain('https://project-one.catalyst-sandbox.store'); + expect(output).toContain('https://vanity.project-one.example.com'); + expect(output).toContain('Project Two (b23f5785-fd99-4a94-9fb3-945551623924)'); + expect(output).toContain('(not deployed)'); expect(exitMock).toHaveBeenCalledWith(0); }); @@ -405,13 +400,17 @@ describe('project list', () => { accessToken, ]); - const logCalls = vi.mocked(consola.log).mock.calls.map(([msg]) => String(msg)); + const blocks = vi + .mocked(consola.log) + .mock.calls.map(([msg]) => String(msg)) + .join('\n') + .split('\n\n'); - const linkedLine = logCalls.find((line) => line.includes(projectUuid2)); - const otherLine = logCalls.find((line) => line.includes(projectUuid1)); + const linkedBlock = blocks.find((block) => block.includes(projectUuid2)); + const otherBlock = blocks.find((block) => block.includes(projectUuid1)); - expect(linkedLine).toContain('[linked]'); - expect(otherLine).not.toContain('[linked]'); + expect(linkedBlock).toContain('[linked]'); + expect(otherBlock).not.toContain('[linked]'); }); test('does not mark any project when nothing is linked', async () => { @@ -515,26 +514,7 @@ describe('project link', () => { }); test('fetches projects and prompts user to select one', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async (message, opts) => { - expect(message).toContain( - 'Select a project or create a new project (Press to select).', - ); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ - label: 'Project Two', - value: projectUuid2, - }); - expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); - - return new Promise((resolve) => resolve(projectUuid2)); - }); + selectMock.mockResolvedValueOnce(projectUuid2); await program.parseAsync([ 'node', @@ -547,6 +527,22 @@ describe('project link', () => { accessToken, ]); + expect(selectMock).toHaveBeenCalledTimes(1); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); @@ -564,36 +560,11 @@ describe('project link', () => { expect(config.get('projectUuid')).toBe(projectUuid2); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('prompts to create a new project', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - expect(message).toContain( - 'Select a project or create a new project (Press to select).', - ); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ - label: 'Project Two', - value: projectUuid2, - }); - expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); - - return new Promise((resolve) => resolve('create')); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return new Promise((resolve) => resolve('New Project')); - }); + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('New Project'); await program.parseAsync([ 'node', @@ -606,6 +577,24 @@ describe('project link', () => { accessToken, ]); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); @@ -618,8 +607,6 @@ describe('project link', () => { expect(config.get('projectUuid')).toBe(projectUuid3); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('errors when create project API fails', async () => { @@ -629,31 +616,8 @@ describe('project link', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - expect(message).toContain( - 'Select a project or create a new project (Press to select).', - ); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ - label: 'Project Two', - value: projectUuid2, - }); - expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); - - return new Promise((resolve) => resolve('create')); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return new Promise((resolve) => resolve('New Project')); - }); + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('New Project'); await expect( program.parseAsync([ @@ -668,12 +632,28 @@ describe('project link', () => { ]), ).rejects.toThrow('Failed to create project, is the name already in use?'); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - - consolaPromptMock.mockRestore(); }); test('errors when create project returns 422 validation error', async () => { @@ -683,31 +663,8 @@ describe('project link', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - expect(message).toContain( - 'Select a project or create a new project (Press to select).', - ); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ - label: 'Project Two', - value: projectUuid2, - }); - expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); - - return new Promise((resolve) => resolve('create')); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return new Promise((resolve) => resolve('bad name')); - }); + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('bad name'); await expect( program.parseAsync([ @@ -724,31 +681,34 @@ describe('project link', () => { "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", ); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - - consolaPromptMock.mockRestore(); }); test('marks the currently linked project with [linked] in the select prompt', async () => { config.set('projectUuid', projectUuid2); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - const linkedOption = options.find((o) => o.value === projectUuid2); - const otherOption = options.find((o) => o.value === projectUuid1); - - expect(linkedOption?.label).toContain('[linked]'); - expect(otherOption?.label).not.toContain('[linked]'); - - return Promise.resolve(projectUuid2); - }); + selectMock.mockResolvedValueOnce(projectUuid2); await program.parseAsync([ 'node', @@ -761,7 +721,16 @@ describe('project link', () => { accessToken, ]); - consolaPromptMock.mockRestore(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as unknown as { + choices: Array<{ name: string; value: string }>; + }; + + const linkedOption = selectArgs.choices.find((o) => o.value === projectUuid2); + const otherOption = selectArgs.choices.find((o) => o.value === projectUuid1); + + expect(linkedOption?.name).toContain('[linked]'); + expect(otherOption?.name).not.toContain('[linked]'); }); test('exits gracefully with guidance when user declines to create from empty list', async () => { @@ -771,9 +740,8 @@ describe('project link', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + // Empty list → confirm() to create; declining throws NoLinkedProjectError. + confirmMock.mockResolvedValue(false); await expect( program.parseAsync([ @@ -792,8 +760,6 @@ describe('project link', () => { "When you're ready to create a project, run `catalyst project create` or re-run `catalyst project link`.", ); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('errors when infrastructure projects API is not found', async () => { @@ -825,8 +791,6 @@ describe('project link', () => { describe('post-link Commerce Hosting setup prompt', () => { test('does not prompt when project is already transformed', async () => { - const consolaPromptMock = vi.spyOn(consola, 'prompt'); - vi.mocked(getProjectState).mockReturnValue(transformedState); await program.parseAsync([ @@ -838,23 +802,17 @@ describe('project link', () => { projectUuid1, ]); - const promptMessages = consolaPromptMock.mock.calls.map(([msg]) => msg); + const confirmMessages = confirmMock.mock.calls.map(([opts]) => opts.message); - expect(promptMessages).not.toContain( + expect(confirmMessages).not.toContainEqual( expect.stringContaining('not fully set up for Commerce Hosting'), ); - - consolaPromptMock.mockRestore(); }); test('prompts and runs setup when user accepts', async () => { vi.mocked(getProjectState).mockReturnValue(untransformedState); - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockImplementation(async (message) => { - expect(message).toContain('not fully set up for Commerce Hosting'); - - return Promise.resolve(true); - }); + confirmMock.mockResolvedValue(true); await program.parseAsync([ 'node', @@ -865,20 +823,21 @@ describe('project link', () => { projectUuid1, ]); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(confirmMock.mock.calls[0][0].message).toContain( + 'not fully set up for Commerce Hosting', + ); + expect(setupCommerceHosting).toHaveBeenCalledWith( expect.objectContaining({ projectUuid: projectUuid1 }), ); expect(installDependencies).toHaveBeenCalled(); - - consolaPromptMock.mockRestore(); }); test('skips setup when user declines', async () => { vi.mocked(getProjectState).mockReturnValue(untransformedState); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + confirmMock.mockResolvedValue(false); await program.parseAsync([ 'node', @@ -891,8 +850,6 @@ describe('project link', () => { expect(setupCommerceHosting).not.toHaveBeenCalled(); expect(installDependencies).not.toHaveBeenCalled(); - - consolaPromptMock.mockRestore(); }); }); @@ -914,8 +871,6 @@ describe('project link', () => { describe('project delete', () => { test('with --project-uuid and --force deletes without prompting', async () => { - const consolaPromptMock = vi.spyOn(consola, 'prompt'); - await program.parseAsync([ 'node', 'catalyst', @@ -930,26 +885,16 @@ describe('project delete', () => { accessToken, ]); - expect(consolaPromptMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith(`Deleting project ${projectUuid1}...`); expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('with --project-uuid prompts for confirmation and deletes on accept', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async (message, opts) => { - expect(message).toContain('Are you sure you want to delete project'); - expect(message).toContain(projectUuid1); - expect(message).toContain('irreversible'); - expect(opts).toMatchObject({ type: 'confirm' }); - - return Promise.resolve(true); - }); + confirmMock.mockResolvedValue(true); await program.parseAsync([ 'node', @@ -964,11 +909,17 @@ describe('project delete', () => { accessToken, ]); - expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + + const confirmArgs = confirmMock.mock.calls[0][0]; + + expect(confirmArgs.message).toContain('Are you sure you want to delete project'); + expect(confirmArgs.message).toContain(projectUuid1); + expect(confirmArgs.message).toContain('irreversible'); + expect(confirmArgs.default).toBe(false); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('aborts when user declines the confirmation prompt', async () => { @@ -985,9 +936,7 @@ describe('project delete', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + confirmMock.mockResolvedValue(false); await program.parseAsync([ 'node', @@ -1005,32 +954,11 @@ describe('project delete', () => { expect(deleteRequested).toBe(false); expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('without --project-uuid fetches projects and prompts to select one', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - expect(message).toContain('Select a project to delete'); - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); - expect(options[1]).toMatchObject({ label: 'Project Two', value: projectUuid2 }); - expect(options[2]).toMatchObject({ label: 'Cancel', value: 'cancel' }); - - return Promise.resolve(projectUuid2); - }) - .mockImplementationOnce(async (message) => { - expect(message).toContain('"Project Two"'); - expect(message).toContain(projectUuid2); - - return Promise.resolve(true); - }); + selectMock.mockResolvedValueOnce(projectUuid2); + confirmMock.mockResolvedValueOnce(true); await program.parseAsync([ 'node', @@ -1043,32 +971,34 @@ describe('project delete', () => { accessToken, ]); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain('Select a project to delete'); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Cancel', value: 'cancel' }); + + const confirmArgs = confirmMock.mock.calls[0][0]; + + expect(confirmArgs.message).toContain('"Project Two"'); + expect(confirmArgs.message).toContain(projectUuid2); + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid2} deleted.`); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('marks the currently linked project with [linked] in the select prompt', async () => { config.set('projectUuid', projectUuid2); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - const linkedOption = options.find((o) => o.value === projectUuid2); - const otherOption = options.find((o) => o.value === projectUuid1); - - expect(linkedOption?.label).toContain('[linked]'); - expect(otherOption?.label).not.toContain('[linked]'); - - return Promise.resolve(projectUuid1); - }) - .mockImplementationOnce(async () => Promise.resolve(true)); + selectMock.mockResolvedValueOnce(projectUuid1); + confirmMock.mockResolvedValueOnce(true); await program.parseAsync([ 'node', @@ -1081,7 +1011,16 @@ describe('project delete', () => { accessToken, ]); - consolaPromptMock.mockRestore(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as unknown as { + choices: Array<{ name: string; value: string }>; + }; + + const linkedOption = selectArgs.choices.find((o) => o.value === projectUuid2); + const otherOption = selectArgs.choices.find((o) => o.value === projectUuid1); + + expect(linkedOption?.name).toContain('[linked]'); + expect(otherOption?.name).not.toContain('[linked]'); }); test('aborts when user selects Cancel from the project list', async () => { @@ -1098,9 +1037,7 @@ describe('project delete', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve('cancel')); + selectMock.mockResolvedValue('cancel'); await program.parseAsync([ 'node', @@ -1113,12 +1050,11 @@ describe('project delete', () => { accessToken, ]); - expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); expect(deleteRequested).toBe(false); expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('exits cleanly when there are no projects to delete', async () => { diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 7942d88fe..5995ecc12 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -1,3 +1,4 @@ +import { confirm, input, select } from '@inquirer/prompts'; import { Command } from 'commander'; import { colorize } from 'consola/utils'; import { dirname } from 'node:path'; @@ -42,10 +43,11 @@ async function offerCommerceHostingSetup( return; } - const shouldSetup = await consola.prompt( - 'Your project has been linked, but is not fully set up for Commerce Hosting deployments yet. Would you like to run the setup now?', - { type: 'confirm', initial: true }, - ); + const shouldSetup = await confirm({ + message: + 'Your project has been linked, but is not fully set up for Commerce Hosting deployments yet. Would you like to run the setup now?', + default: true, + }); if (!shouldSetup) return; @@ -94,22 +96,22 @@ Example: const linkedProjectUuid = config.get('projectUuid'); - projects.forEach((p) => { + const projectList = projects.map((p) => { const marker = p.uuid === linkedProjectUuid ? ` ${colorize('green', '[linked]')}` : ''; - - consola.log(`${p.name} (${p.uuid})${marker}`); - - if (p.deployment_hostnames.length === 0) { - consola.log(' (not deployed)'); - } else { - p.deployment_hostnames.forEach((hostname) => { - consola.log(` ${colorize('blue', `https://${hostname}`)}`); - }); - } - - consola.log(''); + const hostnames = + p.deployment_hostnames.length === 0 + ? [' (not deployed)'] + : p.deployment_hostnames.map( + (hostname) => ` ${colorize('blue', `https://${hostname}`)}`, + ); + + return [`${p.name} (${p.uuid})${marker}`, ...hostnames].join('\n'); }); + // Single log call so projects are blank-line separated without each blank + // picking up a reporter timestamp. + consola.log(projectList.join('\n\n')); + process.exit(0); }); @@ -165,9 +167,7 @@ Example: await getTelemetry().identify(storeHash); - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); + const newProjectName = await input({ message: 'Enter a name for the new project:' }); const data = await createProject(newProjectName, storeHash, accessToken, options.apiHost); @@ -307,24 +307,18 @@ Examples: const linkedProjectUuid = config.get('projectUuid'); - const selected = await consola.prompt( - 'Select a project to delete (Press to select).', - { - type: 'select', - options: [ - ...projects.map((p) => ({ - label: - p.uuid === linkedProjectUuid - ? `${p.name} ${colorize('green', '[linked]')}` - : p.name, - value: p.uuid, - hint: p.uuid, - })), - { label: 'Cancel', value: 'cancel', hint: 'Exit without deleting any project.' }, - ], - cancel: 'reject', - }, - ); + const selected = await select({ + message: 'Select a project to delete (Press to select).', + choices: [ + ...projects.map((p) => ({ + name: + p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, + value: p.uuid, + description: p.uuid, + })), + { name: 'Cancel', value: 'cancel', description: 'Exit without deleting any project.' }, + ], + }); if (selected === 'cancel') { consola.info('Aborted. No project was deleted.'); @@ -346,10 +340,10 @@ Examples: if (!options.force) { const label = targetName ? `"${targetName}" (${targetUuid})` : targetUuid; - const confirmed = await consola.prompt( - `Are you sure you want to delete project ${label}? This action is irreversible and will permanently destroy the project and all of its data.`, - { type: 'confirm', initial: false }, - ); + const confirmed = await confirm({ + message: `Are you sure you want to delete project ${label}? This action is irreversible and will permanently destroy the project and all of its data.`, + default: false, + }); if (!confirmed) { consola.info('Aborted. No project was deleted.'); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts index 67ae9b372..8a8beba66 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -1,3 +1,4 @@ +import { confirm, input, select } from '@inquirer/prompts'; import { http, HttpResponse } from 'msw'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -7,6 +8,16 @@ import { runChannelSiteUrlFlow } from './channel-site-flow'; import { NoLinkedProjectError } from './commerce-hosting'; import { consola } from './logger'; +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), +})); + +const selectMock = vi.mocked(select); +const confirmMock = vi.mocked(confirm); +const inputMock = vi.mocked(input); + const storeHash = 'test-store'; const accessToken = 'test-token'; const apiHost = 'api.bigcommerce.com'; @@ -67,11 +78,10 @@ describe('runChannelSiteUrlFlow', () => { ), ); - const promptMock = vi - .spyOn(consola, 'prompt') - // First prompt — channel select; resolve with channel id (as string) - .mockResolvedValueOnce('2') - // Second prompt — hostname select + selectMock + // First select — channel select; resolves with the channel id (a number) + .mockResolvedValueOnce(2) + // Second select — hostname select (a string) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await runChannelSiteUrlFlow({ @@ -81,7 +91,7 @@ describe('runChannelSiteUrlFlow', () => { projectUuid: linkedProjectUuid, }); - expect(promptMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalledTimes(2); expect(putChannelId).toBe('2'); expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); expect(consola.success).toHaveBeenCalledWith( @@ -90,9 +100,7 @@ describe('runChannelSiteUrlFlow', () => { }); test('--channel-id short-circuits the channel prompt', async () => { - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce('vanity.project-one.example.com'); + selectMock.mockResolvedValueOnce('vanity.project-one.example.com'); await runChannelSiteUrlFlow({ storeHash, @@ -102,16 +110,15 @@ describe('runChannelSiteUrlFlow', () => { channelId: 99, }); - // Only the hostname prompt fires - expect(promptMock).toHaveBeenCalledTimes(1); - expect(promptMock).toHaveBeenCalledWith( - 'Select the hostname to point the channel at.', - expect.any(Object), + // Only the hostname select fires + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select the hostname to point the channel at.' }), ); }); test('--hostname short-circuits the hostname prompt', async () => { - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + selectMock.mockResolvedValueOnce(2); await runChannelSiteUrlFlow({ storeHash, @@ -121,16 +128,16 @@ describe('runChannelSiteUrlFlow', () => { hostname: 'manual.example.com', }); - expect(promptMock).toHaveBeenCalledTimes(1); - expect(promptMock).toHaveBeenCalledWith('Select the channel to update.', expect.any(Object)); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select the channel to update.' }), + ); expect(consola.success).toHaveBeenCalledWith( expect.stringContaining('https://manual.example.com'), ); }); test('both overrides skip all prompts', async () => { - const promptMock = vi.spyOn(consola, 'prompt'); - await runChannelSiteUrlFlow({ storeHash, accessToken, @@ -140,23 +147,18 @@ describe('runChannelSiteUrlFlow', () => { hostname: 'auto.example.com', }); - expect(promptMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); expect(consola.success).toHaveBeenCalledWith( expect.stringContaining('Updated channel 42 site URL to https://auto.example.com.'), ); }); test('preferHostname is placed first in the hostname options', async () => { - let hostnameOptions: Array<{ label: string; value: string }> | undefined; - - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') // channel (the catalyst one) - .mockImplementationOnce((_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; - - return Promise.resolve('vanity.project-one.example.com'); - }); + selectMock + .mockResolvedValueOnce(2) // channel (the catalyst one) + .mockResolvedValueOnce('vanity.project-one.example.com'); // hostname await runChannelSiteUrlFlow({ storeHash, @@ -166,7 +168,12 @@ describe('runChannelSiteUrlFlow', () => { preferHostname: 'vanity.project-one.example.com', }); - expect(hostnameOptions?.[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); + // The hostname select is the second call; inspect the choices passed to it. + const hostnameCall = selectMock.mock.calls[1][0]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const hostnameChoices = hostnameCall.choices as Array<{ name: string; value: string }>; + + expect(hostnameChoices[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); }); test('throws when no Catalyst channels are available', async () => { @@ -190,16 +197,9 @@ describe('runChannelSiteUrlFlow', () => { }); test('filters non-Catalyst channels out of the picker', async () => { - let channelOptions: Array<{ label: string; value: string }> | undefined; - - vi.spyOn(consola, 'prompt') - .mockImplementationOnce((_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - channelOptions = (opts as { options: Array<{ label: string; value: string }> }).options; - - return Promise.resolve('2'); - }) - .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + selectMock + .mockResolvedValueOnce(2) // channel + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); // hostname await runChannelSiteUrlFlow({ storeHash, @@ -208,17 +208,22 @@ describe('runChannelSiteUrlFlow', () => { projectUuid: linkedProjectUuid, }); + // The channel select is the first call; inspect the choices passed to it. + const channelCall = selectMock.mock.calls[0][0]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const channelChoices = channelCall.choices as Array<{ name: string; value: number }>; + // The default handler returns one bigcommerce + one catalyst channel; only // the catalyst one should appear in the picker. - expect(channelOptions).toHaveLength(1); - expect(channelOptions?.[0]).toMatchObject({ label: 'Catalyst Storefront', value: '2' }); + expect(channelChoices).toHaveLength(1); + expect(channelChoices[0]).toMatchObject({ name: 'Catalyst Storefront', value: 2 }); }); test('throws when the project has no deployment hostnames', async () => { // Project Two in the default handler has deployment_hostnames: [] const projectTwo = 'b23f5785-fd99-4a94-9fb3-945551623924'; - vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + selectMock.mockResolvedValueOnce(2); await expect( runChannelSiteUrlFlow({ @@ -249,12 +254,12 @@ describe('runChannelSiteUrlFlow', () => { }), ); - vi.spyOn(consola, 'prompt') - // selectOrCreateInfrastructureProject prompt — pick the only project + selectMock + // selectOrCreateInfrastructureProject select — pick the only project (UUID string) .mockResolvedValueOnce(linkedProjectUuid) - // channel - .mockResolvedValueOnce('2') - // hostname + // channel (number) + .mockResolvedValueOnce(2) + // hostname (string) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost }); @@ -273,7 +278,7 @@ describe('runChannelSiteUrlFlow', () => { ); // The "create a new project?" confirm prompt — user says no - vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); await expect(runChannelSiteUrlFlow({ storeHash, accessToken, apiHost })).rejects.toBeInstanceOf( NoLinkedProjectError, @@ -295,12 +300,12 @@ describe('runChannelSiteUrlFlow', () => { ), ); - vi.spyOn(consola, 'prompt') - // project picker + selectMock + // project picker (UUID string) .mockResolvedValueOnce('different-uuid') - // channel - .mockResolvedValueOnce('1') - // hostname + // channel (number) + .mockResolvedValueOnce(1) + // hostname (string) .mockResolvedValueOnce('other.example.com'); await runChannelSiteUrlFlow({ diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index 01fb55297..5d8a41dff 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -1,3 +1,5 @@ +import { select } from '@inquirer/prompts'; + import { type Channel, fetchAvailableChannels, updateChannelSiteUrl } from './channels'; import { selectOrCreateInfrastructureProject } from './commerce-hosting'; import { consola } from './logger'; @@ -71,17 +73,15 @@ async function resolveChannel( ); } - const selectedId = await consola.prompt('Select the channel to update.', { - type: 'select', - options: catalystChannels.map((c: Channel) => ({ - label: c.name, - value: String(c.id), - hint: `id: ${c.id}`, + const id = await select({ + message: 'Select the channel to update.', + choices: catalystChannels.map((c: Channel) => ({ + name: c.name, + value: c.id, + description: `id: ${c.id}`, })), - cancel: 'reject', }); - const id = Number(selectedId); const matched = catalystChannels.find((c) => c.id === id); return { id, name: matched?.name }; @@ -111,13 +111,12 @@ async function resolveHostname( ] : project.deployment_hostnames; - const selected = await consola.prompt('Select the hostname to point the channel at.', { - type: 'select', - options: ordered.map((h) => ({ label: h, value: h })), - cancel: 'reject', + const selected = await select({ + message: 'Select the hostname to point the channel at.', + choices: ordered.map((h) => ({ name: h, value: h })), }); - return String(selected); + return selected; } export async function runChannelSiteUrlFlow(options: ChannelSiteFlowOptions): Promise { diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts index 0a0c85324..7ae46b8b8 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts @@ -1,4 +1,4 @@ -import { input, select } from '@inquirer/prompts'; +import { confirm, input, select } from '@inquirer/prompts'; import { existsSync, lstatSync, @@ -20,11 +20,11 @@ import { promptForCommerceHostingProject, setupCommerceHosting, } from './commerce-hosting'; -import { consola } from './logger'; import * as projectLib from './project'; import { InfrastructureProjectValidationError } from './project'; vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), input: vi.fn(), select: vi.fn(), Separator: class FakeSeparator { @@ -32,6 +32,7 @@ vi.mock('@inquirer/prompts', () => ({ }, })); +const confirmMock = vi.mocked(confirm); const inputMock = vi.mocked(input); const selectMock = vi.mocked(select); @@ -59,6 +60,7 @@ let createProjectSpy: MockInstance; let consoleErrorSpy: MockInstance<(typeof console)['error']>; beforeEach(() => { + confirmMock.mockReset(); inputMock.mockReset(); selectMock.mockReset(); fetchProjectsSpy = vi.spyOn(projectLib, 'fetchProjects').mockResolvedValue([]); @@ -578,19 +580,16 @@ describe('setupCommerceHosting', () => { let projectDir: string; let restoreTty: () => void; - let consolaPromptSpy: MockInstance; beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'catalyst-create-test-')); restoreTty = withInteractiveTty(); - consolaPromptSpy = vi.spyOn(consola, 'prompt'); - consolaPromptSpy.mockResolvedValue(true); + confirmMock.mockResolvedValue(true); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); restoreTty(); - consolaPromptSpy.mockRestore(); }); function writeCorePackageJson(contents: unknown) { @@ -676,7 +675,7 @@ describe('setupCommerceHosting', () => { const pkg = readCorePackageJson(); expect(pkg.dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); }); it('does not modify package.json scripts (handled by setupCoreProject)', async () => { @@ -881,7 +880,7 @@ describe('setupCommerceHosting', () => { }); it('preserves core/instrumentation.ts when the user declines the prompt', async () => { - consolaPromptSpy.mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); writeCorePackageJson({ scripts: { dev: 'next dev' }, @@ -899,7 +898,7 @@ describe('setupCommerceHosting', () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).resolves.not.toThrow(); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); }); }); }); @@ -909,19 +908,16 @@ describe('cleanupCloudflareIncompatibilities', () => { let projectDir: string; let restoreTty: () => void; - let consolaPromptSpy: MockInstance; beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'catalyst-cleanup-test-')); restoreTty = withInteractiveTty(); - consolaPromptSpy = vi.spyOn(consola, 'prompt'); - consolaPromptSpy.mockResolvedValue(true); + confirmMock.mockResolvedValue(true); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); restoreTty(); - consolaPromptSpy.mockRestore(); }); function writeCorePackageJson(contents: unknown) { @@ -958,7 +954,7 @@ describe('cleanupCloudflareIncompatibilities', () => { }); it('leaves the file and the dep alone when the user declines (TTY)', async () => { - consolaPromptSpy.mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); writeCorePackageJson({ dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' }, @@ -982,7 +978,7 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); }); @@ -992,7 +988,7 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); }); diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.ts b/packages/catalyst/src/cli/lib/commerce-hosting.ts index 8dac968c3..161b8aec2 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.ts @@ -1,4 +1,4 @@ -import { input, select, Separator } from '@inquirer/prompts'; +import { confirm, input, select, Separator } from '@inquirer/prompts'; import { colorize } from 'consola/utils'; import { existsSync, @@ -89,14 +89,13 @@ export const cleanupCloudflareIncompatibilities = async (projectDir: string) => return; } - const shouldRemove = await consola.prompt( - 'Catalyst found core/instrumentation.ts, which is incompatible with the Cloudflare Workers ' + + const shouldRemove = await confirm({ + message: + 'Catalyst found core/instrumentation.ts, which is incompatible with the Cloudflare Workers ' + 'bundle when it uses @vercel/otel (causes "Failed to prepare server" at cold start). ' + 'Remove it and drop @vercel/otel from core/package.json?', - { type: 'confirm', initial: true }, - ); - - consola.log(''); + default: true, + }); if (!shouldRemove) { consola.info( @@ -180,16 +179,9 @@ export class NoLinkedProjectError extends Error { } async function promptForNewProjectName(api: CommerceHostingApiContext): Promise { - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); + const newProjectName = await input({ message: 'Enter a name for the new project:' }); - const data = await createProject( - String(newProjectName), - api.storeHash, - api.accessToken, - api.apiHost, - ); + const data = await createProject(newProjectName, api.storeHash, api.accessToken, api.apiHost); consola.success(`Project "${data.name}" created successfully.`); @@ -217,10 +209,11 @@ export async function selectOrCreateInfrastructureProject( // No existing projects on the store — skip the select prompt and offer // creation directly. Declining means we have nothing to link to. if (existingProjects.length === 0) { - const shouldCreate = await consola.prompt( - 'There are not any hosting projects that you can link to yet. Would you like to create one?', - { type: 'confirm', initial: true }, - ); + const shouldCreate = await confirm({ + message: + 'There are not any hosting projects that you can link to yet. Would you like to create one?', + default: true, + }); if (!shouldCreate) { throw new NoLinkedProjectError(); @@ -229,23 +222,23 @@ export async function selectOrCreateInfrastructureProject( return promptForNewProjectName(api); } - const promptOptions = [ + const choices = [ ...existingProjects.map((p) => ({ - label: p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, + name: p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, value: p.uuid, - hint: p.uuid, + description: p.uuid, })), { - label: 'Create a new project', + name: 'Create a new project', value: 'create', - hint: 'Create a new hosting project for this Catalyst storefront.', + description: 'Create a new hosting project for this Catalyst storefront.', }, ]; - const selected = await consola.prompt( - 'Select a project or create a new project (Press to select).', - { type: 'select', options: promptOptions, cancel: 'reject' }, - ); + const selected = await select({ + message: 'Select a project or create a new project (Press to select).', + choices, + }); if (selected === 'create') { return promptForNewProjectName(api); diff --git a/packages/catalyst/src/cli/lib/login.ts b/packages/catalyst/src/cli/lib/login.ts index 4891111e7..a4ffa35f1 100644 --- a/packages/catalyst/src/cli/lib/login.ts +++ b/packages/catalyst/src/cli/lib/login.ts @@ -1,4 +1,4 @@ -import { password } from '@inquirer/prompts'; +import { confirm, input, password } from '@inquirer/prompts'; import { colorize } from 'consola/utils'; import open from 'open'; import yoctoSpinner from 'yocto-spinner'; @@ -88,8 +88,8 @@ async function manualLogin(apiHost: string): Promise { `Grant these OAuth scopes: ${DEVICE_OAUTH_SCOPES}`, ); - const storeHashInput = await consola.prompt('Store hash:', { type: 'text' }); - const storeHash = String(storeHashInput).trim(); + const storeHashInput = await input({ message: 'Store hash:' }); + const storeHash = storeHashInput.trim(); if (!storeHash) { throw new Error('Store hash is required.'); @@ -138,10 +138,10 @@ export async function login(loginUrl: string, apiHost: string): Promise