Skip to content

Commit abe4a66

Browse files
jorgemoyaclaude
andauthored
LTRAC-908: feat(cli) - Add catalyst channel link command (#3051)
* LTRAC-908: feat(cli) - Add `catalyst channel connect` command Add a `connect` subcommand under `catalyst channel` that connects an existing local checkout to a BigCommerce channel and writes its credentials to .env.local — useful when a teammate clones a Catalyst repo (where .env.local is gitignored) and needs to regenerate it from the channel. Resolves credentials from flags/env, the persisted project config, or an interactive device-code login (persisting the result), then selects a channel (or takes --channel-id), fetches its channel-init data, and writes .env.local in the cwd. Supports repeatable --env KEY=VALUE overrides. Supersedes the dropped plan to port create-catalyst's standalone `init`: the catalyst CLI is native-hosting only and `catalyst create` already connects a channel for new projects, so the only residual need — bootstrapping .env.local for an existing checkout — lives under `channel`. Refs LTRAC-908 Co-Authored-By: Claude <noreply@anthropic.com> * LTRAC-908: ref(cli) - Clean up `channel connect` success output Drop the per-line info-icon clutter on the next-steps hint: print a single success line (channel + .env.local) followed by a spaced, plain block with the `pnpm run dev` command. Refs LTRAC-908 Co-Authored-By: Claude <noreply@anthropic.com> * LTRAC-908: ref(cli) - Rename `channel connect` to `channel link` Align with `catalyst project link` — the established CLI verb for linking a local checkout to a remote BigCommerce resource (infrastructure project vs storefront channel). The success line now reads "Linked to channel …". Refs LTRAC-908 Co-Authored-By: Claude <noreply@anthropic.com> * LTRAC-908: ref(cli) - Extract shared channel sort/label helpers `channel link` had copied `catalyst create`'s channel-platform sort comparator and the Stencil/title-case label. Move both into channels.ts as `sortChannelsByPlatform` (returns a sorted copy) and `channelPlatformLabel`, and use them from both pickers. No behavior change. Refs LTRAC-908 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4035531 commit abe4a66

5 files changed

Lines changed: 406 additions & 27 deletions

File tree

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

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Command } from 'commander';
22
import Conf from 'conf';
33
import { http, HttpResponse } from 'msw';
4+
import { readFileSync } from 'node:fs';
5+
import { join } from 'node:path';
46
import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest';
57

68
import { server } from '../../../tests/mocks/node';
@@ -11,6 +13,12 @@ import { program } from '../program';
1113

1214
import { channel } from './channel';
1315

16+
// `channel link` can trigger the interactive device-code login (browser +
17+
// spinner); stub both so the no-credentials path runs headless in tests.
18+
vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) }));
19+
// eslint-disable-next-line import/dynamic-import-chunkname
20+
vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner'));
21+
1422
let exitMock: MockInstance;
1523

1624
let tmpDir: string;
@@ -82,6 +90,13 @@ describe('channel', () => {
8290
expect(update).toBeDefined();
8391
expect(update?.description()).toContain('Update a BigCommerce channel');
8492
});
93+
94+
test('has the link subcommand', () => {
95+
const link = channel.commands.find((cmd) => cmd.name() === 'link');
96+
97+
expect(link).toBeDefined();
98+
expect(link?.description()).toContain('Link this Catalyst project to a BigCommerce channel');
99+
});
85100
});
86101

87102
describe('channel update', () => {
@@ -253,3 +268,161 @@ describe('channel update', () => {
253268
).rejects.toThrow('Re-run `catalyst auth login`');
254269
});
255270
});
271+
272+
describe('channel link', () => {
273+
const initUrl =
274+
'https://cxm-prd.bigcommerceapp.com/stores/:storeHash/cli-api/v3/channels/:channelId/init';
275+
276+
test('links a channel by id and writes .env.local', async () => {
277+
let initChannelId: string | undefined;
278+
279+
server.use(
280+
http.get(initUrl, ({ params }) => {
281+
initChannelId = String(params.channelId);
282+
283+
return HttpResponse.json({
284+
data: {
285+
storefront_api_token: 'sft-token',
286+
envVars: {
287+
BIGCOMMERCE_STORE_HASH: storeHash,
288+
BIGCOMMERCE_CHANNEL_ID: '2',
289+
BIGCOMMERCE_STOREFRONT_TOKEN: 'sft-token',
290+
},
291+
},
292+
});
293+
}),
294+
);
295+
296+
await program.parseAsync([
297+
'node',
298+
'catalyst',
299+
'channel',
300+
'link',
301+
'--store-hash',
302+
storeHash,
303+
'--access-token',
304+
accessToken,
305+
'--channel-id',
306+
'2',
307+
]);
308+
309+
expect(initChannelId).toBe('2');
310+
expect(mockIdentify).toHaveBeenCalledWith(storeHash);
311+
312+
const envLocal = readFileSync(join(tmpDir, '.env.local'), 'utf8');
313+
314+
expect(envLocal).toContain(`BIGCOMMERCE_STORE_HASH=${storeHash}`);
315+
expect(envLocal).toContain('BIGCOMMERCE_STOREFRONT_TOKEN=sft-token');
316+
expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('Linked to channel 2'));
317+
expect(exitMock).toHaveBeenCalledWith(0);
318+
});
319+
320+
test('prompts for a channel when --channel-id is omitted', async () => {
321+
let initChannelId: string | undefined;
322+
323+
server.use(
324+
http.get(initUrl, ({ params }) => {
325+
initChannelId = String(params.channelId);
326+
327+
return HttpResponse.json({
328+
data: { storefront_api_token: 'sft-token', envVars: { BIGCOMMERCE_CHANNEL_ID: '2' } },
329+
});
330+
}),
331+
);
332+
333+
const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2');
334+
335+
await program.parseAsync([
336+
'node',
337+
'catalyst',
338+
'channel',
339+
'link',
340+
'--store-hash',
341+
storeHash,
342+
'--access-token',
343+
accessToken,
344+
]);
345+
346+
expect(promptMock).toHaveBeenCalledTimes(1);
347+
expect(initChannelId).toBe('2');
348+
// id 2 in the default channels handler is "Catalyst Storefront".
349+
expect(consola.success).toHaveBeenCalledWith(
350+
expect.stringContaining('Linked to channel "Catalyst Storefront" (2)'),
351+
);
352+
});
353+
354+
test('merges --env overrides into .env.local', async () => {
355+
server.use(
356+
http.get(initUrl, () =>
357+
HttpResponse.json({
358+
data: {
359+
storefront_api_token: 'sft-token',
360+
envVars: { BIGCOMMERCE_STORE_HASH: storeHash },
361+
},
362+
}),
363+
),
364+
);
365+
366+
await program.parseAsync([
367+
'node',
368+
'catalyst',
369+
'channel',
370+
'link',
371+
'--store-hash',
372+
storeHash,
373+
'--access-token',
374+
accessToken,
375+
'--channel-id',
376+
'2',
377+
'--env',
378+
'EXTRA_FLAG=on',
379+
'--env',
380+
'BIGCOMMERCE_STORE_HASH=overridden',
381+
]);
382+
383+
const envLocal = readFileSync(join(tmpDir, '.env.local'), 'utf8');
384+
385+
expect(envLocal).toContain('EXTRA_FLAG=on');
386+
expect(envLocal).toContain('BIGCOMMERCE_STORE_HASH=overridden');
387+
});
388+
389+
test('exits when the store has no storefront channels', async () => {
390+
server.use(
391+
http.get('https://:apiHost/stores/:storeHash/v3/channels', () =>
392+
HttpResponse.json({ data: [] }),
393+
),
394+
);
395+
396+
await program.parseAsync([
397+
'node',
398+
'catalyst',
399+
'channel',
400+
'link',
401+
'--store-hash',
402+
storeHash,
403+
'--access-token',
404+
accessToken,
405+
]);
406+
407+
expect(consola.info).toHaveBeenCalledWith(
408+
expect.stringContaining('No storefront channels found'),
409+
);
410+
expect(exitMock).toHaveBeenCalledWith(0);
411+
});
412+
413+
test('logs in and persists credentials when none are available', async () => {
414+
server.use(
415+
http.get(initUrl, () =>
416+
HttpResponse.json({
417+
data: { storefront_api_token: 'sft-token', envVars: { BIGCOMMERCE_CHANNEL_ID: '2' } },
418+
}),
419+
),
420+
);
421+
422+
await program.parseAsync(['node', 'catalyst', 'channel', 'link', '--channel-id', '2']);
423+
424+
expect(config.get('storeHash')).toBe('mock-store-hash');
425+
expect(config.get('accessToken')).toBe('mock-access-token');
426+
expect(mockIdentify).toHaveBeenCalledWith('mock-store-hash');
427+
});
428+
});

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

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,73 @@
1-
import { Command, Option } from 'commander';
1+
import { Command, InvalidArgumentError, Option } from 'commander';
2+
import type Conf from 'conf';
3+
import { colorize } from 'consola/utils';
4+
import { outputFileSync } from 'fs-extra/esm';
5+
import { join } from 'node:path';
26

37
import { runChannelSiteUrlFlow } from '../lib/channel-site-flow';
8+
import {
9+
channelPlatformLabel,
10+
fetchAvailableChannels,
11+
getChannelInit,
12+
sortChannelsByPlatform,
13+
} from '../lib/channels';
414
import { NoLinkedProjectError } from '../lib/commerce-hosting';
15+
import { parseEnvAssignment } from '../lib/env-config';
516
import { consola } from '../lib/logger';
6-
import { getProjectConfig } from '../lib/project-config';
17+
import { LoginAbortedError, login as runInteractiveLogin } from '../lib/login';
18+
import { getProjectConfig, type ProjectConfigSchema } from '../lib/project-config';
719
import { resolveCredentials } from '../lib/resolve-credentials';
820
import {
921
accessTokenOption,
1022
apiHostOption,
23+
loginUrlOption,
1124
projectUuidOption,
1225
storeHashOption,
1326
} from '../lib/shared-options';
1427
import { getTelemetry } from '../lib/telemetry';
1528

29+
const parseChannelId = (value: string): number => {
30+
const parsed = Number.parseInt(value, 10);
31+
32+
if (Number.isNaN(parsed)) {
33+
throw new InvalidArgumentError(`"${value}" is not a valid channel ID (expected a number).`);
34+
}
35+
36+
return parsed;
37+
};
38+
39+
// Resolve credentials from flags/env → persisted project config → interactive
40+
// login (persisting on success). Returns null when the user aborts login.
41+
// `channel link` is an onboarding command — a fresh clone has neither
42+
// .env.local nor .bigcommerce/project.json — so it logs the user in like
43+
// `catalyst project create`, rather than erroring like the operational commands.
44+
async function resolveCredentialsWithLogin(
45+
options: { storeHash?: string; accessToken?: string; loginUrl: string; apiHost: string },
46+
config: Conf<ProjectConfigSchema>,
47+
): Promise<{ storeHash: string; accessToken: string } | null> {
48+
const storeHash = options.storeHash ?? config.get('storeHash');
49+
const accessToken = options.accessToken ?? config.get('accessToken');
50+
51+
if (storeHash && accessToken) {
52+
return { storeHash, accessToken };
53+
}
54+
55+
try {
56+
const credentials = await runInteractiveLogin(options.loginUrl, options.apiHost);
57+
58+
config.set('storeHash', credentials.storeHash);
59+
config.set('accessToken', credentials.accessToken);
60+
61+
return credentials;
62+
} catch (error) {
63+
if (error instanceof LoginAbortedError) {
64+
return null;
65+
}
66+
67+
throw error;
68+
}
69+
}
70+
1671
const update = new Command('update')
1772
.configureHelp({ showGlobalOptions: true })
1873
.description(
@@ -76,7 +131,123 @@ Examples:
76131
process.exit(0);
77132
});
78133

134+
const link = new Command('link')
135+
.configureHelp({ showGlobalOptions: true })
136+
.description(
137+
'Link this Catalyst project to a BigCommerce channel and write its credentials to .env.local.',
138+
)
139+
.addHelpText(
140+
'after',
141+
`
142+
Examples:
143+
# Pick a channel interactively (logs you in if needed)
144+
$ catalyst channel link
145+
146+
# Non-interactive
147+
$ catalyst channel link --store-hash <hash> --access-token <token> --channel-id 123
148+
149+
# Append extra environment variables to .env.local
150+
$ catalyst channel link --channel-id 123 --env MY_FLAG=1`,
151+
)
152+
.addOption(storeHashOption())
153+
.addOption(accessTokenOption())
154+
.addOption(apiHostOption())
155+
.addOption(loginUrlOption())
156+
.addOption(
157+
new Option('--channel-id <id>', 'Link this channel directly, skipping the picker.').argParser(
158+
parseChannelId,
159+
),
160+
)
161+
.option(
162+
'--env <vars...>',
163+
'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).',
164+
)
165+
.addOption(
166+
new Option('--cli-api-origin <origin>', 'Catalyst CLI API origin')
167+
.default('https://cxm-prd.bigcommerceapp.com')
168+
.hideHelp(),
169+
)
170+
.action(async (options) => {
171+
const config = getProjectConfig();
172+
173+
const credentials = await resolveCredentialsWithLogin(options, config);
174+
175+
if (!credentials) {
176+
consola.info(
177+
'Login aborted. Re-run `catalyst channel link` when you have your credentials ready.',
178+
);
179+
process.exit(0);
180+
181+
return;
182+
}
183+
184+
const { storeHash, accessToken } = credentials;
185+
186+
await getTelemetry().identify(storeHash);
187+
188+
let channelId = options.channelId;
189+
let channelName: string | undefined;
190+
191+
if (channelId === undefined) {
192+
const channels = await fetchAvailableChannels(storeHash, accessToken, options.apiHost);
193+
194+
if (channels.length === 0) {
195+
consola.info(
196+
'No storefront channels found on this store. Create one with `catalyst create` and try again.',
197+
);
198+
process.exit(0);
199+
200+
return;
201+
}
202+
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),
209+
})),
210+
cancel: 'reject',
211+
});
212+
213+
channelId = Number(selected);
214+
channelName = channels.find((c) => c.id === channelId)?.name;
215+
}
216+
217+
const initData = await getChannelInit(channelId, storeHash, accessToken, options.cliApiOrigin);
218+
219+
const envVars: Record<string, string> = { ...initData.envVars };
220+
221+
// Inline `--env KEY=VALUE` overrides win over the channel-provided values.
222+
if (options.env) {
223+
options.env.forEach((entry) => {
224+
const { key, value } = parseEnvAssignment(entry);
225+
226+
envVars[key] = value;
227+
});
228+
}
229+
230+
// Writes .env.local in the current working directory — `channel link`
231+
// runs from inside `core/`, the same place `dev`/`build`/`deploy` run.
232+
outputFileSync(
233+
join(process.cwd(), '.env.local'),
234+
`${Object.entries(envVars)
235+
.map(([key, value]) => `${key}=${value}`)
236+
.join('\n')}\n`,
237+
);
238+
239+
const label = channelName ? `"${channelName}" (${channelId})` : `${channelId}`;
240+
241+
consola.success(
242+
`Linked to channel ${label} and wrote ${colorize('cyanBright', '.env.local')}.`,
243+
);
244+
consola.log(`\nStart your storefront:\n\n ${colorize('yellow', 'pnpm run dev')}\n`);
245+
246+
process.exit(0);
247+
});
248+
79249
export const channel = new Command('channel')
80250
.configureHelp({ showGlobalOptions: true })
81251
.description('Manage BigCommerce channels.')
252+
.addCommand(link)
82253
.addCommand(update);

0 commit comments

Comments
 (0)