Skip to content

Commit 8dfd9c7

Browse files
jorgemoyaclaude
andcommitted
LTRAC-911: ref(cli) - Standardize interactive prompts on @inquirer/prompts (#3054)
* LTRAC-911: ref(cli) - Standardize interactive prompts on @inquirer/prompts Replace all `consola.prompt` usages with `@inquirer/prompts` (confirm/input/select/ checkbox) so the CLI uses one prompt library; consola is retained for logging only. Converts create's locale multiselect to an inquirer `checkbox` with a `validate` (drops the consola cast + recursion hack), and migrates the prompts in login, channel link/update, project, deploy, channel-site-flow, and commerce-hosting. Specs updated to mock `@inquirer/prompts`. Chosen over consola because consola.prompt has no masked input (login's access token uses `password({ mask: true })`), plus inquirer's validate/theming. Stacked on #3051 (channel link). Refs LTRAC-911 Co-Authored-By: Claude <noreply@anthropic.com> * LTRAC-911: style(cli) - Standardize spacing on prompts and command output Stray blank lines came from leading/trailing newlines and `consola.log('')` calls, which the fancy reporter timestamps as their own log line. Collapse multi-line "Next steps" output into a single `consola.log` call (one timestamp, predictable spacing) and drop the manual blank-line separators after prompts. `project list` now joins its project blocks into one log call so each blank separator isn't timestamped. Refs LTRAC-911 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb701b6 commit 8dfd9c7

14 files changed

Lines changed: 452 additions & 561 deletions

packages/catalyst/src/cli/commands/auth.spec.ts

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { password } from '@inquirer/prompts';
1+
import { confirm, input, password } from '@inquirer/prompts';
22
import { Command } from 'commander';
33
import { http, HttpResponse } from 'msw';
44
import { realpath } from 'node:fs/promises';
@@ -27,9 +27,13 @@ import { auth } from './auth';
2727
vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner'));
2828
vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) }));
2929
vi.mock('@inquirer/prompts', () => ({
30+
confirm: vi.fn(),
31+
input: vi.fn(),
3032
password: vi.fn(),
3133
}));
3234

35+
const confirmMock = vi.mocked(confirm);
36+
const inputMock = vi.mocked(input);
3337
const passwordMock = vi.mocked(password);
3438

3539
let exitMock: MockInstance;
@@ -163,25 +167,16 @@ describe('login', () => {
163167
),
164168
);
165169

170+
confirmMock.mockResolvedValueOnce(true);
171+
inputMock.mockResolvedValueOnce('manual-store-hash');
166172
passwordMock.mockResolvedValueOnce('manual-access-token');
167173

168-
const promptMock = vi
169-
.spyOn(consola, 'prompt')
170-
.mockImplementationOnce(async (message, opts) => {
171-
expect(message).toContain('Try logging in manually');
172-
expect(opts).toMatchObject({ type: 'confirm' });
173-
174-
return Promise.resolve(true);
175-
})
176-
.mockImplementationOnce(async (message, opts) => {
177-
expect(message).toBe('Store hash:');
178-
expect(opts).toMatchObject({ type: 'text' });
179-
180-
return Promise.resolve('manual-store-hash');
181-
});
182-
183174
await program.parseAsync(['node', 'catalyst', 'auth', 'login']);
184175

176+
expect(confirmMock).toHaveBeenCalledOnce();
177+
expect(confirmMock.mock.calls[0]?.[0].message).toContain('Try logging in manually');
178+
expect(inputMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'Store hash:' }));
179+
185180
expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work"));
186181
expect(consola.success).toHaveBeenCalledWith('Logged in to store manual-store-hash.');
187182
expect(exitMock).toHaveBeenCalledWith(0);
@@ -190,8 +185,6 @@ describe('login', () => {
190185

191186
expect(config.get('storeHash')).toBe('manual-store-hash');
192187
expect(config.get('accessToken')).toBe('manual-access-token');
193-
194-
promptMock.mockRestore();
195188
});
196189

197190
test('exits cleanly when user declines manual login fallback', async () => {
@@ -202,7 +195,7 @@ describe('login', () => {
202195
),
203196
);
204197

205-
const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false);
198+
confirmMock.mockResolvedValueOnce(false);
206199

207200
await program.parseAsync(['node', 'catalyst', 'auth', 'login']);
208201

@@ -211,8 +204,6 @@ describe('login', () => {
211204
'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.',
212205
);
213206
expect(exitMock).toHaveBeenCalledWith(0);
214-
215-
promptMock.mockRestore();
216207
});
217208

218209
test('fails when manual credentials cannot be validated', async () => {
@@ -227,21 +218,16 @@ describe('login', () => {
227218
),
228219
);
229220

221+
confirmMock.mockResolvedValueOnce(true);
222+
inputMock.mockResolvedValueOnce('manual-store-hash');
230223
passwordMock.mockResolvedValueOnce('bad-token');
231224

232-
const promptMock = vi
233-
.spyOn(consola, 'prompt')
234-
.mockResolvedValueOnce(true)
235-
.mockResolvedValueOnce('manual-store-hash');
236-
237225
await program.parseAsync(['node', 'catalyst', 'auth', 'login']);
238226

239227
expect(consola.error).toHaveBeenCalledWith(
240228
expect.stringContaining('Could not validate credentials'),
241229
);
242230
expect(exitMock).toHaveBeenCalledWith(1);
243-
244-
promptMock.mockRestore();
245231
});
246232

247233
test('rejects empty store hash during manual login', async () => {
@@ -252,17 +238,13 @@ describe('login', () => {
252238
),
253239
);
254240

255-
const promptMock = vi
256-
.spyOn(consola, 'prompt')
257-
.mockResolvedValueOnce(true)
258-
.mockResolvedValueOnce(' ');
241+
confirmMock.mockResolvedValueOnce(true);
242+
inputMock.mockResolvedValueOnce(' ');
259243

260244
await program.parseAsync(['node', 'catalyst', 'auth', 'login']);
261245

262246
expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Store hash is required'));
263247
expect(exitMock).toHaveBeenCalledWith(1);
264-
265-
promptMock.mockRestore();
266248
});
267249

268250
test('rejects empty access token during manual login', async () => {
@@ -273,19 +255,14 @@ describe('login', () => {
273255
),
274256
);
275257

258+
confirmMock.mockResolvedValueOnce(true);
259+
inputMock.mockResolvedValueOnce('manual-store-hash');
276260
passwordMock.mockResolvedValueOnce(' ');
277261

278-
const promptMock = vi
279-
.spyOn(consola, 'prompt')
280-
.mockResolvedValueOnce(true)
281-
.mockResolvedValueOnce('manual-store-hash');
282-
283262
await program.parseAsync(['node', 'catalyst', 'auth', 'login']);
284263

285264
expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Access token is required'));
286265
expect(exitMock).toHaveBeenCalledWith(1);
287-
288-
promptMock.mockRestore();
289266
});
290267

291268
test('handles browser open failure gracefully', async () => {

packages/catalyst/src/cli/commands/channel.spec.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { confirm, select } from '@inquirer/prompts';
12
import { Command } from 'commander';
23
import Conf from 'conf';
34
import { http, HttpResponse } from 'msw';
@@ -13,12 +14,20 @@ import { program } from '../program';
1314

1415
import { channel } from './channel';
1516

17+
vi.mock('@inquirer/prompts', () => ({
18+
select: vi.fn(),
19+
confirm: vi.fn(),
20+
input: vi.fn(),
21+
}));
1622
// `channel link` can trigger the interactive device-code login (browser +
1723
// spinner); stub both so the no-credentials path runs headless in tests.
1824
vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) }));
1925
// eslint-disable-next-line import/dynamic-import-chunkname
2026
vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner'));
2127

28+
const mockSelect = vi.mocked(select);
29+
const mockConfirm = vi.mocked(confirm);
30+
2231
let exitMock: MockInstance;
2332

2433
let tmpDir: string;
@@ -122,10 +131,7 @@ describe('channel update', () => {
122131
),
123132
);
124133

125-
const promptMock = vi
126-
.spyOn(consola, 'prompt')
127-
.mockResolvedValueOnce('2')
128-
.mockResolvedValueOnce('project-one.catalyst-sandbox.store');
134+
mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store');
129135

130136
await program.parseAsync([
131137
'node',
@@ -140,7 +146,7 @@ describe('channel update', () => {
140146
linkedProjectUuid,
141147
]);
142148

143-
expect(promptMock).toHaveBeenCalledTimes(2);
149+
expect(mockSelect).toHaveBeenCalledTimes(2);
144150
expect(putChannelId).toBe('2');
145151
expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' });
146152
expect(consola.success).toHaveBeenCalledWith(
@@ -152,10 +158,7 @@ describe('channel update', () => {
152158
test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => {
153159
config.set('projectUuid', linkedProjectUuid);
154160

155-
const promptMock = vi
156-
.spyOn(consola, 'prompt')
157-
.mockResolvedValueOnce('2')
158-
.mockResolvedValueOnce('vanity.project-one.example.com');
161+
mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com');
159162

160163
await program.parseAsync([
161164
'node',
@@ -168,7 +171,7 @@ describe('channel update', () => {
168171
accessToken,
169172
]);
170173

171-
expect(promptMock).toHaveBeenCalledTimes(2);
174+
expect(mockSelect).toHaveBeenCalledTimes(2);
172175
expect(consola.success).toHaveBeenCalledWith(
173176
expect.stringContaining('https://vanity.project-one.example.com'),
174177
);
@@ -192,8 +195,6 @@ describe('channel update', () => {
192195
),
193196
);
194197

195-
const promptMock = vi.spyOn(consola, 'prompt');
196-
197198
await program.parseAsync([
198199
'node',
199200
'catalyst',
@@ -211,7 +212,7 @@ describe('channel update', () => {
211212
'override.example',
212213
]);
213214

214-
expect(promptMock).not.toHaveBeenCalled();
215+
expect(mockSelect).not.toHaveBeenCalled();
215216
expect(putChannelId).toBe('5');
216217
expect(putBody).toEqual({ url: 'https://override.example' });
217218
});
@@ -224,7 +225,7 @@ describe('channel update', () => {
224225
);
225226

226227
// First prompt: "Would you like to create one?" — user says no
227-
vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false);
228+
mockConfirm.mockResolvedValueOnce(false);
228229

229230
await program.parseAsync([
230231
'node',
@@ -248,9 +249,7 @@ describe('channel update', () => {
248249
),
249250
);
250251

251-
vi.spyOn(consola, 'prompt')
252-
.mockResolvedValueOnce('2')
253-
.mockResolvedValueOnce('project-one.catalyst-sandbox.store');
252+
mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store');
254253

255254
await expect(
256255
program.parseAsync([
@@ -330,7 +329,7 @@ describe('channel link', () => {
330329
}),
331330
);
332331

333-
const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2');
332+
mockSelect.mockResolvedValueOnce(2);
334333

335334
await program.parseAsync([
336335
'node',
@@ -343,7 +342,7 @@ describe('channel link', () => {
343342
accessToken,
344343
]);
345344

346-
expect(promptMock).toHaveBeenCalledTimes(1);
345+
expect(mockSelect).toHaveBeenCalledTimes(1);
347346
expect(initChannelId).toBe('2');
348347
// id 2 in the default channels handler is "Catalyst Storefront".
349348
expect(consola.success).toHaveBeenCalledWith(

packages/catalyst/src/cli/commands/channel.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { select } from '@inquirer/prompts';
12
import { Command, InvalidArgumentError, Option } from 'commander';
23
import type Conf from 'conf';
34
import { colorize } from 'consola/utils';
@@ -200,17 +201,15 @@ Examples:
200201
return;
201202
}
202203

203-
const selected = await consola.prompt('Which channel would you like to link?', {
204-
type: 'select',
205-
options: sortChannelsByPlatform(channels).map((c) => ({
206-
label: c.name,
207-
value: String(c.id),
208-
hint: channelPlatformLabel(c.platform),
204+
channelId = await select({
205+
message: 'Which channel would you like to link?',
206+
choices: sortChannelsByPlatform(channels).map((c) => ({
207+
name: c.name,
208+
value: c.id,
209+
description: channelPlatformLabel(c.platform),
209210
})),
210-
cancel: 'reject',
211211
});
212212

213-
channelId = Number(selected);
214213
channelName = channels.find((c) => c.id === channelId)?.name;
215214
}
216215

@@ -241,7 +240,7 @@ Examples:
241240
consola.success(
242241
`Linked to channel ${label} and wrote ${colorize('cyanBright', '.env.local')}.`,
243242
);
244-
consola.log(`\nStart your storefront:\n\n ${colorize('yellow', 'pnpm run dev')}\n`);
243+
consola.log(`Next steps:\n\n ${colorize('yellow', 'pnpm run dev')}`);
245244

246245
process.exit(0);
247246
});

packages/catalyst/src/cli/commands/create.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { create } from './create';
2222
vi.mock('child_process', () => ({ execSync: vi.fn() }));
2323

2424
vi.mock('@inquirer/prompts', () => ({
25+
checkbox: vi.fn(),
2526
input: vi.fn(),
2627
select: vi.fn(),
2728
}));

packages/catalyst/src/cli/commands/create.ts

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
2-
import { input, select } from '@inquirer/prompts';
2+
import { checkbox, input, select } from '@inquirer/prompts';
33
import { execSync } from 'child_process';
44
import { colorize } from 'consola/utils';
55
import { pathExistsSync } from 'fs-extra/esm';
@@ -100,30 +100,15 @@ async function handleChannelCreation(
100100
let additionalLocales: string[] = [];
101101

102102
if (shouldAddAdditionalLocales) {
103-
const localeOptions = availableLocales
103+
const localeChoices = availableLocales
104104
.filter(({ value }) => value !== storefrontLocale)
105-
.map(({ name, value }) => ({ label: name, value, hint: value }));
106-
107-
// consola's multiselect returns the value strings at runtime, but its typed
108-
// return is loose (the whole option array). Recursion + cast avoids the
109-
// no-await-in-loop / no-constant-condition lint hits and re-prompts on overflow.
110-
const pickLocales = async (): Promise<string[]> => {
111-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
112-
const selected = (await consola.prompt(
113-
'Which additional languages would you like to add to your channel?',
114-
{ type: 'multiselect', options: localeOptions, cancel: 'reject' },
115-
)) as unknown as string[];
116-
117-
if (selected.length > 4) {
118-
consola.warn('You can only select up to 4 additional languages. Please try again.');
119-
120-
return pickLocales();
121-
}
122-
123-
return selected;
124-
};
105+
.map(({ name, value }) => ({ name, value, description: value }));
125106

126-
additionalLocales = await pickLocales();
107+
additionalLocales = await checkbox({
108+
message: 'Which additional languages would you like to add to your channel?',
109+
choices: localeChoices,
110+
validate: (items) => items.length <= 4 || 'You can only select up to 4 additional languages.',
111+
});
127112
}
128113

129114
const shouldInstallSampleData = await select({
@@ -487,15 +472,16 @@ Examples:
487472
}
488473

489474
consola.success(`Created '${projectName}' at '${projectDir}'`);
490-
consola.info('Next steps:');
491-
consola.info(colorize('yellow', ` cd ${projectName}/core && pnpm run dev`));
475+
476+
const steps = [`cd ${projectName}/core && pnpm run dev`];
492477

493478
if (useCommerceHosting) {
494-
consola.info(
495-
colorize(
496-
'yellow',
497-
` Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`,
498-
),
479+
steps.push(
480+
`Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`,
499481
);
500482
}
483+
484+
consola.log(
485+
`Next steps:\n\n${steps.map((step) => ` ${colorize('yellow', step)}`).join('\n')}`,
486+
);
501487
});

0 commit comments

Comments
 (0)