Skip to content

Commit 3c47b7d

Browse files
jorgemoyaclaude
andcommitted
LTRAC-908: feat(cli) - Handle CLI-API gateway errors gracefully
The channel init/eligibility/create calls go through the cxm CLI-API gateway (--cli-api-origin), which only exists for staging and production. Against any other environment (e.g. integration), or on a transient 5xx, the call failed with a raw error. Introduce a typed CliApiError thrown by the gateway helpers in channels.ts and a shared reportCliApiError() that prints a clear, actionable message (likely cause + how to verify --api-host/--cli-api-origin) and exits non-zero instead of crashing. `channel connect` catches it before writing, so an existing .env.local is never clobbered; `catalyst create` catches it before cloning, so there's no partial project. Refs LTRAC-908 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 85103b8 commit 3c47b7d

7 files changed

Lines changed: 244 additions & 54 deletions

File tree

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Command } from 'commander';
22
import Conf from 'conf';
33
import { http, HttpResponse } from 'msw';
4-
import { readFileSync } from 'node:fs';
4+
import { readFileSync, writeFileSync } from 'node:fs';
55
import { join } from 'node:path';
66
import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest';
77

@@ -427,4 +427,37 @@ describe('channel connect', () => {
427427
expect(config.get('accessToken')).toBe('mock-access-token');
428428
expect(mockIdentify).toHaveBeenCalledWith('mock-store-hash');
429429
});
430+
431+
test('surfaces a clear error and leaves .env.local untouched when channel init fails', async () => {
432+
// Pre-existing env file that must NOT be clobbered on a gateway failure.
433+
const envPath = join(tmpDir, '.env.local');
434+
435+
writeFileSync(envPath, 'EXISTING=keep\n');
436+
437+
server.use(
438+
http.get(
439+
initUrl,
440+
() => new HttpResponse(null, { status: 500, statusText: 'Internal Server Error' }),
441+
),
442+
);
443+
444+
await program.parseAsync([
445+
'node',
446+
'catalyst',
447+
'channel',
448+
'connect',
449+
'--store-hash',
450+
storeHash,
451+
'--access-token',
452+
accessToken,
453+
'--channel-id',
454+
'2',
455+
]);
456+
457+
expect(consola.error).toHaveBeenCalledWith(
458+
expect.stringContaining('Could not fetch channel credentials from the Catalyst CLI API'),
459+
);
460+
expect(exitMock).toHaveBeenCalledWith(1);
461+
expect(readFileSync(envPath, 'utf8')).toBe('EXISTING=keep\n');
462+
});
430463
});

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { outputFileSync } from 'fs-extra/esm';
55
import { join } from 'node:path';
66

77
import { runChannelSiteUrlFlow } from '../lib/channel-site-flow';
8-
import { fetchAvailableChannels, getChannelInit } from '../lib/channels';
8+
import { type ChannelInit, fetchAvailableChannels, getChannelInit } from '../lib/channels';
9+
import { reportCliApiError } from '../lib/cli-api-errors';
910
import { NoLinkedProjectError } from '../lib/commerce-hosting';
1011
import { parseEnvAssignment } from '../lib/env-config';
1112
import { consola } from '../lib/logger';
@@ -228,7 +229,19 @@ Examples:
228229
channelName = channels.find((c) => c.id === channelId)?.name;
229230
}
230231

231-
const initData = await getChannelInit(channelId, storeHash, accessToken, options.cliApiOrigin);
232+
let initData: ChannelInit;
233+
234+
try {
235+
initData = await getChannelInit(channelId, storeHash, accessToken, options.cliApiOrigin);
236+
} catch (error) {
237+
reportCliApiError(
238+
error,
239+
'Could not fetch channel credentials from the Catalyst CLI API',
240+
'Your existing .env.local was left unchanged.',
241+
);
242+
243+
return;
244+
}
232245

233246
const envVars: Record<string, string> = { ...initData.envVars };
234247

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { join } from 'node:path';
44
import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest';
55

66
import { buildWorkspacePackages } from '../lib/build-workspace-packages';
7+
import { checkChannelEligibility } from '../lib/channels';
8+
import { CliApiError } from '../lib/cli-api-errors';
79
import { cloneCatalyst } from '../lib/clone-catalyst';
810
import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting';
911
import { installDependencies } from '../lib/install-dependencies';
@@ -50,6 +52,12 @@ vi.mock('../lib/install-dependencies', () => ({
5052
}));
5153
vi.mock('../lib/build-workspace-packages', () => ({ buildWorkspacePackages: vi.fn() }));
5254
vi.mock('../lib/write-env', () => ({ writeEnv: vi.fn() }));
55+
vi.mock('../lib/channels', () => ({
56+
checkChannelEligibility: vi.fn(),
57+
createChannel: vi.fn(),
58+
fetchAvailableChannels: vi.fn(),
59+
getChannelInit: vi.fn(),
60+
}));
5361

5462
vi.mock('../lib/commerce-hosting', async (importOriginal) => {
5563
const actual = await importOriginal<typeof import('../lib/commerce-hosting')>();
@@ -476,6 +484,32 @@ describe('failure handling', () => {
476484

477485
expect(cloneCatalyst).not.toHaveBeenCalled();
478486
});
487+
488+
test('fails gracefully when a CLI-API channel call errors (no project cloned)', async () => {
489+
vi.mocked(checkChannelEligibility).mockRejectedValueOnce(
490+
new CliApiError('GET /channels/catalyst/eligibility failed: 500 Internal Server Error', 500),
491+
);
492+
493+
await program.parseAsync([
494+
'node',
495+
'catalyst',
496+
'create',
497+
'--project-name',
498+
uniqueProjectName(),
499+
'--project-dir',
500+
tmpDir,
501+
'--store-hash',
502+
storeHash,
503+
'--access-token',
504+
accessToken,
505+
]);
506+
507+
expect(consola.error).toHaveBeenCalledWith(
508+
expect.stringContaining('Could not set up the BigCommerce channel'),
509+
);
510+
expect(exitMock).toHaveBeenCalledWith(1);
511+
expect(cloneCatalyst).not.toHaveBeenCalled();
512+
});
479513
});
480514

481515
describe('--hosting commerce preconditions', () => {

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

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
fetchAvailableChannels,
1515
getChannelInit,
1616
} from '../lib/channels';
17+
import { reportCliApiError } from '../lib/cli-api-errors';
1718
import { cloneCatalyst } from '../lib/clone-catalyst';
1819
import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting';
1920
import { installDependencies } from '../lib/install-dependencies';
@@ -368,61 +369,69 @@ Examples:
368369
}
369370

370371
// Resolve channel only when we have creds and are missing channel info.
371-
if (storeHash && accessToken && (!channelId || !storefrontToken)) {
372-
const apiHost = `api.${options.bigcommerceHostname}`;
373-
const cliApiOrigin = options.cliApiOrigin;
374-
375-
if (channelId && !storefrontToken) {
376-
const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin);
377-
378-
envVars = { ...initData.envVars };
379-
storefrontToken = initData.storefrontToken;
380-
} else if (!channelId) {
381-
const eligibility = await checkChannelEligibility(storeHash, accessToken, cliApiOrigin);
382-
383-
if (!eligibility.eligible) {
384-
consola.warn(eligibility.message);
385-
}
386-
387-
let shouldCreateChannel;
388-
389-
if (eligibility.eligible) {
390-
shouldCreateChannel = await select({
391-
message: 'Would you like to create a new channel?',
392-
choices: [
393-
{ name: 'Yes', value: true },
394-
{ name: 'No', value: false },
395-
],
396-
});
397-
}
398-
399-
if (shouldCreateChannel) {
400-
const channelData = await handleChannelCreation(
401-
storeHash,
402-
accessToken,
403-
apiHost,
404-
cliApiOrigin,
405-
);
406-
407-
channelId = channelData.channelId;
408-
storefrontToken = channelData.storefrontToken;
409-
envVars = { ...channelData.envVars };
410-
411-
consola.success('Channel created successfully.');
412-
consola.warn(
413-
'A preview storefront has been deployed in your BigCommerce control panel. This preview may look different from your local environment as it may be running different code. Additionally, it may take a few minutes for the channel storefront to be accessible.',
414-
);
415-
}
416-
417-
if (!shouldCreateChannel) {
418-
channelId = await handleChannelSelection(storeHash, accessToken, apiHost);
372+
// CLI-API (cxm gateway) failures here are surfaced cleanly rather than
373+
// crashing — and this runs before any cloning, so there's no partial project.
374+
try {
375+
if (storeHash && accessToken && (!channelId || !storefrontToken)) {
376+
const apiHost = `api.${options.bigcommerceHostname}`;
377+
const cliApiOrigin = options.cliApiOrigin;
419378

379+
if (channelId && !storefrontToken) {
420380
const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin);
421381

422382
envVars = { ...initData.envVars };
423383
storefrontToken = initData.storefrontToken;
384+
} else if (!channelId) {
385+
const eligibility = await checkChannelEligibility(storeHash, accessToken, cliApiOrigin);
386+
387+
if (!eligibility.eligible) {
388+
consola.warn(eligibility.message);
389+
}
390+
391+
let shouldCreateChannel;
392+
393+
if (eligibility.eligible) {
394+
shouldCreateChannel = await select({
395+
message: 'Would you like to create a new channel?',
396+
choices: [
397+
{ name: 'Yes', value: true },
398+
{ name: 'No', value: false },
399+
],
400+
});
401+
}
402+
403+
if (shouldCreateChannel) {
404+
const channelData = await handleChannelCreation(
405+
storeHash,
406+
accessToken,
407+
apiHost,
408+
cliApiOrigin,
409+
);
410+
411+
channelId = channelData.channelId;
412+
storefrontToken = channelData.storefrontToken;
413+
envVars = { ...channelData.envVars };
414+
415+
consola.success('Channel created successfully.');
416+
consola.warn(
417+
'A preview storefront has been deployed in your BigCommerce control panel. This preview may look different from your local environment as it may be running different code. Additionally, it may take a few minutes for the channel storefront to be accessible.',
418+
);
419+
}
420+
421+
if (!shouldCreateChannel) {
422+
channelId = await handleChannelSelection(storeHash, accessToken, apiHost);
423+
424+
const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin);
425+
426+
envVars = { ...initData.envVars };
427+
storefrontToken = initData.storefrontToken;
428+
}
424429
}
425430
}
431+
} catch (error) {
432+
reportCliApiError(error, 'Could not set up the BigCommerce channel');
433+
434+
return;
426435
}
427436

428437
if (options.env) {

packages/catalyst/src/cli/lib/channels.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod';
22

33
import { assertAuthorized } from './auth-errors';
4+
import { CliApiError } from './cli-api-errors';
45
import { getTelemetry } from './telemetry';
56

67
// `origin` is the CLI-API gateway (configured via `--cli-api-origin`, default
@@ -84,8 +85,9 @@ export async function getChannelInit(
8485
});
8586

8687
if (!response.ok) {
87-
throw new Error(
88+
throw new CliApiError(
8889
`GET /channels/${channelId}/init failed: ${response.status} ${response.statusText}`,
90+
response.status,
8991
);
9092
}
9193

@@ -110,8 +112,9 @@ export async function checkChannelEligibility(
110112
});
111113

112114
if (!response.ok) {
113-
throw new Error(
115+
throw new CliApiError(
114116
`GET /channels/catalyst/eligibility failed: ${response.status} ${response.statusText}`,
117+
response.status,
115118
);
116119
}
117120

@@ -147,7 +150,10 @@ export async function createChannel(
147150
});
148151

149152
if (!response.ok) {
150-
throw new Error(`POST /channels/catalyst failed: ${response.status} ${response.statusText}`);
153+
throw new CliApiError(
154+
`POST /channels/catalyst failed: ${response.status} ${response.statusText}`,
155+
response.status,
156+
);
151157
}
152158

153159
const { data } = createChannelResponseSchema.parse(await response.json());
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest';
2+
3+
import { CLI_API_ERROR_HINT, CliApiError, reportCliApiError } from './cli-api-errors';
4+
import { consola } from './logger';
5+
6+
let exitMock: MockInstance;
7+
8+
beforeAll(() => {
9+
consola.mockTypes(() => vi.fn());
10+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
11+
exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never);
12+
});
13+
14+
afterEach(() => {
15+
vi.clearAllMocks();
16+
});
17+
18+
afterAll(() => {
19+
vi.restoreAllMocks();
20+
exitMock.mockRestore();
21+
});
22+
23+
describe('CliApiError', () => {
24+
test('is an Error carrying the message and status', () => {
25+
const error = new CliApiError('boom', 500);
26+
27+
expect(error).toBeInstanceOf(Error);
28+
expect(error.name).toBe('CliApiError');
29+
expect(error.message).toBe('boom');
30+
expect(error.status).toBe(500);
31+
});
32+
});
33+
34+
describe('reportCliApiError', () => {
35+
test('logs the context + hint and exits non-zero for a CliApiError', () => {
36+
reportCliApiError(new CliApiError('GET ... failed: 500', 500), 'Could not do the thing');
37+
38+
expect(consola.error).toHaveBeenCalledWith('Could not do the thing: GET ... failed: 500');
39+
expect(consola.info).toHaveBeenCalledWith(CLI_API_ERROR_HINT);
40+
expect(exitMock).toHaveBeenCalledWith(1);
41+
});
42+
43+
test('includes the extra info line when provided', () => {
44+
reportCliApiError(new CliApiError('nope'), 'ctx', 'left unchanged.');
45+
46+
expect(consola.info).toHaveBeenCalledWith('left unchanged.');
47+
});
48+
49+
test('rethrows non-CliApiError errors untouched and does not exit', () => {
50+
expect(() => reportCliApiError(new Error('unrelated'), 'ctx')).toThrow('unrelated');
51+
expect(exitMock).not.toHaveBeenCalled();
52+
});
53+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { consola } from './logger';
2+
3+
// Thrown by the cxm "CLI-API" gateway helpers (getChannelInit,
4+
// checkChannelEligibility, createChannel) when the gateway returns a non-2xx.
5+
// Typed distinctly so commands can fail gracefully instead of surfacing a raw
6+
// error, while unrelated failures keep propagating.
7+
export class CliApiError extends Error {
8+
status?: number;
9+
10+
constructor(message: string, status?: number) {
11+
super(message);
12+
this.name = 'CliApiError';
13+
this.status = status;
14+
}
15+
}
16+
17+
// Shared guidance shown when a CLI-API call fails. The gateway only exists in
18+
// staging (cxm-stg) and production (cxm-prd), so a store on another environment
19+
// (e.g. integration) has no gateway to answer the request.
20+
export const CLI_API_ERROR_HINT =
21+
"This usually means the store's environment has no Catalyst CLI API gateway " +
22+
'(only staging and production do), or a transient gateway error. ' +
23+
'Verify --api-host and --cli-api-origin, then try again.';
24+
25+
// Handle a caught error from a CLI-API call: when it's a CliApiError, print a
26+
// clear, actionable message (plus any command-specific note) and exit non-zero,
27+
// leaving the working tree untouched. Anything else is rethrown so unrelated
28+
// failures aren't swallowed.
29+
export function reportCliApiError(error: unknown, context: string, extraInfo?: string): void {
30+
if (!(error instanceof CliApiError)) {
31+
throw error;
32+
}
33+
34+
consola.error(`${context}: ${error.message}`);
35+
consola.info(CLI_API_ERROR_HINT);
36+
37+
if (extraInfo) {
38+
consola.info(extraInfo);
39+
}
40+
41+
process.exit(1);
42+
}

0 commit comments

Comments
 (0)