Skip to content

Commit 24f35a4

Browse files
authored
feat(cli): show deployment hostnames in project list output (#2988)
1 parent 3bea22d commit 24f35a4

10 files changed

Lines changed: 97 additions & 86 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst": minor
3+
---
4+
5+
Show every deployed URL for each project in `catalyst project list` output (the canonical hostname plus any vanity hostnames) so users can recover the hosted storefront URLs without having to redeploy.

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ describe('deployment and event streaming', () => {
234234
start(controller) {
235235
controller.enqueue(
236236
encoder.encode(
237-
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"processing","progress":75}}`,
237+
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"processing","progress":75}}`,
238238
),
239239
);
240240
setTimeout(() => {
@@ -244,7 +244,7 @@ describe('deployment and event streaming', () => {
244244
setTimeout(() => {
245245
controller.enqueue(
246246
encoder.encode(
247-
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"finalizing","progress":99}}`,
247+
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"finalizing","progress":99}}`,
248248
),
249249
);
250250
controller.close();
@@ -288,13 +288,13 @@ describe('deployment and event streaming', () => {
288288
start(controller) {
289289
controller.enqueue(
290290
encoder.encode(
291-
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"processing","progress":75}}`,
291+
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"processing","progress":75}}`,
292292
),
293293
);
294294
setTimeout(() => {
295295
controller.enqueue(
296296
encoder.encode(
297-
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"unzipping","progress":99},"error":{"code":30}}`,
297+
`data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"unzipping","progress":99},"error":{"code":30}}`,
298298
),
299299
);
300300
}, 10);

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const DeploymentStatusSchema = z.object({
7878
progress: z.number(),
7979
})
8080
.nullable(),
81-
deployment_url: z.string().nullable(),
81+
deployment_hostnames: z.array(z.string()).optional(),
8282
error: z
8383
.object({
8484
code: z.number(),
@@ -298,7 +298,7 @@ export const getDeploymentStatus = async (
298298

299299
const decoder = new TextDecoder();
300300
let done = false;
301-
let deploymentUrl: string | undefined;
301+
let deploymentHostname: string | undefined;
302302

303303
while (!done) {
304304
// eslint-disable-next-line no-await-in-loop
@@ -334,8 +334,8 @@ export const getDeploymentStatus = async (
334334
spinner.text = STEPS[data.event.step];
335335
}
336336

337-
if (data.deployment_url) {
338-
deploymentUrl = data.deployment_url;
337+
if (data.deployment_hostnames && data.deployment_hostnames.length > 0) {
338+
deploymentHostname = data.deployment_hostnames[0];
339339
}
340340
});
341341
}
@@ -345,10 +345,10 @@ export const getDeploymentStatus = async (
345345

346346
spinner.success('Deployment completed successfully.');
347347

348-
if (deploymentUrl) {
349-
const url = deploymentUrl.startsWith('https://') ? deploymentUrl : `https://${deploymentUrl}`;
350-
351-
consola.success(`View your deployment at: ${colorize('blue', url)}`);
348+
if (deploymentHostname) {
349+
consola.success(
350+
`View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`,
351+
);
352352
}
353353
};
354354

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

Lines changed: 0 additions & 36 deletions
This file was deleted.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,14 @@ describe('project list', () => {
296296
expect(consola.start).toHaveBeenCalledWith('Fetching projects...');
297297
expect(consola.success).toHaveBeenCalledWith('Projects fetched.');
298298
expect(consola.log).toHaveBeenCalledWith('Project One (a23f5785-fd99-4a94-9fb3-945551623923)');
299+
expect(consola.log).toHaveBeenCalledWith(
300+
expect.stringContaining('https://project-one.catalyst-sandbox.store'),
301+
);
302+
expect(consola.log).toHaveBeenCalledWith(
303+
expect.stringContaining('https://vanity.project-one.example.com'),
304+
);
299305
expect(consola.log).toHaveBeenCalledWith('Project Two (b23f5785-fd99-4a94-9fb3-945551623924)');
306+
expect(consola.log).toHaveBeenCalledWith(' (not deployed)');
300307
expect(exitMock).toHaveBeenCalledWith(0);
301308
});
302309

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ Example:
8787
const marker = p.uuid === linkedProjectUuid ? ` ${colorize('green', '[linked]')}` : '';
8888

8989
consola.log(`${p.name} (${p.uuid})${marker}`);
90+
91+
if (p.deployment_hostnames.length === 0) {
92+
consola.log(' (not deployed)');
93+
} else {
94+
p.deployment_hostnames.forEach((hostname) => {
95+
consola.log(` ${colorize('blue', `https://${hostname}`)}`);
96+
});
97+
}
98+
99+
consola.log('');
90100
});
91101

92102
process.exit(0);

packages/catalyst/src/cli/lib/commerce-hosting.spec.ts

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ describe('promptAndCreateCommerceHostingProject', () => {
8585

8686
const result = await promptAndCreateCommerceHostingProject(API, []);
8787

88-
expect(result).toEqual({ uuid: 'u', name: 'my-project' });
88+
expect(result).toEqual({ uuid: 'u', name: 'my-project', deployment_hostnames: [] });
8989
expect(createProjectSpy).toHaveBeenCalledTimes(1);
9090
expect(createProjectSpy).toHaveBeenCalledWith(
9191
'my-project',
@@ -118,7 +118,7 @@ describe('promptAndCreateCommerceHostingProject', () => {
118118

119119
const result = await promptAndCreateCommerceHostingProject(API, []);
120120

121-
expect(result).toEqual({ uuid: 'u', name: 'good-name' });
121+
expect(result).toEqual({ uuid: 'u', name: 'good-name', deployment_hostnames: [] });
122122
expect(inputMock).toHaveBeenCalledTimes(2);
123123
expect(createProjectSpy).toHaveBeenCalledTimes(2);
124124
});
@@ -136,7 +136,7 @@ describe('promptAndCreateCommerceHostingProject', () => {
136136

137137
const result = await promptAndCreateCommerceHostingProject(API, []);
138138

139-
expect(result).toEqual({ uuid: 'u', name: 'good' });
139+
expect(result).toEqual({ uuid: 'u', name: 'good', deployment_hostnames: [] });
140140
expect(inputMock).toHaveBeenCalledTimes(3);
141141
expect(createProjectSpy).toHaveBeenCalledTimes(3);
142142
});
@@ -234,7 +234,7 @@ describe('promptForCommerceHostingProject', () => {
234234

235235
const result = await promptForCommerceHostingProject(API, 'fresh');
236236

237-
expect(result).toEqual({ uuid: 'u', name: 'fresh' });
237+
expect(result).toEqual({ uuid: 'u', name: 'fresh', deployment_hostnames: [] });
238238
expect(selectMock).not.toHaveBeenCalled();
239239
expect(inputMock).not.toHaveBeenCalled();
240240
expect(createProjectSpy).toHaveBeenCalledWith(
@@ -247,14 +247,14 @@ describe('promptForCommerceHostingProject', () => {
247247

248248
it('silently auto-creates with the supplied default name when other projects exist but none conflict', async () => {
249249
fetchProjectsSpy.mockResolvedValue([
250-
{ uuid: 'aaa', name: 'unrelated-one' },
251-
{ uuid: 'bbb', name: 'unrelated-two' },
250+
{ uuid: 'aaa', name: 'unrelated-one', deployment_hostnames: [] },
251+
{ uuid: 'bbb', name: 'unrelated-two', deployment_hostnames: [] },
252252
]);
253253
createProjectSpy.mockResolvedValueOnce(createdProject('new', 'my-store'));
254254

255255
const result = await promptForCommerceHostingProject(API, 'my-store');
256256

257-
expect(result).toEqual({ uuid: 'new', name: 'my-store' });
257+
expect(result).toEqual({ uuid: 'new', name: 'my-store', deployment_hostnames: [] });
258258
expect(selectMock).not.toHaveBeenCalled();
259259
expect(inputMock).not.toHaveBeenCalled();
260260
expect(createProjectSpy).toHaveBeenCalledWith(
@@ -267,34 +267,36 @@ describe('promptForCommerceHostingProject', () => {
267267

268268
it('returns a selected existing project without calling create', async () => {
269269
const existing = [
270-
{ uuid: 'aaa', name: 'first' },
271-
{ uuid: 'bbb', name: 'second' },
270+
{ uuid: 'aaa', name: 'first', deployment_hostnames: [] },
271+
{ uuid: 'bbb', name: 'second', deployment_hostnames: [] },
272272
];
273273

274274
fetchProjectsSpy.mockResolvedValue(existing);
275275

276276
selectMock
277277
.mockResolvedValueOnce('select-from-list')
278-
.mockResolvedValueOnce({ uuid: 'bbb', name: 'second' });
278+
.mockResolvedValueOnce({ uuid: 'bbb', name: 'second', deployment_hostnames: [] });
279279

280280
const result = await promptForCommerceHostingProject(API, 'first');
281281

282-
expect(result).toEqual({ uuid: 'bbb', name: 'second' });
282+
expect(result).toEqual({ uuid: 'bbb', name: 'second', deployment_hostnames: [] });
283283
expect(createProjectSpy).not.toHaveBeenCalled();
284284
expect(inputMock).not.toHaveBeenCalled();
285285
expect(selectMock).toHaveBeenCalledTimes(2);
286286
});
287287

288288
it('routes to the create flow when the default name conflicts and the user chooses to create a new project', async () => {
289-
fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'default-name' }]);
289+
fetchProjectsSpy.mockResolvedValue([
290+
{ uuid: 'aaa', name: 'default-name', deployment_hostnames: [] },
291+
]);
290292
createProjectSpy.mockResolvedValueOnce(createdProject('new', 'new-proj'));
291293

292294
selectMock.mockResolvedValueOnce('create');
293295
inputMock.mockResolvedValueOnce('new-proj');
294296

295297
const result = await promptForCommerceHostingProject(API, 'default-name');
296298

297-
expect(result).toEqual({ uuid: 'new', name: 'new-proj' });
299+
expect(result).toEqual({ uuid: 'new', name: 'new-proj', deployment_hostnames: [] });
298300
expect(createProjectSpy).toHaveBeenCalledWith(
299301
'new-proj',
300302
API.storeHash,
@@ -306,8 +308,8 @@ describe('promptForCommerceHostingProject', () => {
306308

307309
it('shows the conflict-aware message and three choices when a conflict exists', async () => {
308310
fetchProjectsSpy.mockResolvedValue([
309-
{ uuid: 'aaa', name: 'My-Store' },
310-
{ uuid: 'bbb', name: 'other-project' },
311+
{ uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] },
312+
{ uuid: 'bbb', name: 'other-project', deployment_hostnames: [] },
311313
]);
312314

313315
selectMock.mockResolvedValueOnce('create');
@@ -327,9 +329,12 @@ describe('promptForCommerceHostingProject', () => {
327329
});
328330

329331
it('returns the conflicting project directly when the user picks Use "<name>"', async () => {
330-
const conflict = { uuid: 'aaa', name: 'My-Store' };
332+
const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] };
331333

332-
fetchProjectsSpy.mockResolvedValue([conflict, { uuid: 'bbb', name: 'other-project' }]);
334+
fetchProjectsSpy.mockResolvedValue([
335+
conflict,
336+
{ uuid: 'bbb', name: 'other-project', deployment_hostnames: [] },
337+
]);
333338

334339
selectMock.mockResolvedValueOnce('use-named');
335340

@@ -342,8 +347,8 @@ describe('promptForCommerceHostingProject', () => {
342347
});
343348

344349
it('shows all projects (including the conflict) and a "Create a new project" option in the list', async () => {
345-
const conflict = { uuid: 'aaa', name: 'My-Store' };
346-
const other = { uuid: 'bbb', name: 'other-project' };
350+
const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] };
351+
const other = { uuid: 'bbb', name: 'other-project', deployment_hostnames: [] };
347352

348353
fetchProjectsSpy.mockResolvedValue([conflict, other]);
349354

@@ -368,8 +373,8 @@ describe('promptForCommerceHostingProject', () => {
368373
});
369374

370375
it('routes to the create flow when the user picks "Create a new project" from the list', async () => {
371-
const conflict = { uuid: 'aaa', name: 'My-Store' };
372-
const other = { uuid: 'bbb', name: 'other-project' };
376+
const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] };
377+
const other = { uuid: 'bbb', name: 'other-project', deployment_hostnames: [] };
373378

374379
fetchProjectsSpy.mockResolvedValue([conflict, other]);
375380
createProjectSpy.mockResolvedValueOnce(createdProject('new', 'fresh-name'));
@@ -379,7 +384,7 @@ describe('promptForCommerceHostingProject', () => {
379384

380385
const result = await promptForCommerceHostingProject(API, 'my-store');
381386

382-
expect(result).toEqual({ uuid: 'new', name: 'fresh-name' });
387+
expect(result).toEqual({ uuid: 'new', name: 'fresh-name', deployment_hostnames: [] });
383388
expect(createProjectSpy).toHaveBeenCalledWith(
384389
'fresh-name',
385390
API.storeHash,
@@ -389,7 +394,7 @@ describe('promptForCommerceHostingProject', () => {
389394
});
390395

391396
it('omits Select from my projects when the conflict is the only existing project', async () => {
392-
const conflict = { uuid: 'aaa', name: 'My-Store' };
397+
const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] };
393398

394399
fetchProjectsSpy.mockResolvedValue([conflict]);
395400

@@ -407,12 +412,14 @@ describe('promptForCommerceHostingProject', () => {
407412
});
408413

409414
it('skips all prompts and creates with the supplied name when autoUseDefaultName is true', async () => {
410-
fetchProjectsSpy.mockResolvedValue([{ uuid: 'other', name: 'unrelated' }]);
415+
fetchProjectsSpy.mockResolvedValue([
416+
{ uuid: 'other', name: 'unrelated', deployment_hostnames: [] },
417+
]);
411418
createProjectSpy.mockResolvedValueOnce(createdProject('u', 'auto-name'));
412419

413420
const result = await promptForCommerceHostingProject(API, 'auto-name', true);
414421

415-
expect(result).toEqual({ uuid: 'u', name: 'auto-name' });
422+
expect(result).toEqual({ uuid: 'u', name: 'auto-name', deployment_hostnames: [] });
416423
expect(fetchProjectsSpy).toHaveBeenCalled();
417424
expect(selectMock).not.toHaveBeenCalled();
418425
expect(inputMock).not.toHaveBeenCalled();
@@ -425,7 +432,7 @@ describe('promptForCommerceHostingProject', () => {
425432
});
426433

427434
it('returns the existing project when --project-name collides and the user picks Yes', async () => {
428-
const existing = { uuid: 'aaa', name: 'taken-name' };
435+
const existing = { uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] };
429436

430437
fetchProjectsSpy.mockResolvedValue([existing]);
431438

@@ -447,7 +454,7 @@ describe('promptForCommerceHostingProject', () => {
447454
});
448455

449456
it('reuses the existing project without prompting when --use-existing is passed', async () => {
450-
const existing = { uuid: 'aaa', name: 'taken-name' };
457+
const existing = { uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] };
451458

452459
fetchProjectsSpy.mockResolvedValue([existing]);
453460

@@ -459,7 +466,9 @@ describe('promptForCommerceHostingProject', () => {
459466
});
460467

461468
it('exits without prompting in non-interactive environments when --use-existing is not passed', async () => {
462-
fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]);
469+
fetchProjectsSpy.mockResolvedValue([
470+
{ uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] },
471+
]);
463472

464473
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
465474
throw new Error(`process.exit(${String(code)})`);
@@ -480,7 +489,7 @@ describe('promptForCommerceHostingProject', () => {
480489
});
481490

482491
it('detects --project-name collision case-insensitively and reports the stored name', async () => {
483-
const existing = { uuid: 'aaa', name: 'MyProject' };
492+
const existing = { uuid: 'aaa', name: 'MyProject', deployment_hostnames: [] };
484493

485494
fetchProjectsSpy.mockResolvedValue([existing]);
486495

@@ -502,7 +511,9 @@ describe('promptForCommerceHostingProject', () => {
502511
});
503512

504513
it('exits when --project-name collides and the user picks No', async () => {
505-
fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]);
514+
fetchProjectsSpy.mockResolvedValue([
515+
{ uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] },
516+
]);
506517

507518
selectMock.mockResolvedValueOnce(false);
508519

0 commit comments

Comments
 (0)