Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions packages/catalyst/src/cli/commands/channel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Command } from 'commander';
import Conf from 'conf';
import { http, HttpResponse } from 'msw';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest';

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

import { channel } from './channel';

// `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'));

let exitMock: MockInstance;

let tmpDir: string;
Expand Down Expand Up @@ -82,6 +90,13 @@ describe('channel', () => {
expect(update).toBeDefined();
expect(update?.description()).toContain('Update a BigCommerce channel');
});

test('has the link subcommand', () => {
const link = channel.commands.find((cmd) => cmd.name() === 'link');

expect(link).toBeDefined();
expect(link?.description()).toContain('Link this Catalyst project to a BigCommerce channel');
});
});

describe('channel update', () => {
Expand Down Expand Up @@ -253,3 +268,161 @@ describe('channel update', () => {
).rejects.toThrow('Re-run `catalyst auth login`');
});
});

describe('channel link', () => {
const initUrl =
'https://cxm-prd.bigcommerceapp.com/stores/:storeHash/cli-api/v3/channels/:channelId/init';

test('links a channel by id and writes .env.local', async () => {
let initChannelId: string | undefined;

server.use(
http.get(initUrl, ({ params }) => {
initChannelId = String(params.channelId);

return HttpResponse.json({
data: {
storefront_api_token: 'sft-token',
envVars: {
BIGCOMMERCE_STORE_HASH: storeHash,
BIGCOMMERCE_CHANNEL_ID: '2',
BIGCOMMERCE_STOREFRONT_TOKEN: 'sft-token',
},
},
});
}),
);

await program.parseAsync([
'node',
'catalyst',
'channel',
'link',
'--store-hash',
storeHash,
'--access-token',
accessToken,
'--channel-id',
'2',
]);

expect(initChannelId).toBe('2');
expect(mockIdentify).toHaveBeenCalledWith(storeHash);

const envLocal = readFileSync(join(tmpDir, '.env.local'), 'utf8');

expect(envLocal).toContain(`BIGCOMMERCE_STORE_HASH=${storeHash}`);
expect(envLocal).toContain('BIGCOMMERCE_STOREFRONT_TOKEN=sft-token');
expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('Linked to channel 2'));
expect(exitMock).toHaveBeenCalledWith(0);
});

test('prompts for a channel when --channel-id is omitted', async () => {
let initChannelId: string | undefined;

server.use(
http.get(initUrl, ({ params }) => {
initChannelId = String(params.channelId);

return HttpResponse.json({
data: { storefront_api_token: 'sft-token', envVars: { BIGCOMMERCE_CHANNEL_ID: '2' } },
});
}),
);

const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2');

await program.parseAsync([
'node',
'catalyst',
'channel',
'link',
'--store-hash',
storeHash,
'--access-token',
accessToken,
]);

expect(promptMock).toHaveBeenCalledTimes(1);
expect(initChannelId).toBe('2');
// id 2 in the default channels handler is "Catalyst Storefront".
expect(consola.success).toHaveBeenCalledWith(
expect.stringContaining('Linked to channel "Catalyst Storefront" (2)'),
);
});

test('merges --env overrides into .env.local', async () => {
server.use(
http.get(initUrl, () =>
HttpResponse.json({
data: {
storefront_api_token: 'sft-token',
envVars: { BIGCOMMERCE_STORE_HASH: storeHash },
},
}),
),
);

await program.parseAsync([
'node',
'catalyst',
'channel',
'link',
'--store-hash',
storeHash,
'--access-token',
accessToken,
'--channel-id',
'2',
'--env',
'EXTRA_FLAG=on',
'--env',
'BIGCOMMERCE_STORE_HASH=overridden',
]);

const envLocal = readFileSync(join(tmpDir, '.env.local'), 'utf8');

expect(envLocal).toContain('EXTRA_FLAG=on');
expect(envLocal).toContain('BIGCOMMERCE_STORE_HASH=overridden');
});

test('exits when the store has no storefront channels', async () => {
server.use(
http.get('https://:apiHost/stores/:storeHash/v3/channels', () =>
HttpResponse.json({ data: [] }),
),
);

await program.parseAsync([
'node',
'catalyst',
'channel',
'link',
'--store-hash',
storeHash,
'--access-token',
accessToken,
]);

expect(consola.info).toHaveBeenCalledWith(
expect.stringContaining('No storefront channels found'),
);
expect(exitMock).toHaveBeenCalledWith(0);
});

test('logs in and persists credentials when none are available', async () => {
server.use(
http.get(initUrl, () =>
HttpResponse.json({
data: { storefront_api_token: 'sft-token', envVars: { BIGCOMMERCE_CHANNEL_ID: '2' } },
}),
),
);

await program.parseAsync(['node', 'catalyst', 'channel', 'link', '--channel-id', '2']);

expect(config.get('storeHash')).toBe('mock-store-hash');
expect(config.get('accessToken')).toBe('mock-access-token');
expect(mockIdentify).toHaveBeenCalledWith('mock-store-hash');
});
});
175 changes: 173 additions & 2 deletions packages/catalyst/src/cli/commands/channel.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,73 @@
import { Command, Option } from 'commander';
import { Command, InvalidArgumentError, Option } from 'commander';
import type Conf from 'conf';
import { colorize } from 'consola/utils';
import { outputFileSync } from 'fs-extra/esm';
import { join } from 'node:path';

import { runChannelSiteUrlFlow } from '../lib/channel-site-flow';
import {
channelPlatformLabel,
fetchAvailableChannels,
getChannelInit,
sortChannelsByPlatform,
} from '../lib/channels';
import { NoLinkedProjectError } from '../lib/commerce-hosting';
import { parseEnvAssignment } from '../lib/env-config';
import { consola } from '../lib/logger';
import { getProjectConfig } from '../lib/project-config';
import { LoginAbortedError, login as runInteractiveLogin } from '../lib/login';
import { getProjectConfig, type ProjectConfigSchema } from '../lib/project-config';
import { resolveCredentials } from '../lib/resolve-credentials';
import {
accessTokenOption,
apiHostOption,
loginUrlOption,
projectUuidOption,
storeHashOption,
} from '../lib/shared-options';
import { getTelemetry } from '../lib/telemetry';

const parseChannelId = (value: string): number => {
const parsed = Number.parseInt(value, 10);

if (Number.isNaN(parsed)) {
throw new InvalidArgumentError(`"${value}" is not a valid channel ID (expected a number).`);
}

return parsed;
};

// Resolve credentials from flags/env → persisted project config → interactive
// login (persisting on success). Returns null when the user aborts login.
// `channel link` is an onboarding command — a fresh clone has neither
// .env.local nor .bigcommerce/project.json — so it logs the user in like
// `catalyst project create`, rather than erroring like the operational commands.
async function resolveCredentialsWithLogin(
options: { storeHash?: string; accessToken?: string; loginUrl: string; apiHost: string },
config: Conf<ProjectConfigSchema>,
): Promise<{ storeHash: string; accessToken: string } | null> {
const storeHash = options.storeHash ?? config.get('storeHash');
const accessToken = options.accessToken ?? config.get('accessToken');

if (storeHash && accessToken) {
return { storeHash, accessToken };
}

try {
const credentials = await runInteractiveLogin(options.loginUrl, options.apiHost);

config.set('storeHash', credentials.storeHash);
config.set('accessToken', credentials.accessToken);

return credentials;
} catch (error) {
if (error instanceof LoginAbortedError) {
return null;
}

throw error;
}
}

const update = new Command('update')
.configureHelp({ showGlobalOptions: true })
.description(
Expand Down Expand Up @@ -76,7 +131,123 @@ Examples:
process.exit(0);
});

const link = new Command('link')
.configureHelp({ showGlobalOptions: true })
.description(
'Link this Catalyst project to a BigCommerce channel and write its credentials to .env.local.',
)
.addHelpText(
'after',
`
Examples:
# Pick a channel interactively (logs you in if needed)
$ catalyst channel link

# Non-interactive
$ catalyst channel link --store-hash <hash> --access-token <token> --channel-id 123

# Append extra environment variables to .env.local
$ catalyst channel link --channel-id 123 --env MY_FLAG=1`,
)
.addOption(storeHashOption())
.addOption(accessTokenOption())
.addOption(apiHostOption())
.addOption(loginUrlOption())
.addOption(
new Option('--channel-id <id>', 'Link this channel directly, skipping the picker.').argParser(
parseChannelId,
),
)
.option(
'--env <vars...>',
'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).',
)
.addOption(
new Option('--cli-api-origin <origin>', 'Catalyst CLI API origin')
.default('https://cxm-prd.bigcommerceapp.com')
.hideHelp(),
)
.action(async (options) => {
const config = getProjectConfig();

const credentials = await resolveCredentialsWithLogin(options, config);

if (!credentials) {
consola.info(
'Login aborted. Re-run `catalyst channel link` when you have your credentials ready.',
);
process.exit(0);

return;
}

const { storeHash, accessToken } = credentials;

await getTelemetry().identify(storeHash);

let channelId = options.channelId;
let channelName: string | undefined;

if (channelId === undefined) {
const channels = await fetchAvailableChannels(storeHash, accessToken, options.apiHost);

if (channels.length === 0) {
consola.info(
'No storefront channels found on this store. Create one with `catalyst create` and try again.',
);
process.exit(0);

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),
})),
cancel: 'reject',
});

channelId = Number(selected);
channelName = channels.find((c) => c.id === channelId)?.name;
}

const initData = await getChannelInit(channelId, storeHash, accessToken, options.cliApiOrigin);

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

// Inline `--env KEY=VALUE` overrides win over the channel-provided values.
if (options.env) {
options.env.forEach((entry) => {
const { key, value } = parseEnvAssignment(entry);

envVars[key] = value;
});
}

// Writes .env.local in the current working directory — `channel link`
// runs from inside `core/`, the same place `dev`/`build`/`deploy` run.
outputFileSync(
join(process.cwd(), '.env.local'),
`${Object.entries(envVars)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\n`,
);

const label = channelName ? `"${channelName}" (${channelId})` : `${channelId}`;

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`);

process.exit(0);
});

export const channel = new Command('channel')
.configureHelp({ showGlobalOptions: true })
.description('Manage BigCommerce channels.')
.addCommand(link)
.addCommand(update);
Loading
Loading