diff --git a/core/.gitignore b/core/.gitignore index f4086e925..347b1c394 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -54,3 +54,5 @@ build-config.json # OpenNext .open-next .wrangler + +.bigcommerce diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 94898ba88..c78c33f90 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -21,14 +21,13 @@ export const build = new Command('build') .addOption( new Option( '--project-uuid ', - 'Project UUID to be included in the deployment configuration.', - ).env('BIGCOMMERCE_PROJECT_UUID'), + 'Project UUID to be included in the deployment configuration. Can also be set via the CATALYST_PROJECT_UUID environment variable.', + ).env('CATALYST_PROJECT_UUID'), ) .addOption( - new Option('--framework ', 'The framework to use for the build.').choices([ - 'nextjs', - 'catalyst', - ]), + new Option('--framework ', 'The framework to use for the build.') + .env('CATALYST_FRAMEWORK') + .choices(['nextjs', 'catalyst']), ) .action(async (nextBuildOptions, options) => { const coreDir = process.cwd(); @@ -60,7 +59,7 @@ export const build = new Command('build') if (!projectUuid) { throw new Error( - 'Project UUID is required. Please run `catalyst project create` or `catalyst project link` or this command again with --project-uuid .', + 'Project UUID is required. This can be set via the --project-uuid flag, the CATALYST_PROJECT_UUID environment variable, or the projectUuid property in the .bigcommerce/project.json file.', ); } diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 22e7c81dd..a612caa21 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -306,19 +306,19 @@ test('--dry-run skips upload and deployment', async () => { test('reads from env options', () => { const envVariables = parseEnvironmentVariables([ - 'BIGCOMMERCE_STORE_HASH=123', - 'BIGCOMMERCE_STOREFRONT_TOKEN=456', + 'CATALYST_STORE_HASH=123', + 'CATALYST_ACCESS_TOKEN=456', ]); expect(envVariables).toEqual([ { type: 'secret', - key: 'BIGCOMMERCE_STORE_HASH', + key: 'CATALYST_STORE_HASH', value: '123', }, { type: 'secret', - key: 'BIGCOMMERCE_STOREFRONT_TOKEN', + key: 'CATALYST_ACCESS_TOKEN', value: '456', }, ]); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index eee50458f..9181eda25 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -282,18 +282,14 @@ export const deploy = new Command('deploy') .addOption( new Option( '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ) - .env('BIGCOMMERCE_STORE_HASH') - .makeOptionMandatory(), + 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Can also be set via the CATALYST_STORE_HASH environment variable or the storeHash property in the .bigcommerce/project.json file.', + ).env('CATALYST_STORE_HASH'), ) .addOption( new Option( '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ) - .env('BIGCOMMERCE_ACCESS_TOKEN') - .makeOptionMandatory(), + 'BigCommerce access token. Can be found after creating a store-level API account. Can also be set via the CATALYST_ACCESS_TOKEN environment variable or the accessToken property in the .bigcommerce/project.json file.', + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -303,8 +299,8 @@ export const deploy = new Command('deploy') .addOption( new Option( '--project-uuid ', - 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('BIGCOMMERCE_PROJECT_UUID'), + 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Can also be set via the CATALYST_PROJECT_UUID environment variable or the projectUuid property in the .bigcommerce/project.json file.', + ).env('CATALYST_PROJECT_UUID'), ) .addOption( new Option( @@ -318,16 +314,24 @@ export const deploy = new Command('deploy') try { const config = getProjectConfig(); - await telemetry.identify(options.storeHash); - + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); const projectUuid = options.projectUuid ?? config.get('projectUuid'); + if (!storeHash || !accessToken) { + throw new Error( + 'Store hash and access token are required. Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', + ); + } + if (!projectUuid) { throw new Error( - 'Project UUID is required. Please run either `catalyst project link` or `catalyst project create` or this command again with --project-uuid .', + 'Project UUID is required. This can be set via the --project-uuid flag, the CATALYST_PROJECT_UUID environment variable, or the projectUuid property in the .bigcommerce/project.json file.', ); } + await telemetry.identify(storeHash); + await generateBundleZip(); if (options.dryRun) { @@ -341,8 +345,8 @@ export const deploy = new Command('deploy') } const uploadSignature = await generateUploadSignature( - options.storeHash, - options.accessToken, + storeHash, + accessToken, options.apiHost, ); @@ -353,18 +357,13 @@ export const deploy = new Command('deploy') const { deployment_uuid: deploymentUuid } = await createDeployment( projectUuid, uploadSignature.upload_uuid, - options.storeHash, - options.accessToken, + storeHash, + accessToken, options.apiHost, environmentVariables, ); - await getDeploymentStatus( - deploymentUuid, - options.storeHash, - options.accessToken, - options.apiHost, - ); + await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost); } catch (error) { consola.error(error); process.exit(1); diff --git a/packages/catalyst/src/cli/commands/link.spec.ts b/packages/catalyst/src/cli/commands/link.spec.ts new file mode 100644 index 000000000..fda90683c --- /dev/null +++ b/packages/catalyst/src/cli/commands/link.spec.ts @@ -0,0 +1,349 @@ +import { Command } from 'commander'; +import Conf from 'conf'; +import { http, HttpResponse } from 'msw'; +import { afterAll, afterEach, beforeAll, expect, MockInstance, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { program } from '../program'; + +import { link } from './project'; + +let exitMock: MockInstance; + +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const { mockIdentify } = vi.hoisted(() => ({ + mockIdentify: vi.fn(), +})); + +const projectUuid1 = 'a23f5785-fd99-4a94-9fb3-945551623923'; +const projectUuid2 = 'b23f5785-fd99-4a94-9fb3-945551623924'; +const projectUuid3 = 'c23f5785-fd99-4a94-9fb3-945551623925'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + + vi.mock('../lib/telemetry', () => { + return { + Telemetry: vi.fn().mockImplementation(() => { + return { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + analytics: { + closeAndFlush: vi.fn(), + }, + }; + }), + }; + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + + config = getProjectConfig(tmpDir); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +test('properly configured Command instance', () => { + expect(link).toBeInstanceOf(Command); + expect(link.name()).toBe('link'); + expect(link.description()).toBe( + 'Link your local Catalyst project to a BigCommerce infrastructure project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', + ); + expect(link.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ', defaultValue: 'api.bigcommerce.com' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--root-dir ', defaultValue: process.cwd() }), + ]), + ); +}); + +test('sets projectUuid when called with --project-uuid', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + '--root-dir', + tmpDir, + ]); + + expect(consola.start).toHaveBeenCalledWith( + 'Writing project UUID to .bigcommerce/project.json...', + ); + expect(consola.success).toHaveBeenCalledWith( + 'Project UUID written to .bigcommerce/project.json.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + expect(config.get('projectUuid')).toBe(projectUuid1); + expect(config.get('framework')).toBe('catalyst'); +}); + +test('fetches projects and prompts user to select one', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async (message, opts) => { + // Assert the prompt message and options + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); + expect(options[1]).toMatchObject({ + label: 'Project Two', + value: projectUuid2, + }); + expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); + + // Simulate selecting the second option + return new Promise((resolve) => resolve(projectUuid2)); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.start).toHaveBeenCalledWith( + 'Writing project UUID to .bigcommerce/project.json...', + ); + expect(consola.success).toHaveBeenCalledWith( + 'Project UUID written to .bigcommerce/project.json.', + ); + + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('projectUuid')).toBe(projectUuid2); + expect(config.get('framework')).toBe('catalyst'); + + consolaPromptMock.mockRestore(); +}); + +test('prompts to create a new project', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + // Assert the prompt message and options + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); + expect(options[1]).toMatchObject({ + label: 'Project Two', + value: projectUuid2, + }); + expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); + + // Simulate selecting the create option + return new Promise((resolve) => resolve('create')); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return new Promise((resolve) => resolve('New Project')); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); + + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('projectUuid')).toBe(projectUuid3); + expect(config.get('framework')).toBe('catalyst'); + + consolaPromptMock.mockRestore(); +}); + +test('prompts to create a new project', async () => { + server.use( + http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 502 }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + // Assert the prompt message and options + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); + expect(options[1]).toMatchObject({ + label: 'Project Two', + value: projectUuid2, + }); + expect(options[2]).toMatchObject({ label: 'Create a new project', value: 'create' }); + + // Simulate selecting the create option + return new Promise((resolve) => resolve('create')); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return new Promise((resolve) => resolve('New Project')); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.error).toHaveBeenCalledWith( + 'Failed to create project, is the name already in use?', + ); + + expect(exitMock).toHaveBeenCalledWith(1); + + consolaPromptMock.mockRestore(); +}); + +test('errors when infrastructure projects API is not found', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.error).toHaveBeenCalledWith( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); +}); + +test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { + const savedCatalystStoreHash = process.env.CATALYST_STORE_HASH; + const savedCatalystAccessToken = process.env.CATALYST_ACCESS_TOKEN; + const savedCatalystProjectUuid = process.env.CATALYST_PROJECT_UUID; + + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; + delete process.env.CATALYST_PROJECT_UUID; + + const projectConfig = getProjectConfig(tmpDir); + + projectConfig.delete('storeHash'); + projectConfig.delete('accessToken'); + projectConfig.delete('projectUuid'); + + await program.parseAsync(['node', 'catalyst', 'project', 'link', '--root-dir', tmpDir]); + + if (savedCatalystStoreHash !== undefined) + process.env.CATALYST_STORE_HASH = savedCatalystStoreHash; + if (savedCatalystAccessToken !== undefined) + process.env.CATALYST_ACCESS_TOKEN = savedCatalystAccessToken; + if (savedCatalystProjectUuid !== undefined) + process.env.CATALYST_PROJECT_UUID = savedCatalystProjectUuid; + + expect(consola.start).not.toHaveBeenCalled(); + expect(consola.success).not.toHaveBeenCalled(); + expect(consola.error).toHaveBeenCalledWith('Insufficient information to link a project.'); + expect(consola.info).toHaveBeenCalledWith( + 'This command requires either a project UUID or a combination of store hash and access token.', + ); + expect(consola.info).toHaveBeenCalledWith( + 'Project UUID: This can be set via the --project-uuid flag, the CATALYST_PROJECT_UUID environment variable, or the projectUuid property in the .bigcommerce/project.json file.', + ); + expect(consola.info).toHaveBeenCalledWith( + 'Store hash and access token: Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', + ); + + expect(exitMock).toHaveBeenCalledWith(1); +}); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 68a32cab5..b6a63ecc9 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -127,21 +127,29 @@ describe('project create', () => { }); test('with insufficient credentials exits with error', async () => { - // Unset env so Commander doesn't pick up BIGCOMMERCE_* and trigger the create flow (which would prompt for name) - const savedStoreHash = process.env.BIGCOMMERCE_STORE_HASH; - const savedAccessToken = process.env.BIGCOMMERCE_ACCESS_TOKEN; + // Unset env and project.json so the command takes the insufficient-credentials path + const savedStoreHash = process.env.CATALYST_STORE_HASH; + const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; - delete process.env.BIGCOMMERCE_STORE_HASH; - delete process.env.BIGCOMMERCE_ACCESS_TOKEN; + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; + + const projectConfig = getProjectConfig(tmpDir); + + projectConfig.delete('storeHash'); + projectConfig.delete('accessToken'); await program.parseAsync(['node', 'catalyst', 'project', 'create', '--root-dir', tmpDir]); - if (savedStoreHash !== undefined) process.env.BIGCOMMERCE_STORE_HASH = savedStoreHash; - if (savedAccessToken !== undefined) process.env.BIGCOMMERCE_ACCESS_TOKEN = savedAccessToken; + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; expect(consola.error).toHaveBeenCalledWith('Insufficient information to create a project.'); expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + 'This command requires a combination of store hash and access token.', + ); + expect(consola.info).toHaveBeenCalledWith( + 'Store hash and access token: Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', ); expect(exitMock).toHaveBeenCalledWith(1); }); @@ -199,20 +207,28 @@ describe('project list', () => { }); test('with insufficient credentials exits with error', async () => { - const savedStoreHash = process.env.BIGCOMMERCE_STORE_HASH; - const savedAccessToken = process.env.BIGCOMMERCE_ACCESS_TOKEN; + const savedStoreHash = process.env.CATALYST_STORE_HASH; + const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; + + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; - delete process.env.BIGCOMMERCE_STORE_HASH; - delete process.env.BIGCOMMERCE_ACCESS_TOKEN; + const projectConfig = getProjectConfig(tmpDir); + + projectConfig.delete('storeHash'); + projectConfig.delete('accessToken'); await program.parseAsync(['node', 'catalyst', 'project', 'list']); - if (savedStoreHash !== undefined) process.env.BIGCOMMERCE_STORE_HASH = savedStoreHash; - if (savedAccessToken !== undefined) process.env.BIGCOMMERCE_ACCESS_TOKEN = savedAccessToken; + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; expect(consola.error).toHaveBeenCalledWith('Insufficient information to list projects.'); expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + 'This command requires a combination of store hash and access token.', + ); + expect(consola.info).toHaveBeenCalledWith( + 'Store hash and access token: Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', ); expect(exitMock).toHaveBeenCalledWith(1); }); @@ -461,14 +477,38 @@ describe('project link', () => { }); test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { + const savedStoreHash = process.env.CATALYST_STORE_HASH; + const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; + const savedCatalystProjectUuid = process.env.CATALYST_PROJECT_UUID; + + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; + delete process.env.CATALYST_PROJECT_UUID; + + const projectConfig = getProjectConfig(tmpDir); + + projectConfig.delete('storeHash'); + projectConfig.delete('accessToken'); + projectConfig.delete('projectUuid'); + await program.parseAsync(['node', 'catalyst', 'project', 'link', '--root-dir', tmpDir]); + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + if (savedCatalystProjectUuid !== undefined) + process.env.CATALYST_PROJECT_UUID = savedCatalystProjectUuid; + expect(consola.start).not.toHaveBeenCalled(); expect(consola.success).not.toHaveBeenCalled(); expect(consola.error).toHaveBeenCalledWith('Insufficient information to link a project.'); - expect(consola.info).toHaveBeenCalledWith('Provide a project UUID with --project-uuid, or'); expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token to fetch and select a project.', + 'This command requires either a project UUID or a combination of store hash and access token.', + ); + expect(consola.info).toHaveBeenCalledWith( + 'Project UUID: This can be set via the --project-uuid flag, the CATALYST_PROJECT_UUID environment variable, or the projectUuid property in the .bigcommerce/project.json file.', + ); + expect(consola.info).toHaveBeenCalledWith( + 'Store hash and access token: Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', ); expect(exitMock).toHaveBeenCalledWith(1); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index a97980e2e..21c429a49 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -12,14 +12,14 @@ const list = new Command('list') .addOption( new Option( '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'), + 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Can also be set via the CATALYST_STORE_HASH environment variable.', + ).env('CATALYST_STORE_HASH'), ) .addOption( new Option( '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('BIGCOMMERCE_ACCESS_TOKEN'), + 'BigCommerce access token. Can be found after creating a store-level API account. Can also be set via the CATALYST_ACCESS_TOKEN environment variable.', + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -28,21 +28,26 @@ const list = new Command('list') ) .action(async (options) => { try { - if (!options.storeHash || !options.accessToken) { + const config = getProjectConfig(); + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (!storeHash || !accessToken) { consola.error('Insufficient information to list projects.'); + consola.info('This command requires a combination of store hash and access token.'); consola.info( - 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + 'Store hash and access token: Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', ); process.exit(1); return; } - await telemetry.identify(options.storeHash); + await telemetry.identify(storeHash); consola.start('Fetching projects...'); - const projects = await fetchProjects(options.storeHash, options.accessToken, options.apiHost); + const projects = await fetchProjects(storeHash, accessToken, options.apiHost); consola.success('Projects fetched.'); @@ -71,14 +76,14 @@ const create = new Command('create') .addOption( new Option( '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'), + 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Can also be set via the CATALYST_STORE_HASH environment variable.', + ).env('CATALYST_STORE_HASH'), ) .addOption( new Option( '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('BIGCOMMERCE_ACCESS_TOKEN'), + 'BigCommerce access token. Can be found after creating a store-level API account. Can also be set via the CATALYST_ACCESS_TOKEN environment variable.', + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -92,36 +97,36 @@ const create = new Command('create') ) .action(async (options) => { try { - if (!options.storeHash || !options.accessToken) { + const config = getProjectConfig(options.rootDir); + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (!storeHash || !accessToken) { consola.error('Insufficient information to create a project.'); + consola.info('This command requires a combination of store hash and access token.'); consola.info( - 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + 'Store hash and access token: Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', ); process.exit(1); return; } - await telemetry.identify(options.storeHash); + await telemetry.identify(storeHash); const newProjectName = await consola.prompt('Enter a name for the new project:', { type: 'text', }); - const data = await createProject( - newProjectName, - options.storeHash, - options.accessToken, - options.apiHost, - ); + const data = await createProject(newProjectName, storeHash, accessToken, options.apiHost); consola.success(`Project "${data.name}" created successfully.`); - const config = getProjectConfig(options.rootDir); - consola.start('Writing project UUID to .bigcommerce/project.json...'); config.set('projectUuid', data.uuid); config.set('framework', 'catalyst'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); consola.success('Project UUID written to .bigcommerce/project.json.'); process.exit(0); @@ -138,14 +143,14 @@ export const link = new Command('link') .addOption( new Option( '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'), + 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Can also be set via the CATALYST_STORE_HASH environment variable.', + ).env('CATALYST_STORE_HASH'), ) .addOption( new Option( '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('BIGCOMMERCE_ACCESS_TOKEN'), + 'BigCommerce access token. Can be found after creating a store-level API account. Can also be set via the CATALYST_ACCESS_TOKEN environment variable.', + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -154,7 +159,7 @@ export const link = new Command('link') ) .option( '--project-uuid ', - 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Use this to link directly without fetching projects.', + 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Use this to link directly without fetching projects. Can also be set via the CATALYST_PROJECT_UUID environment variable.', ) .option( '--root-dir ', @@ -165,29 +170,40 @@ export const link = new Command('link') try { const config = getProjectConfig(options.rootDir); - const writeProjectConfig = (uuid: string) => { + const writeProjectConfig = ( + uuid: string, + opts?: { storeHash?: string; accessToken?: string }, + ) => { consola.start('Writing project UUID to .bigcommerce/project.json...'); config.set('projectUuid', uuid); config.set('framework', 'catalyst'); + + if (opts?.storeHash !== undefined) { + config.set('storeHash', opts.storeHash); + } + + if (opts?.accessToken !== undefined) { + config.set('accessToken', opts.accessToken); + } + consola.success('Project UUID written to .bigcommerce/project.json.'); }; + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + if (options.projectUuid) { writeProjectConfig(options.projectUuid); process.exit(0); } - if (options.storeHash && options.accessToken) { - await telemetry.identify(options.storeHash); + if (storeHash && accessToken) { + await telemetry.identify(storeHash); consola.start('Fetching projects...'); - const projects = await fetchProjects( - options.storeHash, - options.accessToken, - options.apiHost, - ); + const projects = await fetchProjects(storeHash, accessToken, options.apiHost); consola.success('Projects fetched.'); @@ -218,26 +234,31 @@ export const link = new Command('link') type: 'text', }); - const data = await createProject( - newProjectName, - options.storeHash, - options.accessToken, - options.apiHost, - ); + const data = await createProject(newProjectName, storeHash, accessToken, options.apiHost); projectUuid = data.uuid; consola.success(`Project "${data.name}" created successfully.`); } - writeProjectConfig(projectUuid); + writeProjectConfig(projectUuid, { + storeHash, + accessToken, + }); process.exit(0); } consola.error('Insufficient information to link a project.'); - consola.info('Provide a project UUID with --project-uuid, or'); - consola.info('Provide both --store-hash and --access-token to fetch and select a project.'); + consola.info( + 'This command requires either a project UUID or a combination of store hash and access token.', + ); + consola.info( + 'Project UUID: This can be set via the --project-uuid flag, the CATALYST_PROJECT_UUID environment variable, or the projectUuid property in the .bigcommerce/project.json file.', + ); + consola.info( + 'Store hash and access token: Can be set via the --store-hash and --access-token flags, the CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or the storeHash and accessToken properties in the .bigcommerce/project.json file.', + ); process.exit(1); } catch (error) { consola.error(error instanceof Error ? error.message : error); diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index 2a0c68cbe..2f56fbc35 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -15,10 +15,9 @@ export const start = new Command('start') 'Pass additional options to the start command. If framework is Next.js, see https://nextjs.org/docs/api-reference/cli#start for available options.', ) .addOption( - new Option('--framework ', 'The framework to use for the preview').choices([ - 'catalyst', - 'nextjs', - ]), + new Option('--framework ', 'The framework to use for the preview') + .env('CATALYST_FRAMEWORK') + .choices(['catalyst', 'nextjs']), ) .action(async (startOptions, options) => { try { diff --git a/packages/catalyst/src/cli/config-priority.spec.ts b/packages/catalyst/src/cli/config-priority.spec.ts new file mode 100644 index 000000000..b03364157 --- /dev/null +++ b/packages/catalyst/src/cli/config-priority.spec.ts @@ -0,0 +1,338 @@ +import { http, HttpResponse } from 'msw'; +import { mkdir, realpath, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + MockInstance, + test, + vi, +} from 'vitest'; + +import { handlers } from '../../tests/mocks/handlers'; +import { server } from '../../tests/mocks/node'; +import { textHistory } from '../../tests/mocks/spinner'; + +import { consola } from './lib/logger'; +import { mkTempDir } from './lib/mk-temp-dir'; +import { program } from './program'; + +// eslint-disable-next-line import/dynamic-import-chunkname +vi.mock('yocto-spinner', () => import('../../tests/mocks/spinner')); + +const { mockIdentify } = vi.hoisted(() => ({ + mockIdentify: vi.fn(), +})); + +vi.mock('./lib/telemetry', () => ({ + Telemetry: vi.fn().mockImplementation(() => ({ + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + analytics: { closeAndFlush: vi.fn() }, + })), +})); + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; + +const projectUuidFromFlag = '11111111-fd99-4a94-9fb3-945551623923'; +const projectUuidFromEnv = '22222222-fd99-4a94-9fb3-945551623924'; +const projectUuidFromProjectJson = 'a23f5785-fd99-4a94-9fb3-945551623923'; + +const storeHashFromFlag = 'store-hash-flag'; +const storeHashFromEnv = 'store-hash-env'; +const storeHashFromProjectJson = 'store-hash-json'; + +const accessTokenFromFlag = 'access-token-flag'; +const accessTokenFromEnv = 'access-token-env'; +const accessTokenFromProjectJson = 'access-token-json'; + +const deploymentResponse = () => + HttpResponse.json({ + data: { deployment_uuid: '5b29c3c0-5f68-44fe-99e5-06492babf7be' }, + }); + +async function writeProjectJson(overrides: { + projectUuid?: string; + storeHash?: string; + accessToken?: string; +}) { + const path = join(tmpDir, '.bigcommerce', 'project.json'); + + await mkdir(dirname(path), { recursive: true }); + + const defaults = { + projectUuid: projectUuidFromProjectJson, + framework: 'catalyst' as const, + storeHash: storeHashFromProjectJson, + accessToken: accessTokenFromProjectJson, + telemetry: { enabled: true, anonymousId: 'test-id' }, + }; + + await writeFile(path, JSON.stringify({ ...defaults, ...overrides })); +} + +function useCaptureHandlers(captured: { + storeHash: string; + projectUuid: string; + accessToken: string; +}) { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/deployments/uploads', + ({ request, params }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + captured.storeHash = params.storeHash as string; + captured.accessToken = + request.headers.get('X-Auth-Token') ?? request.headers.get('x-auth-token') ?? ''; + + return HttpResponse.json({ + data: { + upload_url: 'https://mock-upload-url.com', + upload_uuid: '0e93ce5f-6f91-4236-87ec-ca79627f31ba', + }, + }); + }, + ), + ); + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/deployments', + async ({ request, params }) => { + captured.storeHash = + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (params.storeHash as string) || new URL(request.url).pathname.split('/')[2] || ''; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const body = (await request.json()) as { project_uuid?: string }; + + captured.projectUuid = body.project_uuid ?? ''; + captured.accessToken = + request.headers.get('X-Auth-Token') ?? request.headers.get('x-auth-token') ?? ''; + + return deploymentResponse(); + }, + ), + ); +} + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + tmpDir = await realpath(tmpDir); + + // Minimal dist so deploy's generateBundleZip() passes; we only assert on config → request values. + const distDir = join(tmpDir, '.bigcommerce', 'dist'); + + await mkdir(distDir, { recursive: true }); + await writeFile(join(distDir, 'worker.js'), ''); + await mkdir(join(distDir, 'assets'), { recursive: true }); + await writeFile(join(distDir, 'assets', 'placeholder'), ''); +}); + +beforeEach(() => { + process.chdir(tmpDir); + vi.clearAllMocks(); + textHistory.length = 0; + server.resetHandlers(); + server.use(...handlers); +}); + +afterEach(() => { + delete process.env.CATALYST_PROJECT_UUID; + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; +}); + +afterAll(async () => { + server.resetHandlers(); + server.use(...handlers); + exitMock.mockRestore(); + await cleanup(); +}); + +describe('config resolution priority', () => { + describe('projectUuid', () => { + test('explicit flag overrides env and project.json', async () => { + await writeProjectJson({ projectUuid: projectUuidFromProjectJson }); + process.env.CATALYST_PROJECT_UUID = projectUuidFromEnv; + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHashFromFlag, + '--access-token', + accessTokenFromFlag, + '--project-uuid', + projectUuidFromFlag, + ]); + + expect(captured.projectUuid).toBe(projectUuidFromFlag); + }); + + test('environment variable overrides project.json when no flag', async () => { + await writeProjectJson({ projectUuid: projectUuidFromProjectJson }); + process.env.CATALYST_PROJECT_UUID = projectUuidFromEnv; + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + expect(captured.projectUuid).toBe(projectUuidFromEnv); + }); + + test('project.json used when no flag or env', async () => { + await writeProjectJson({ + projectUuid: projectUuidFromProjectJson, + storeHash: storeHashFromProjectJson, + accessToken: accessTokenFromProjectJson, + }); + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + expect(captured.projectUuid).toBe(projectUuidFromProjectJson); + }); + }); + + describe('storeHash', () => { + test('explicit flag overrides env and project.json', async () => { + await writeProjectJson({ storeHash: storeHashFromProjectJson }); + process.env.CATALYST_STORE_HASH = storeHashFromEnv; + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHashFromFlag, + '--access-token', + accessTokenFromFlag, + '--project-uuid', + projectUuidFromFlag, + ]); + + expect(captured.storeHash).toBe(storeHashFromFlag); + }); + + test('environment variable overrides project.json when no flag', async () => { + await writeProjectJson({ + storeHash: storeHashFromProjectJson, + accessToken: accessTokenFromProjectJson, + projectUuid: projectUuidFromProjectJson, + }); + process.env.CATALYST_STORE_HASH = storeHashFromEnv; + process.env.CATALYST_PROJECT_UUID = projectUuidFromEnv; + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + expect(captured.storeHash).toBe(storeHashFromEnv); + }); + + test('project.json used when no flag or env', async () => { + await writeProjectJson({ + projectUuid: projectUuidFromProjectJson, + storeHash: storeHashFromProjectJson, + accessToken: accessTokenFromProjectJson, + }); + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + expect(captured.storeHash).toBe(storeHashFromProjectJson); + }); + }); + + describe('accessToken', () => { + test('explicit flag overrides env and project.json', async () => { + await writeProjectJson({ accessToken: accessTokenFromProjectJson }); + process.env.CATALYST_STORE_HASH = storeHashFromEnv; + process.env.CATALYST_ACCESS_TOKEN = accessTokenFromEnv; + process.env.CATALYST_PROJECT_UUID = projectUuidFromEnv; + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHashFromFlag, + '--access-token', + accessTokenFromFlag, + '--project-uuid', + projectUuidFromFlag, + ]); + + expect(captured.accessToken).toBe(accessTokenFromFlag); + }); + + test('environment variable overrides project.json when no flag', async () => { + await writeProjectJson({ + storeHash: storeHashFromProjectJson, + accessToken: accessTokenFromProjectJson, + projectUuid: projectUuidFromProjectJson, + }); + process.env.CATALYST_STORE_HASH = storeHashFromEnv; + process.env.CATALYST_ACCESS_TOKEN = accessTokenFromEnv; + process.env.CATALYST_PROJECT_UUID = projectUuidFromEnv; + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + expect(captured.accessToken).toBe(accessTokenFromEnv); + }); + + test('project.json used when no flag or env', async () => { + await writeProjectJson({ + projectUuid: projectUuidFromProjectJson, + storeHash: storeHashFromProjectJson, + accessToken: accessTokenFromProjectJson, + }); + + const captured = { storeHash: '', projectUuid: '', accessToken: '' }; + + useCaptureHandlers(captured); + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + expect(captured.accessToken).toBe(accessTokenFromProjectJson); + }); + }); +}); diff --git a/packages/catalyst/src/cli/index.ts b/packages/catalyst/src/cli/index.ts index 42c5a8a76..10ee9979c 100644 --- a/packages/catalyst/src/cli/index.ts +++ b/packages/catalyst/src/cli/index.ts @@ -1,4 +1,23 @@ #!/usr/bin/env node +import { config } from 'dotenv'; +import { resolve } from 'node:path'; + import { program } from './program'; +// Load --env-file before parse so option resolution uses this order: +// 1. Explicit flags 2. --env-file (if passed) 3. process.env (shell) 4. project.json +const envFileIndex = process.argv.findIndex( + (arg) => arg === '--env-file' || arg.startsWith('--env-file='), +); + +if (envFileIndex !== -1) { + const arg = process.argv[envFileIndex]; + const path = + arg === '--env-file' ? process.argv[envFileIndex + 1] : arg.slice('--env-file='.length); + + if (path) { + config({ path: resolve(process.cwd(), path), override: true }); + } +} + program.parse(process.argv); diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 20cd65f98..3ee0770b7 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -4,6 +4,8 @@ import { join } from 'path'; export interface ProjectConfigSchema { projectUuid: string; framework: 'catalyst' | 'nextjs'; + storeHash?: string; + accessToken?: string; telemetry: { enabled: boolean; anonymousId: string; @@ -22,6 +24,8 @@ export function getProjectConfig(rootDir = process.cwd()) { enum: ['catalyst', 'nextjs'], default: 'nextjs', }, + storeHash: { type: 'string' }, + accessToken: { type: 'string' }, telemetry: { type: 'object', properties: { diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 4a99a17ac..f3811aa1e 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -1,7 +1,5 @@ import { Command } from 'commander'; import { colorize } from 'consola/utils'; -import { config } from 'dotenv'; -import { resolve } from 'node:path'; import PACKAGE_INFO from '../../package.json'; @@ -17,22 +15,16 @@ import { consola } from './lib/logger'; export const program = new Command(); -config({ - path: [ - resolve(process.cwd(), '.env'), - resolve(process.cwd(), '.env.local'), - // Assumes the parent directory is the monorepo root: - resolve(process.cwd(), '..', '.env'), - resolve(process.cwd(), '..', '.env.local'), - ], -}); - consola.log(colorize('cyanBright', `◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.version}\n`)); program .name(PACKAGE_INFO.name) .version(PACKAGE_INFO.version) .description('CLI tool for Catalyst development') + .option( + '--env-file ', + 'Path to an environment variable file to load (relative to current directory)', + ) .addCommand(version) .addCommand(dev) .addCommand(start)