From c3c6bc492f8b385e37b2fbf0db58282eab620fa1 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Wed, 17 Sep 2025 14:59:46 -0600 Subject: [PATCH 01/90] chore: add initial changeset for @bigcommerce/catalyst --- .changeset/config.json | 3 +-- .changeset/itchy-lions-work.md | 5 +++++ packages/catalyst/package.json | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 .changeset/itchy-lions-work.md diff --git a/.changeset/config.json b/.changeset/config.json index b326eacda5..cbb9db7a44 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -12,6 +12,5 @@ "tag": true }, "baseBranch": "canary", - "updateInternalDependencies": "patch", - "ignore": ["@bigcommerce/catalyst"] + "updateInternalDependencies": "patch" } diff --git a/.changeset/itchy-lions-work.md b/.changeset/itchy-lions-work.md new file mode 100644 index 0000000000..db39b9b042 --- /dev/null +++ b/.changeset/itchy-lions-work.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": major +--- + +Alpha version of the CLI diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index 9697588b0e..281533c5b7 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -9,7 +9,6 @@ "dist", "templates" ], - "private": true, "scripts": { "dev": "tsup --watch", "typecheck": "tsc --noEmit", From d5d6eaa7dd81d0fd87caee8ee8a824d85b9a1239 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Wed, 17 Sep 2025 15:06:50 -0600 Subject: [PATCH 02/90] chore: add pre version setup files --- .changeset/pre.json | 10 ++++++++++ packages/catalyst/CHANGELOG.md | 7 +++++++ packages/catalyst/package.json | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .changeset/pre.json create mode 100644 packages/catalyst/CHANGELOG.md diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..eb0c70f1ff --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,10 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "@bigcommerce/catalyst": "1.0.0" + }, + "changesets": [ + "itchy-lions-work" + ] +} diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md new file mode 100644 index 0000000000..f12e93f45a --- /dev/null +++ b/packages/catalyst/CHANGELOG.md @@ -0,0 +1,7 @@ +# @bigcommerce/catalyst + +## 1.0.0-alpha.0 + +### Major Changes + +- [`acee114`](https://github.com/bigcommerce/catalyst/commit/acee114ca0ee7428e33b1db28a5b3b18914cde4b) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Alpha version of the CLI diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index 281533c5b7..a835d4ac3a 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/catalyst", - "version": "0.1.0", + "version": "1.0.0-alpha.0", "type": "module", "bin": { "catalyst": "dist/cli.js" From a1baee660c39758dfe82079e04a963f438d2a3c1 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Mon, 20 Oct 2025 17:23:27 -0600 Subject: [PATCH 03/90] fix: optimize catalyst deployments --- packages/catalyst/src/cli/commands/build.ts | 7 +- .../catalyst/src/cli/commands/deploy.spec.ts | 13 ++-- packages/catalyst/src/cli/commands/deploy.ts | 69 ++++++++++++++++++- .../src/cli/lib/wrangler-config.spec.ts | 2 +- .../catalyst/src/cli/lib/wrangler-config.ts | 10 +-- .../catalyst/templates/open-next.config.ts | 10 ++- packages/catalyst/templates/public_headers | 2 + packages/catalyst/tests/mocks/handlers.ts | 6 +- 8 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 packages/catalyst/templates/public_headers diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 94898ba888..34364b87c2 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -64,7 +64,7 @@ export const build = new Command('build') ); } - const wranglerConfig = getWranglerConfig(projectUuid, 'PLACEHOLDER_KV_ID'); + const wranglerConfig = getWranglerConfig(projectUuid); consola.start('Copying templates...'); @@ -122,6 +122,11 @@ export const build = new Command('build') recursive: true, force: true, }); + + await copyFile( + join(getModuleCliPath(), 'templates', 'public_headers'), + join(coreDir, '.bigcommerce', 'dist', 'assets', '_headers'), + ); } } catch (error) { consola.error(error); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 22e7c81ddf..c578937cb7 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -71,6 +71,7 @@ beforeAll(async () => { beforeEach(() => { process.chdir(tmpDir); + vi.spyOn(consola, 'prompt').mockResolvedValue(true); }); afterEach(() => { @@ -176,7 +177,7 @@ describe('deployment and event streaming', () => { 'Fetching...', 'Processing...', 'Finalizing...', - 'Deployment completed successfully.', + 'Deployment completed successfully.\n', ]); }); @@ -191,7 +192,7 @@ describe('deployment and event streaming', () => { start(controller) { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"processing","progress":75}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"processing","progress":75}}`, ), ); setTimeout(() => { @@ -201,7 +202,7 @@ describe('deployment and event streaming', () => { setTimeout(() => { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"finalizing","progress":99}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"finalizing","progress":99}}`, ), ); controller.close(); @@ -225,7 +226,7 @@ describe('deployment and event streaming', () => { 'Fetching...', 'Processing...', 'Finalizing...', - 'Deployment completed successfully.', + 'Deployment completed successfully.\n', ]); expect(consola.warn).toHaveBeenCalledWith( @@ -245,13 +246,13 @@ describe('deployment and event streaming', () => { start(controller) { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"processing","progress":75}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"processing","progress":75}}`, ), ); setTimeout(() => { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"unzipping","progress":99},"error":{"code":30}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"unzipping","progress":99},"error":{"code":30}}`, ), ); }, 10); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index eee50458f6..1817909bff 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -44,6 +44,15 @@ const CreateDeploymentSchema = z.object({ }), }); +const ProjectSchema = z.object({ + data: z.array( + z.object({ + uuid: z.uuid(), + name: z.string(), + }), + ), +}); + const DeploymentStatusSchema = z.object({ deployment_uuid: z.uuid(), deployment_status: z.enum(['queued', 'in_progress', 'failed', 'completed']), @@ -53,6 +62,7 @@ const DeploymentStatusSchema = z.object({ progress: z.number(), }) .nullable(), + deployment_url: z.string().nullable(), error: z .object({ code: z.number(), @@ -237,6 +247,7 @@ export const getDeploymentStatus = async ( const decoder = new TextDecoder(); let done = false; + let deploymentUrl: string | undefined; while (!done) { // eslint-disable-next-line no-await-in-loop @@ -250,6 +261,7 @@ export const getDeploymentStatus = async ( .map((s) => s.replace('data:', '').trim()) .filter(Boolean); + // eslint-disable-next-line no-loop-func split.forEach((event) => { try { json = JSON.parse(event); @@ -268,13 +280,50 @@ export const getDeploymentStatus = async ( if (data.event && STEPS[data.event.step] !== spinner.text) { spinner.text = STEPS[data.event.step]; } + + if (data.deployment_url) { + console.log(data.deployment_url); + deploymentUrl = data.deployment_url; + } }); } done = streamDone; } - spinner.success('Deployment completed successfully.'); + spinner.success('Deployment completed successfully.\n'); + + if (deploymentUrl) { + consola.success(`View your deployment at: ${deploymentUrl}`); + } +}; + +export const fetchProject = async ( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +) => { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch projects: ${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const { data } = ProjectSchema.parse(res); + + return data.find((project) => project.uuid === projectUuid); }; export const deploy = new Command('deploy') @@ -328,6 +377,24 @@ export const deploy = new Command('deploy') ); } + const project = await fetchProject( + projectUuid, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + if (!project) { + throw new Error(`Project with UUID ${projectUuid} not found.`); + } + + if (process.env.CI !== 'true') { + await consola.prompt(`Are you sure you want to deploy to the project: ${project.name}?`, { + type: 'confirm', + cancel: 'reject', + }); + } + await generateBundleZip(); if (options.dryRun) { diff --git a/packages/catalyst/src/cli/lib/wrangler-config.spec.ts b/packages/catalyst/src/cli/lib/wrangler-config.spec.ts index 09f4673e0b..93a70c01ab 100644 --- a/packages/catalyst/src/cli/lib/wrangler-config.spec.ts +++ b/packages/catalyst/src/cli/lib/wrangler-config.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { getWranglerConfig } from './wrangler-config'; test('returns a config with name identical to worker self reference service', () => { - const config = getWranglerConfig('uuid', 'kv-namespace-id'); + const config = getWranglerConfig('uuid'); expect(config.name).toBe(`project-uuid`); expect( diff --git a/packages/catalyst/src/cli/lib/wrangler-config.ts b/packages/catalyst/src/cli/lib/wrangler-config.ts index 14ec6512e1..3760d9799e 100644 --- a/packages/catalyst/src/cli/lib/wrangler-config.ts +++ b/packages/catalyst/src/cli/lib/wrangler-config.ts @@ -1,9 +1,9 @@ -export function getWranglerConfig(projectUuid: string, kvNamespaceId: string) { +export function getWranglerConfig(projectUuid: string) { return { $schema: 'node_modules/wrangler/config-schema.json', main: '../.open-next/worker.js', name: `project-${projectUuid}`, - compatibility_date: '2025-07-15', + compatibility_date: '2025-09-15', compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public'], observability: { enabled: true, @@ -24,10 +24,10 @@ export function getWranglerConfig(projectUuid: string, kvNamespaceId: string) { service: `project-${projectUuid}`, }, ], - kv_namespaces: [ + r2_buckets: [ { - binding: 'NEXT_INC_CACHE_KV', - id: kvNamespaceId, + binding: 'NEXT_INC_CACHE_R2_BUCKET', + bucket_name: `project-${projectUuid}`, }, ], durable_objects: { diff --git a/packages/catalyst/templates/open-next.config.ts b/packages/catalyst/templates/open-next.config.ts index b0b2d67a5f..2099c83800 100644 --- a/packages/catalyst/templates/open-next.config.ts +++ b/packages/catalyst/templates/open-next.config.ts @@ -1,20 +1,26 @@ import { defineCloudflareConfig, type OpenNextConfig } from '@opennextjs/cloudflare'; import { purgeCache } from '@opennextjs/cloudflare/overrides/cache-purge/index'; -import kvIncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache'; +import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'; +import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache'; import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue'; import queueCache from '@opennextjs/cloudflare/overrides/queue/queue-cache'; import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache'; const cloudflareConfig = defineCloudflareConfig({ - incrementalCache: kvIncrementalCache, + incrementalCache: withRegionalCache(r2IncrementalCache, { + mode: 'long-lived', + bypassTagCacheOnCacheHit: true, + }), queue: queueCache(doQueue, { regionalCacheTtlSec: 5, + waitForQueueAck: true, }), routePreloadingBehavior: 'withWaitUntil', tagCache: doShardedTagCache({ baseShardSize: 12, regionalCache: true, regionalCacheTtlSec: 5, + regionalCacheDangerouslyPersistMissingTags: true, shardReplication: { numberOfSoftReplicas: 4, numberOfHardReplicas: 2, diff --git a/packages/catalyst/templates/public_headers b/packages/catalyst/templates/public_headers new file mode 100644 index 0000000000..e6320ab147 --- /dev/null +++ b/packages/catalyst/templates/public_headers @@ -0,0 +1,2 @@ +/_next/static/* + Cache-Control: public,max-age=31536000,immutable \ No newline at end of file diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index a2a46c3b42..bf7da4855e 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -44,14 +44,14 @@ export const handlers = [ controller.enqueue( encoder.encode( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"processing","progress":75}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"processing","progress":75},"deployment_url":null}`, ), ); setTimeout(() => { controller.enqueue( encoder.encode( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"finalizing","progress":99}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"finalizing","progress":99},"deployment_url":null}`, ), ); }, 10); @@ -59,7 +59,7 @@ export const handlers = [ controller.enqueue( encoder.encode( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `data: {"deployment_status":"completed","deployment_uuid":"${params.deploymentUuid}","event":null}`, + `data: {"deployment_status":"completed","deployment_uuid":"${params.deploymentUuid}","event":null,"deployment_url":"https://example.com"}`, ), ); controller.close(); From b93b45200edd1cd6c62d10a12a99c1418733b061 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 06:48:05 -0500 Subject: [PATCH 04/90] rename variables to be prefixed with CATALYST_ --- packages/catalyst/src/cli/commands/build.ts | 2 +- packages/catalyst/src/cli/commands/deploy.ts | 6 ++-- .../catalyst/src/cli/commands/project.spec.ts | 30 +++++++++---------- packages/catalyst/src/cli/commands/project.ts | 16 +++++----- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 34364b87c2..5049e3a0fd 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -22,7 +22,7 @@ export const build = new Command('build') new Option( '--project-uuid ', 'Project UUID to be included in the deployment configuration.', - ).env('BIGCOMMERCE_PROJECT_UUID'), + ).env('CATALYST_PROJECT_UUID'), ) .addOption( new Option('--framework ', 'The framework to use for the build.').choices([ diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 1817909bff..f10c4caa2a 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -333,7 +333,7 @@ export const deploy = new Command('deploy') '--store-hash ', 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', ) - .env('BIGCOMMERCE_STORE_HASH') + .env('CATALYST_STORE_HASH') .makeOptionMandatory(), ) .addOption( @@ -341,7 +341,7 @@ export const deploy = new Command('deploy') '--access-token ', 'BigCommerce access token. Can be found after creating a store-level API account.', ) - .env('BIGCOMMERCE_ACCESS_TOKEN') + .env('CATALYST_ACCESS_TOKEN') .makeOptionMandatory(), ) .addOption( @@ -353,7 +353,7 @@ export const deploy = new Command('deploy') new Option( '--project-uuid ', 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('BIGCOMMERCE_PROJECT_UUID'), + ).env('CATALYST_PROJECT_UUID'), ) .addOption( new Option( diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 68a32cab57..fa313f4a21 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -127,21 +127,21 @@ 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 so Commander doesn't pick up CATALYST_* and trigger the create flow (which would prompt for name) + 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; 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).', + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', ); expect(exitMock).toHaveBeenCalledWith(1); }); @@ -199,20 +199,20 @@ 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.BIGCOMMERCE_STORE_HASH; - delete process.env.BIGCOMMERCE_ACCESS_TOKEN; + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; 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).', + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', ); expect(exitMock).toHaveBeenCalledWith(1); }); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index a97980e2e2..bdfc98de1d 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -13,13 +13,13 @@ const list = new Command('list') new Option( '--store-hash ', 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'), + ).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'), + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -31,7 +31,7 @@ const list = new Command('list') if (!options.storeHash || !options.accessToken) { consola.error('Insufficient information to list projects.'); consola.info( - 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', ); process.exit(1); @@ -72,13 +72,13 @@ const create = new Command('create') new Option( '--store-hash ', 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'), + ).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'), + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -95,7 +95,7 @@ const create = new Command('create') if (!options.storeHash || !options.accessToken) { consola.error('Insufficient information to create a project.'); consola.info( - 'Provide both --store-hash and --access-token (or set BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN).', + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', ); process.exit(1); @@ -139,13 +139,13 @@ export const link = new Command('link') new Option( '--store-hash ', 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'), + ).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'), + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') From a53ba6ecedfa51d485fd75e2bd56d3ce2e2b7eec Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Tue, 10 Feb 2026 19:01:54 -0600 Subject: [PATCH 05/90] feat(cli): run build automatically when deploying Deploy now runs the Catalyst build pipeline before bundling, so users no longer need to manually run `catalyst build && catalyst deploy`. --- .changeset/ripe-beers-switch.md | 5 + packages/catalyst/src/cli/commands/build.ts | 132 +++++++++--------- .../catalyst/src/cli/commands/deploy.spec.ts | 5 + packages/catalyst/src/cli/commands/deploy.ts | 21 +-- 4 files changed, 79 insertions(+), 84 deletions(-) create mode 100644 .changeset/ripe-beers-switch.md diff --git a/.changeset/ripe-beers-switch.md b/.changeset/ripe-beers-switch.md new file mode 100644 index 0000000000..ea90bd6138 --- /dev/null +++ b/.changeset/ripe-beers-switch.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Build now runs automatically when deploying. You can pass `--prebuilt` if you don't need to run build before deploying. diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 5049e3a0fd..a6c7597b6a 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -11,6 +11,71 @@ import { getWranglerConfig } from '../lib/wrangler-config'; const WRANGLER_VERSION = '4.24.3'; +export async function buildCatalystProject(projectUuid: string): Promise { + const coreDir = process.cwd(); + const openNextOutDir = join(coreDir, '.open-next'); + const bigcommerceDistDir = join(coreDir, '.bigcommerce', 'dist'); + + const wranglerConfig = getWranglerConfig(projectUuid); + + consola.start('Copying templates...'); + + await copyFile( + join(getModuleCliPath(), 'templates', 'open-next.config.ts'), + join(coreDir, '.bigcommerce', 'open-next.config.ts'), + ); + await writeFile( + join(coreDir, '.bigcommerce', 'wrangler.jsonc'), + JSON.stringify(wranglerConfig, null, 2), + ); + + consola.success('Templates copied'); + + consola.start('Building project...'); + + await execa( + 'pnpm', + [ + 'exec', + 'opennextjs-cloudflare', + 'build', + '--skipWranglerConfigCheck', + '--openNextConfigPath', + join(coreDir, '.bigcommerce', 'open-next.config.ts'), + ], + { + stdout: ['pipe', 'inherit'], + cwd: coreDir, + }, + ); + + await execa( + 'pnpm', + [ + 'dlx', + `wrangler@${WRANGLER_VERSION}`, + 'deploy', + '--config', + join(coreDir, '.bigcommerce', 'wrangler.jsonc'), + '--keep-vars', + '--outdir', + bigcommerceDistDir, + '--dry-run', + ], + { + stdout: ['pipe', 'inherit'], + cwd: coreDir, + }, + ); + + consola.success('Project built'); + + await cp(join(openNextOutDir, 'assets'), join(bigcommerceDistDir, 'assets'), { + recursive: true, + force: true, + }); +} + export const build = new Command('build') .allowUnknownOption() // The unknown options end up in program.args, not in program.opts(). Commander does not take a guess at how to interpret the unknown options. @@ -53,9 +118,6 @@ export const build = new Command('build') } if (framework === 'catalyst') { - const openNextOutDir = join(coreDir, '.open-next'); - const bigcommerceDistDir = join(coreDir, '.bigcommerce', 'dist'); - const projectUuid = options.projectUuid ?? config.get('projectUuid'); if (!projectUuid) { @@ -64,69 +126,7 @@ export const build = new Command('build') ); } - const wranglerConfig = getWranglerConfig(projectUuid); - - consola.start('Copying templates...'); - - await copyFile( - join(getModuleCliPath(), 'templates', 'open-next.config.ts'), - join(coreDir, '.bigcommerce', 'open-next.config.ts'), - ); - await writeFile( - join(coreDir, '.bigcommerce', 'wrangler.jsonc'), - JSON.stringify(wranglerConfig, null, 2), - ); - - consola.success('Templates copied'); - - consola.start('Building project...'); - - await execa( - 'pnpm', - [ - 'exec', - 'opennextjs-cloudflare', - 'build', - '--skipWranglerConfigCheck', - '--openNextConfigPath', - join(coreDir, '.bigcommerce', 'open-next.config.ts'), - ], - { - stdout: ['pipe', 'inherit'], - cwd: coreDir, - }, - ); - - await execa( - 'pnpm', - [ - 'dlx', - `wrangler@${WRANGLER_VERSION}`, - 'deploy', - '--config', - join(coreDir, '.bigcommerce', 'wrangler.jsonc'), - '--keep-vars', - '--outdir', - bigcommerceDistDir, - '--dry-run', - ], - { - stdout: ['pipe', 'inherit'], - cwd: coreDir, - }, - ); - - consola.success('Project built'); - - await cp(join(openNextOutDir, 'assets'), join(bigcommerceDistDir, 'assets'), { - recursive: true, - force: true, - }); - - await copyFile( - join(getModuleCliPath(), 'templates', 'public_headers'), - join(coreDir, '.bigcommerce', 'dist', 'assets', '_headers'), - ); + await buildCatalystProject(projectUuid); } } catch (error) { consola.error(error); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index c578937cb7..f02ff523ac 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -33,6 +33,11 @@ import { // eslint-disable-next-line import/dynamic-import-chunkname vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); +vi.mock('./build', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, buildCatalystProject: vi.fn() }; +}); let exitMock: MockInstance; diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index f10c4caa2a..aed03e38d9 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -9,6 +9,8 @@ import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; import { Telemetry } from '../lib/telemetry'; +import { buildCatalystProject } from './build'; + const telemetry = new Telemetry(); const stepsEnum = z.enum([ @@ -377,24 +379,7 @@ export const deploy = new Command('deploy') ); } - const project = await fetchProject( - projectUuid, - options.storeHash, - options.accessToken, - options.apiHost, - ); - - if (!project) { - throw new Error(`Project with UUID ${projectUuid} not found.`); - } - - if (process.env.CI !== 'true') { - await consola.prompt(`Are you sure you want to deploy to the project: ${project.name}?`, { - type: 'confirm', - cancel: 'reject', - }); - } - + await buildCatalystProject(projectUuid); await generateBundleZip(); if (options.dryRun) { From 5047a4f08bf6703ea60f004ae97f10e886034e95 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Wed, 11 Feb 2026 12:44:57 -0700 Subject: [PATCH 06/90] feat(cli): add --prebuilt flag to deploy command (#2874) skip build step when --prebuilt is passed; fail with actionable error when .bigcommerce/dist/ is missing or empty. --- .../catalyst/src/cli/commands/deploy.spec.ts | 97 ++++++++++++++++++- packages/catalyst/src/cli/commands/deploy.ts | 30 +++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index f02ff523ac..4a6ca5e759 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -1,7 +1,7 @@ import AdmZip from 'adm-zip'; import { Command } from 'commander'; import { http, HttpResponse } from 'msw'; -import { mkdir, realpath, stat, writeFile } from 'node:fs/promises'; +import { mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { afterAll, @@ -21,6 +21,7 @@ import { consola } from '../lib/logger'; import { mkTempDir } from '../lib/mk-temp-dir'; import { program } from '../program'; +import { buildCatalystProject } from './build'; import { createDeployment, deploy, @@ -102,6 +103,7 @@ test('properly configured Command instance', () => { expect.objectContaining({ flags: '--project-uuid ' }), expect.objectContaining({ flags: '--secret ' }), expect.objectContaining({ flags: '--dry-run' }), + expect.objectContaining({ flags: '--prebuilt' }), ]), ); }); @@ -333,3 +335,96 @@ test('reads from env options', () => { 'Invalid secret format: foo_bar. Expected format: KEY=VALUE', ); }); + +describe('--prebuilt flag', () => { + test('skips build step when --prebuilt is passed', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(buildCatalystProject).not.toHaveBeenCalled(); + expect(consola.info).toHaveBeenCalledWith('Using existing build output (--prebuilt).'); + expect(consola.info).toHaveBeenCalledWith('Generating bundle...'); + }); + + test('fails when dist directory is missing', async () => { + const [missingDistDir, missingDistCleanup] = await mkTempDir(); + const resolvedDir = await realpath(missingDistDir); + + process.chdir(resolvedDir); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ]); + + expect(consola.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + }), + ); + expect(exitMock).toHaveBeenCalledWith(1); + + process.chdir(tmpDir); + await missingDistCleanup(); + }); + + test('fails when dist directory is empty', async () => { + const [emptyDistDir, emptyDistCleanup] = await mkTempDir(); + const resolvedDir = await realpath(emptyDistDir); + + await mkdir(join(resolvedDir, '.bigcommerce', 'dist'), { recursive: true }); + + process.chdir(resolvedDir); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ]); + + expect(consola.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + }), + ); + expect(exitMock).toHaveBeenCalledWith(1); + + process.chdir(tmpDir); + await rm(join(resolvedDir, '.bigcommerce'), { recursive: true }); + await emptyDistCleanup(); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index aed03e38d9..f25dc33a5f 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -364,7 +364,10 @@ export const deploy = new Command('deploy') ), ) .option('--dry-run', 'Run the command to generate the bundle without uploading or deploying.') - + .option( + '--prebuilt', + 'Skip the build step. Requires .bigcommerce/dist/ to already contain build output.', + ) .action(async (options) => { try { const config = getProjectConfig(); @@ -379,7 +382,30 @@ export const deploy = new Command('deploy') ); } - await buildCatalystProject(projectUuid); + if (options.prebuilt) { + const distDir = join(process.cwd(), '.bigcommerce', 'dist'); + + try { + await access(distDir); + } catch { + throw new Error( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + ); + } + + const contents = await readdir(distDir); + + if (contents.length === 0) { + throw new Error( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + ); + } + + consola.info('Using existing build output (--prebuilt).'); + } else { + await buildCatalystProject(projectUuid); + } + await generateBundleZip(); if (options.dryRun) { From 31d5a70ef363d22f9e8d2debf3bd6b82a46cd2d9 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Tue, 10 Feb 2026 16:59:59 -0600 Subject: [PATCH 07/90] fix(cli): change --secret from variadic to repeatable --- packages/catalyst/src/cli/commands/deploy.spec.ts | 2 +- packages/catalyst/src/cli/commands/deploy.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 4a6ca5e759..5eb4b26250 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -101,7 +101,7 @@ test('properly configured Command instance', () => { expect.objectContaining({ flags: '--access-token ' }), expect.objectContaining({ flags: '--api-host ', defaultValue: 'api.bigcommerce.com' }), expect.objectContaining({ flags: '--project-uuid ' }), - expect.objectContaining({ flags: '--secret ' }), + expect.objectContaining({ flags: '--secret ' }), expect.objectContaining({ flags: '--dry-run' }), expect.objectContaining({ flags: '--prebuilt' }), ]), diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index f25dc33a5f..a4fd42a9c1 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -359,9 +359,11 @@ export const deploy = new Command('deploy') ) .addOption( new Option( - '--secret ', - 'Secrets to set for the deployment. Format: SECRET_1=FOO SECRET_2=BAR', - ), + '--secret ', + 'Secret to set for the deployment (repeatable). Format: --secret KEY=VALUE', + ).argParser((value: string, previous: string[] = []) => { + return previous.concat([value]); + }), ) .option('--dry-run', 'Run the command to generate the bundle without uploading or deploying.') .option( From 6dfeb4c1891d2a634b98489602f080b82419301e Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Thu, 12 Feb 2026 13:42:32 -0700 Subject: [PATCH 08/90] feat(cli): map ignition error codes to actionable messages (#2879) * feat(cli): map ignition error codes to actionable messages --- .changeset/warm-planes-flash.md | 5 ++ .../catalyst/src/cli/commands/deploy.spec.ts | 4 +- packages/catalyst/src/cli/commands/deploy.ts | 5 +- .../src/cli/lib/deployment-errors.spec.ts | 50 +++++++++++++++++++ .../catalyst/src/cli/lib/deployment-errors.ts | 18 +++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .changeset/warm-planes-flash.md create mode 100644 packages/catalyst/src/cli/lib/deployment-errors.spec.ts create mode 100644 packages/catalyst/src/cli/lib/deployment-errors.ts diff --git a/.changeset/warm-planes-flash.md b/.changeset/warm-planes-flash.md new file mode 100644 index 0000000000..5f37a7510f --- /dev/null +++ b/.changeset/warm-planes-flash.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Map deployment error codes (10–60) to human-readable, actionable messages in the CLI deploy command. Previously, deployment failures showed generic messages like `Deployment failed with error code: 30` with no explanation. diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 5eb4b26250..4f30d47e48 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -276,7 +276,9 @@ describe('deployment and event streaming', () => { await expect( getDeploymentStatus(deploymentUuid, storeHash, accessToken, apiHost), - ).rejects.toThrow('Deployment failed with error code: 30'); + ).rejects.toThrow( + 'Deployment failed (error code 30): Your bundle could not be extracted. This may mean your build output is too large (max 64 MB compressed / 512 MB uncompressed) or the archive is corrupted. Try reducing your build size or rebuilding your project and deploying again.', + ); expect(consola.info).toHaveBeenCalledWith('Fetching deployment status...'); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index a4fd42a9c1..d18579f398 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { getDeploymentErrorMessage } from '../lib/deployment-errors'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; import { Telemetry } from '../lib/telemetry'; @@ -276,7 +277,9 @@ export const getDeploymentStatus = async ( const data = DeploymentStatusSchema.parse(json); if (data.error) { - throw new Error(`Deployment failed with error code: ${data.error.code}`); + throw new Error( + `Deployment failed (error code ${data.error.code}): ${getDeploymentErrorMessage(data.error.code)}`, + ); } if (data.event && STEPS[data.event.step] !== spinner.text) { diff --git a/packages/catalyst/src/cli/lib/deployment-errors.spec.ts b/packages/catalyst/src/cli/lib/deployment-errors.spec.ts new file mode 100644 index 0000000000..654e4c8876 --- /dev/null +++ b/packages/catalyst/src/cli/lib/deployment-errors.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'vitest'; + +import { getDeploymentErrorMessage } from './deployment-errors'; + +describe('getDeploymentErrorMessage', () => { + test('code 10 — internal error', () => { + expect(getDeploymentErrorMessage(10)).toBe( + 'Something went wrong on our end. Please try again. If the issue persists, contact support.', + ); + }); + + test('code 20 — bundle retrieval failure', () => { + expect(getDeploymentErrorMessage(20)).toBe( + "We couldn't retrieve your bundle. This is usually a temporary issue — please try deploying again. If the problem continues, contact support.", + ); + }); + + test('code 30 — bundle extraction failure', () => { + expect(getDeploymentErrorMessage(30)).toBe( + 'Your bundle could not be extracted. This may mean your build output is too large (max 64 MB compressed / 512 MB uncompressed) or the archive is corrupted. Try reducing your build size or rebuilding your project and deploying again.', + ); + }); + + test('code 40 — build output validation failure', () => { + const message = getDeploymentErrorMessage(40); + + expect(message).toContain("There's a problem with your build output."); + expect(message).toContain('A worker.js file larger than 40 MB'); + expect(message).toContain('An individual asset file larger than 25 MB'); + expect(message).toContain('More than 1,000 total files in the bundle'); + }); + + test('code 50 — deployment failure', () => { + expect(getDeploymentErrorMessage(50)).toBe( + 'Deployment failed. This is usually a temporary issue — please try again. If the problem persists, contact support.', + ); + }); + + test('code 60 — deployment URL resolution failure', () => { + expect(getDeploymentErrorMessage(60)).toBe( + "Your code was deployed, but we couldn't determine your deployment URL. Please try deploying again. If the issue persists, contact support.", + ); + }); + + test('unknown code — fallback message', () => { + expect(getDeploymentErrorMessage(99)).toBe( + 'Deployment failed with an unexpected error (code: 99). Please try again. If the issue persists, contact support.', + ); + }); +}); diff --git a/packages/catalyst/src/cli/lib/deployment-errors.ts b/packages/catalyst/src/cli/lib/deployment-errors.ts new file mode 100644 index 0000000000..29e0f67243 --- /dev/null +++ b/packages/catalyst/src/cli/lib/deployment-errors.ts @@ -0,0 +1,18 @@ +const ERROR_MESSAGES: Record = { + 10: 'Something went wrong on our end. Please try again. If the issue persists, contact support.', + 20: "We couldn't retrieve your bundle. This is usually a temporary issue — please try deploying again. If the problem continues, contact support.", + 30: 'Your bundle could not be extracted. This may mean your build output is too large (max 64 MB compressed / 512 MB uncompressed) or the archive is corrupted. Try reducing your build size or rebuilding your project and deploying again.', + 40: `There's a problem with your build output. This could be caused by: +- A worker.js file larger than 40 MB +- An individual asset file larger than 25 MB +- More than 1,000 total files in the bundle`, + 50: 'Deployment failed. This is usually a temporary issue — please try again. If the problem persists, contact support.', + 60: "Your code was deployed, but we couldn't determine your deployment URL. Please try deploying again. If the issue persists, contact support.", +}; + +export function getDeploymentErrorMessage(code: number): string { + return ( + ERROR_MESSAGES[code] ?? + `Deployment failed with an unexpected error (code: ${code}). Please try again. If the issue persists, contact support.` + ); +} From 75cf1be45a35ffc63277fb74c84a4c011f4abf62 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 11:41:43 -0500 Subject: [PATCH 09/90] write and read access token and store hash from project json file --- .../catalyst/src/cli/commands/deploy.spec.ts | 61 +++++++++++++++++++ packages/catalyst/src/cli/commands/deploy.ts | 40 +++++++----- .../catalyst/src/cli/commands/project.spec.ts | 8 ++- packages/catalyst/src/cli/commands/project.ts | 28 +++++++-- .../src/cli/lib/project-config.spec.ts | 8 ++- .../catalyst/src/cli/lib/project-config.ts | 4 ++ 6 files changed, 124 insertions(+), 25 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 4f30d47e48..07f5d711d2 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -19,6 +19,7 @@ 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 { getProjectConfig } from '../lib/project-config'; import { program } from '../program'; import { buildCatalystProject } from './build'; @@ -314,6 +315,66 @@ test('--dry-run skips upload and deployment', async () => { expect(exitMock).toHaveBeenCalledWith(0); }); +test('--dry-run uses storeHash and accessToken from .bigcommerce/project.json when not provided', async () => { + const config = getProjectConfig(); + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--dry-run', + ]); + + expect(consola.info).toHaveBeenCalledWith('Generating bundle...'); + expect(consola.success).toHaveBeenCalledWith(`Bundle created at: ${outputZip}`); + expect(exitMock).toHaveBeenCalledWith(0); +}); + +test('errors when store hash is missing and not in .bigcommerce/project.json', async () => { + const config = getProjectConfig(); + config.set('projectUuid', projectUuid); + config.set('accessToken', accessToken); + config.delete('storeHash'); + + const savedStoreHash = process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_STORE_HASH; + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + + expect(consola.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Store hash is required'), + }), + ); + expect(exitMock).toHaveBeenCalledWith(1); +}); + +test('errors when access token is missing and not in .bigcommerce/project.json', async () => { + const config = getProjectConfig(); + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.delete('accessToken'); + + const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; + delete process.env.CATALYST_ACCESS_TOKEN; + + await program.parseAsync(['node', 'catalyst', 'deploy']); + + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Access token is required'), + }), + ); + expect(exitMock).toHaveBeenCalledWith(1); +}); + test('reads from env options', () => { const envVariables = parseEnvironmentVariables([ 'BIGCOMMERCE_STORE_HASH=123', diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index d18579f398..8c62aecda6 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -336,18 +336,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('CATALYST_STORE_HASH') - .makeOptionMandatory(), + 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Read from .bigcommerce/project.json when not provided.', + ).env('CATALYST_STORE_HASH'), ) .addOption( new Option( '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ) - .env('CATALYST_ACCESS_TOKEN') - .makeOptionMandatory(), + 'BigCommerce access token. Can be found after creating a store-level API account. Read from .bigcommerce/project.json when not provided.', + ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -376,8 +372,10 @@ export const deploy = new Command('deploy') .action(async (options) => { try { const config = getProjectConfig(); + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); - await telemetry.identify(options.storeHash); + await telemetry.identify(storeHash); const projectUuid = options.projectUuid ?? config.get('projectUuid'); @@ -423,9 +421,21 @@ export const deploy = new Command('deploy') process.exit(0); } + if (!storeHash) { + throw new Error( + 'Store hash is required. Provide --store-hash (or set CATALYST_STORE_HASH), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + ); + } + + if (!accessToken) { + throw new Error( + 'Access token is required. Provide --access-token (or set CATALYST_ACCESS_TOKEN), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + ); + } + const uploadSignature = await generateUploadSignature( - options.storeHash, - options.accessToken, + storeHash, + accessToken, options.apiHost, ); @@ -436,16 +446,16 @@ 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, + storeHash, + accessToken, options.apiHost, ); } catch (error) { diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index fa313f4a21..084abe8333 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -122,6 +122,8 @@ describe('project create', () => { expect(config.get('projectUuid')).toBe('c23f5785-fd99-4a94-9fb3-945551623925'); expect(config.get('framework')).toBe('catalyst'); + expect(config.get('storeHash')).toBe(storeHash); + expect(config.get('accessToken')).toBe(accessToken); consolaPromptMock.mockRestore(); }); @@ -212,7 +214,7 @@ describe('project list', () => { expect(consola.error).toHaveBeenCalledWith('Insufficient information to list projects.'); expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN), or run from a project that has been linked with credentials.', ); expect(exitMock).toHaveBeenCalledWith(1); }); @@ -313,6 +315,8 @@ describe('project link', () => { expect(config.get('projectUuid')).toBe(projectUuid2); expect(config.get('framework')).toBe('catalyst'); + expect(config.get('storeHash')).toBe(storeHash); + expect(config.get('accessToken')).toBe(accessToken); consolaPromptMock.mockRestore(); }); @@ -368,6 +372,8 @@ describe('project link', () => { expect(config.get('projectUuid')).toBe(projectUuid3); expect(config.get('framework')).toBe('catalyst'); + expect(config.get('storeHash')).toBe(storeHash); + expect(config.get('accessToken')).toBe(accessToken); consolaPromptMock.mockRestore(); }); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index bdfc98de1d..73671914d0 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -28,21 +28,25 @@ 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( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN), or run from a project that has been linked with credentials.', ); 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.'); @@ -122,6 +126,8 @@ const create = new Command('create') consola.start('Writing project UUID to .bigcommerce/project.json...'); config.set('projectUuid', data.uuid); config.set('framework', 'catalyst'); + config.set('storeHash', options.storeHash); + config.set('accessToken', options.accessToken); consola.success('Project UUID written to .bigcommerce/project.json.'); process.exit(0); @@ -165,10 +171,17 @@ export const link = new Command('link') try { const config = getProjectConfig(options.rootDir); - const writeProjectConfig = (uuid: string) => { + const writeProjectConfig = ( + uuid: string, + credentials?: { storeHash: string; accessToken: string }, + ) => { consola.start('Writing project UUID to .bigcommerce/project.json...'); config.set('projectUuid', uuid); config.set('framework', 'catalyst'); + if (credentials) { + config.set('storeHash', credentials.storeHash); + config.set('accessToken', credentials.accessToken); + } consola.success('Project UUID written to .bigcommerce/project.json.'); }; @@ -230,7 +243,10 @@ export const link = new Command('link') consola.success(`Project "${data.name}" created successfully.`); } - writeProjectConfig(projectUuid); + writeProjectConfig(projectUuid, { + storeHash: options.storeHash, + accessToken: options.accessToken, + }); process.exit(0); } diff --git a/packages/catalyst/src/cli/lib/project-config.spec.ts b/packages/catalyst/src/cli/lib/project-config.spec.ts index 8e0fcd17ca..432a3665fc 100644 --- a/packages/catalyst/src/cli/lib/project-config.spec.ts +++ b/packages/catalyst/src/cli/lib/project-config.spec.ts @@ -40,10 +40,12 @@ test('writes and reads field from .bigcommerce/project.json', async () => { await writeFile(projectJsonPath, JSON.stringify({})); config.set('projectUuid', projectUuid); + config.set('storeHash', 'abc123'); + config.set('accessToken', 'secret-token'); - const modifiedProjectUuid = config.get('projectUuid'); - - expect(modifiedProjectUuid).toBe(projectUuid); + expect(config.get('projectUuid')).toBe(projectUuid); + expect(config.get('storeHash')).toBe('abc123'); + expect(config.get('accessToken')).toBe('secret-token'); }); test('sets default framework to nextjs', async () => { diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 20cd65f98d..3ee0770b74 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: { From b536b0104145d1bc5c92c4c8024ba503a5035897 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 12:30:01 -0500 Subject: [PATCH 10/90] fix linting --- packages/catalyst/src/cli/commands/deploy.spec.ts | 12 ++++++------ packages/catalyst/src/cli/commands/deploy.ts | 7 +------ packages/catalyst/src/cli/commands/project.ts | 2 ++ 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 07f5d711d2..36d92fd660 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -317,16 +317,12 @@ test('--dry-run skips upload and deployment', async () => { test('--dry-run uses storeHash and accessToken from .bigcommerce/project.json when not provided', async () => { const config = getProjectConfig(); + config.set('projectUuid', projectUuid); config.set('storeHash', storeHash); config.set('accessToken', accessToken); - await program.parseAsync([ - 'node', - 'catalyst', - 'deploy', - '--dry-run', - ]); + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); expect(consola.info).toHaveBeenCalledWith('Generating bundle...'); expect(consola.success).toHaveBeenCalledWith(`Bundle created at: ${outputZip}`); @@ -335,11 +331,13 @@ test('--dry-run uses storeHash and accessToken from .bigcommerce/project.json wh test('errors when store hash is missing and not in .bigcommerce/project.json', async () => { const config = getProjectConfig(); + config.set('projectUuid', projectUuid); config.set('accessToken', accessToken); config.delete('storeHash'); const savedStoreHash = process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_STORE_HASH; await program.parseAsync(['node', 'catalyst', 'deploy']); @@ -356,11 +354,13 @@ test('errors when store hash is missing and not in .bigcommerce/project.json', a test('errors when access token is missing and not in .bigcommerce/project.json', async () => { const config = getProjectConfig(); + config.set('projectUuid', projectUuid); config.set('storeHash', storeHash); config.delete('accessToken'); const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; + delete process.env.CATALYST_ACCESS_TOKEN; await program.parseAsync(['node', 'catalyst', 'deploy']); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 8c62aecda6..09bef8faa4 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -452,12 +452,7 @@ export const deploy = new Command('deploy') environmentVariables, ); - await getDeploymentStatus( - deploymentUuid, - storeHash, - 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/project.ts b/packages/catalyst/src/cli/commands/project.ts index 73671914d0..59692594d1 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -178,10 +178,12 @@ export const link = new Command('link') consola.start('Writing project UUID to .bigcommerce/project.json...'); config.set('projectUuid', uuid); config.set('framework', 'catalyst'); + if (credentials) { config.set('storeHash', credentials.storeHash); config.set('accessToken', credentials.accessToken); } + consola.success('Project UUID written to .bigcommerce/project.json.'); }; From a36e4952fa4aef4e1b483910b214351caa747a71 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 16:05:05 -0500 Subject: [PATCH 11/90] fix linting --- packages/catalyst/src/cli/commands/deploy.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 36d92fd660..9193d35cd0 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -346,6 +346,7 @@ test('errors when store hash is missing and not in .bigcommerce/project.json', a expect(consola.error).toHaveBeenCalledWith( expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Vitest matcher return type is any message: expect.stringContaining('Store hash is required'), }), ); @@ -369,6 +370,7 @@ test('errors when access token is missing and not in .bigcommerce/project.json', expect(consola.error).toHaveBeenCalledWith( expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Vitest matcher return type is any message: expect.stringContaining('Access token is required'), }), ); From 7787447d3191ec9cb667f66a0689dd41d527619c Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 16:27:09 -0500 Subject: [PATCH 12/90] added changeset --- .changeset/honest-nights-knock.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/honest-nights-knock.md diff --git a/.changeset/honest-nights-knock.md b/.changeset/honest-nights-knock.md new file mode 100644 index 0000000000..189282d523 --- /dev/null +++ b/.changeset/honest-nights-knock.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Write additional config variables to the project.json file so that we have a centralized place for all required variables. From b33f4acd42c539e907fffe4921a46ac138f5927f Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 09:37:52 -0600 Subject: [PATCH 13/90] updated deploy tests to use vitest stubbing for environment variables --- .../catalyst/src/cli/commands/deploy.spec.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 9193d35cd0..f110830ddf 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -336,14 +336,10 @@ test('errors when store hash is missing and not in .bigcommerce/project.json', a config.set('accessToken', accessToken); config.delete('storeHash'); - const savedStoreHash = process.env.CATALYST_STORE_HASH; - - delete process.env.CATALYST_STORE_HASH; + vi.stubEnv('CATALYST_STORE_HASH', undefined); await program.parseAsync(['node', 'catalyst', 'deploy']); - if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; - expect(consola.error).toHaveBeenCalledWith( expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Vitest matcher return type is any @@ -351,6 +347,8 @@ test('errors when store hash is missing and not in .bigcommerce/project.json', a }), ); expect(exitMock).toHaveBeenCalledWith(1); + + vi.unstubAllEnvs(); }); test('errors when access token is missing and not in .bigcommerce/project.json', async () => { @@ -360,14 +358,10 @@ test('errors when access token is missing and not in .bigcommerce/project.json', config.set('storeHash', storeHash); config.delete('accessToken'); - const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; - - delete process.env.CATALYST_ACCESS_TOKEN; + vi.stubEnv('CATALYST_ACCESS_TOKEN', undefined); await program.parseAsync(['node', 'catalyst', 'deploy']); - if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; - expect(consola.error).toHaveBeenCalledWith( expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Vitest matcher return type is any @@ -375,6 +369,8 @@ test('errors when access token is missing and not in .bigcommerce/project.json', }), ); expect(exitMock).toHaveBeenCalledWith(1); + + vi.unstubAllEnvs(); }); test('reads from env options', () => { From 75a82183c57a4e8815b439c9468dcc5e490fcb4b Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 09:47:12 -0600 Subject: [PATCH 14/90] check valid options first in deploy command --- packages/catalyst/src/cli/commands/deploy.ts | 29 ++++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 09bef8faa4..47c78c1e92 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -374,9 +374,6 @@ export const deploy = new Command('deploy') const config = getProjectConfig(); const storeHash = options.storeHash ?? config.get('storeHash'); const accessToken = options.accessToken ?? config.get('accessToken'); - - await telemetry.identify(storeHash); - const projectUuid = options.projectUuid ?? config.get('projectUuid'); if (!projectUuid) { @@ -385,6 +382,20 @@ export const deploy = new Command('deploy') ); } + if (!storeHash) { + throw new Error( + 'Store hash is required. Provide --store-hash (or set CATALYST_STORE_HASH), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + ); + } + + if (!accessToken) { + throw new Error( + 'Access token is required. Provide --access-token (or set CATALYST_ACCESS_TOKEN), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + ); + } + + await telemetry.identify(storeHash); + if (options.prebuilt) { const distDir = join(process.cwd(), '.bigcommerce', 'dist'); @@ -421,18 +432,6 @@ export const deploy = new Command('deploy') process.exit(0); } - if (!storeHash) { - throw new Error( - 'Store hash is required. Provide --store-hash (or set CATALYST_STORE_HASH), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', - ); - } - - if (!accessToken) { - throw new Error( - 'Access token is required. Provide --access-token (or set CATALYST_ACCESS_TOKEN), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', - ); - } - const uploadSignature = await generateUploadSignature( storeHash, accessToken, From e443e1030b51a75a8965a6eee418de84390f2013 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 16:34:33 -0500 Subject: [PATCH 15/90] remove default loading of environment variable files --- packages/catalyst/src/cli/program.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 4a99a17ac5..0b7dc93257 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,16 +15,6 @@ 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 From fa2a66faf49229743f9a1214042f385ea2d1a405 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 16:36:17 -0500 Subject: [PATCH 16/90] added changeset --- .changeset/curvy-pandas-stop.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/curvy-pandas-stop.md diff --git a/.changeset/curvy-pandas-stop.md b/.changeset/curvy-pandas-stop.md new file mode 100644 index 0000000000..430aa994bf --- /dev/null +++ b/.changeset/curvy-pandas-stop.md @@ -0,0 +1,9 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Remove the reading of default environment variable files. + +## Migration + +Ensure that environment variables are passed explicitly using flags if they are not already included in the `project.json` config file. From 010fe6c56204c64e80ca2b496bd569e711016c78 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 10:01:51 -0600 Subject: [PATCH 17/90] add .bigcommerce directory to core .gitignore file --- core/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/.gitignore b/core/.gitignore index f4086e9252..e3e9341c34 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -54,3 +54,5 @@ build-config.json # OpenNext .open-next .wrangler + +.bigcommerce \ No newline at end of file From c790654947fa81900a3bb75a57891add1b2c174d Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 10:03:29 -0600 Subject: [PATCH 18/90] add changeset --- .changeset/kind-hairs-study.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/kind-hairs-study.md diff --git a/.changeset/kind-hairs-study.md b/.changeset/kind-hairs-study.md new file mode 100644 index 0000000000..c732f96f08 --- /dev/null +++ b/.changeset/kind-hairs-study.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Ignore .bigcommerce folder in the core directory since the project.json file can contain sensitive variables From 46ae3cac29e1cf826417ff9df2faea8b8756e7d3 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 10:41:05 -0600 Subject: [PATCH 19/90] add comment and new line in gitignore --- core/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/.gitignore b/core/.gitignore index e3e9341c34..12fd47d6ed 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -55,4 +55,5 @@ build-config.json .open-next .wrangler -.bigcommerce \ No newline at end of file +# Catalyst CLI config +.bigcommerce From a82fa362d7d36a5aaeff647b01420bde4c8b4f49 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 11:30:37 -0600 Subject: [PATCH 20/90] remove changeset --- .changeset/kind-hairs-study.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/kind-hairs-study.md diff --git a/.changeset/kind-hairs-study.md b/.changeset/kind-hairs-study.md deleted file mode 100644 index c732f96f08..0000000000 --- a/.changeset/kind-hairs-study.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": patch ---- - -Ignore .bigcommerce folder in the core directory since the project.json file can contain sensitive variables From 9dc7c8f1a868b0d5f1ffbdfbeda9b17d361197ab Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Mon, 16 Feb 2026 16:29:18 -0600 Subject: [PATCH 21/90] feat(cli): add whoami command (#2881) --- .changeset/brave-maps-arrive.md | 5 + packages/catalyst/src/cli/commands/auth.ts | 106 +++++++++++++++++++++ packages/catalyst/src/cli/program.ts | 2 + packages/catalyst/tests/mocks/handlers.ts | 9 ++ 4 files changed, 122 insertions(+) create mode 100644 .changeset/brave-maps-arrive.md create mode 100644 packages/catalyst/src/cli/commands/auth.ts diff --git a/.changeset/brave-maps-arrive.md b/.changeset/brave-maps-arrive.md new file mode 100644 index 0000000000..135910ebc6 --- /dev/null +++ b/.changeset/brave-maps-arrive.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Add `catalyst whoami` command to verify stored credentials and display store/project info. diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts new file mode 100644 index 0000000000..38c94989c9 --- /dev/null +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -0,0 +1,106 @@ +import { Command, Option } from 'commander'; +import { z } from 'zod'; + +import { consola } from '../lib/logger'; +import { fetchProjects } from '../lib/project'; +import { getProjectConfig } from '../lib/project-config'; + +const StoreProfileSchema = z.object({ + data: z.object({ + store_name: z.string(), + }), +}); + +async function fetchStoreProfile(storeHash: string, accessToken: string, apiHost: string) { + const response = await fetch(`https://${apiHost}/stores/${storeHash}/v3/settings/store/profile`, { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const { data } = StoreProfileSchema.parse(res); + + return data; +} + +const whoami = new Command('whoami') + .description('Verify stored credentials and display store/project info.') + .addOption( + new Option( + '--store-hash ', + 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', + ).env('CATALYST_STORE_HASH'), + ) + .addOption( + new Option( + '--access-token ', + 'BigCommerce access token. Can be found after creating a store-level API account.', + ).env('CATALYST_ACCESS_TOKEN'), + ) + .addOption( + new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') + .env('BIGCOMMERCE_API_HOST') + .default('api.bigcommerce.com'), + ) + .action(async (options) => { + try { + const config = getProjectConfig(); + + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (!storeHash || !accessToken) { + consola.info('Not logged in: no credentials found.'); + consola.info( + 'Provide --store-hash and --access-token flags, set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or run `catalyst project create` / `catalyst project link` with credentials.', + ); + process.exit(1); + + return; + } + + const store = await fetchStoreProfile(storeHash, accessToken, options.apiHost); + + const projectUuid = config.get('projectUuid'); + + if (projectUuid) { + const projects = await fetchProjects(storeHash, accessToken, options.apiHost); + const linkedProject = projects.find((p) => p.uuid === projectUuid); + + if (linkedProject) { + consola.info( + `Logged in to ${store.store_name} (${storeHash}), connected to project ${linkedProject.name} (${projectUuid})`, + ); + } else { + consola.info( + `Logged in to ${store.store_name} (${storeHash}), project ${projectUuid} not found`, + ); + } + } else { + consola.info(`Logged in to ${store.store_name} (${storeHash})`); + } + + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes('401') || message.includes('403')) { + consola.error(`Not logged in: invalid credentials (${message})`); + } else { + consola.error(`Failed to verify credentials: ${message}`); + } + + process.exit(1); + } + }); + +export const auth = new Command('auth') + .description('Manage authentication for the BigCommerce CLI.') + .addCommand(whoami); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 0b7dc93257..8bebb370fa 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -3,6 +3,7 @@ import { colorize } from 'consola/utils'; import PACKAGE_INFO from '../../package.json'; +import { auth } from './commands/auth'; import { build } from './commands/build'; import { deploy } from './commands/deploy'; import { dev } from './commands/dev'; @@ -27,6 +28,7 @@ program .addCommand(build) .addCommand(deploy) .addCommand(project) + .addCommand(auth) .addCommand(telemetry) .hook('preAction', telemetryPreHook) .hook('postAction', telemetryPostHook); diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index bf7da4855e..a7557d8f4d 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -74,6 +74,15 @@ export const handlers = [ }, ), + // Handler for fetchStoreProfile (auth whoami) + http.get('https://:apiHost/stores/:storeHash/v3/settings/store/profile', () => + HttpResponse.json({ + data: { + store_name: 'Test Store', + }, + }), + ), + // Handle for createProjects http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => HttpResponse.json({ From 30c57e5a6f840da6da75013fd17565d7b3d36e1e Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Mon, 16 Feb 2026 16:35:17 -0600 Subject: [PATCH 22/90] feat(cli): add crash reporting with trace ids (#2880) * feat(cli): add crash reporting with trace ids Centralized error handling via withErrorHandler HOF, singleton telemetry with UUID trace ids, X-Correlation-Id headers on all API calls, and global uncaught exception handlers. On any CLI error, a trace ID is displayed for support debugging. * refactor(cli): remove traceId() and trackError() abstractions * fix(cli): update deploy tests to match error handler string output The error handler extracts the message string from Error objects before passing to consola.error, so tests should expect strings, not Error objects. * refactor(cli): move error handling from per-command HOF to top-level try/catch Replace the withErrorHandler() HOF pattern with a single try/catch around program.parseAsync() in the entry point. This makes error handling automatic for all commands instead of requiring each command author to remember to wrap their action. - Add commandName property to Telemetry, set by preAction hook via Commander's actionCommand parameter - Delete error-handler.ts and its spec - Update tests to assert error propagation via rejects.toThrow() * fix(cli): address PR feedback on error handling - Remove redundant closeAndFlush() from try block (postAction hook already handles it) - Only show trace ID when telemetry is enabled; prompt disabled users to enable telemetry without showing an unlookable trace ID * fix(cli): wrap entry point in IIFE for module compatibility --- packages/catalyst/src/cli/commands/build.ts | 48 ++-- .../catalyst/src/cli/commands/deploy.spec.ts | 96 +++---- packages/catalyst/src/cli/commands/deploy.ts | 136 +++++---- packages/catalyst/src/cli/commands/dev.ts | 27 +- .../catalyst/src/cli/commands/project.spec.ts | 119 ++++---- packages/catalyst/src/cli/commands/project.ts | 261 ++++++++---------- packages/catalyst/src/cli/commands/start.ts | 61 ++-- .../catalyst/src/cli/commands/telemetry.ts | 4 +- packages/catalyst/src/cli/hooks/telemetry.ts | 30 +- packages/catalyst/src/cli/index.ts | 51 +++- packages/catalyst/src/cli/lib/project.ts | 4 + .../catalyst/src/cli/lib/telemetry.spec.ts | 59 ++++ packages/catalyst/src/cli/lib/telemetry.ts | 29 +- packages/catalyst/vitest.setup.ts | 31 ++- 14 files changed, 525 insertions(+), 431 deletions(-) create mode 100644 packages/catalyst/src/cli/lib/telemetry.spec.ts diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index a6c7597b6a..ba717a0c0d 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -97,39 +97,33 @@ export const build = new Command('build') ) .action(async (nextBuildOptions, options) => { const coreDir = process.cwd(); + const config = getProjectConfig(); + const framework = options.framework ?? config.get('framework'); - try { - const config = getProjectConfig(); - const framework = options.framework ?? config.get('framework'); + if (framework === 'nextjs') { + const nextBin = join('node_modules', '.bin', 'next'); - if (framework === 'nextjs') { - const nextBin = join('node_modules', '.bin', 'next'); - - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${coreDir}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['build', ...nextBuildOptions], { - stdio: 'inherit', - cwd: coreDir, - }); + if (!existsSync(nextBin)) { + throw new Error( + `Next.js is not installed in ${coreDir}. Are you in a valid Next.js project?`, + ); } - if (framework === 'catalyst') { - const projectUuid = options.projectUuid ?? config.get('projectUuid'); + await execa(nextBin, ['build', ...nextBuildOptions], { + stdio: 'inherit', + cwd: coreDir, + }); + } - 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 .', - ); - } + if (framework === 'catalyst') { + const projectUuid = options.projectUuid ?? config.get('projectUuid'); - await buildCatalystProject(projectUuid); + 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 .', + ); } - } catch (error) { - consola.error(error); - process.exit(1); + + await buildCatalystProject(projectUuid); } }); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index f110830ddf..5266bdc6ba 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -338,15 +338,9 @@ test('errors when store hash is missing and not in .bigcommerce/project.json', a vi.stubEnv('CATALYST_STORE_HASH', undefined); - await program.parseAsync(['node', 'catalyst', 'deploy']); - - expect(consola.error).toHaveBeenCalledWith( - expect.objectContaining({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Vitest matcher return type is any - message: expect.stringContaining('Store hash is required'), - }), + await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( + 'Store hash is required', ); - expect(exitMock).toHaveBeenCalledWith(1); vi.unstubAllEnvs(); }); @@ -360,15 +354,9 @@ test('errors when access token is missing and not in .bigcommerce/project.json', vi.stubEnv('CATALYST_ACCESS_TOKEN', undefined); - await program.parseAsync(['node', 'catalyst', 'deploy']); - - expect(consola.error).toHaveBeenCalledWith( - expect.objectContaining({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Vitest matcher return type is any - message: expect.stringContaining('Access token is required'), - }), + await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( + 'Access token is required', ); - expect(exitMock).toHaveBeenCalledWith(1); vi.unstubAllEnvs(); }); @@ -426,28 +414,24 @@ describe('--prebuilt flag', () => { process.chdir(resolvedDir); - await program.parseAsync([ - 'node', - 'catalyst', - 'deploy', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--api-host', - apiHost, - '--project-uuid', - projectUuid, - '--prebuilt', - ]); - - expect(consola.error).toHaveBeenCalledWith( - expect.objectContaining({ - message: - 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', - }), + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ]), + ).rejects.toThrow( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', ); - expect(exitMock).toHaveBeenCalledWith(1); process.chdir(tmpDir); await missingDistCleanup(); @@ -461,28 +445,24 @@ describe('--prebuilt flag', () => { process.chdir(resolvedDir); - await program.parseAsync([ - 'node', - 'catalyst', - 'deploy', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--api-host', - apiHost, - '--project-uuid', - projectUuid, - '--prebuilt', - ]); - - expect(consola.error).toHaveBeenCalledWith( - expect.objectContaining({ - message: - 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', - }), + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ]), + ).rejects.toThrow( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', ); - expect(exitMock).toHaveBeenCalledWith(1); process.chdir(tmpDir); await rm(join(resolvedDir, '.bigcommerce'), { recursive: true }); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 47c78c1e92..99094372a3 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -8,12 +8,10 @@ import { z } from 'zod'; import { getDeploymentErrorMessage } from '../lib/deployment-errors'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; -import { Telemetry } from '../lib/telemetry'; +import { getTelemetry } from '../lib/telemetry'; import { buildCatalystProject } from './build'; -const telemetry = new Telemetry(); - const stepsEnum = z.enum([ 'initializing', 'downloading', @@ -119,6 +117,7 @@ export const generateUploadSignature = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().sessionId, }, body: JSON.stringify({}), }, @@ -195,6 +194,7 @@ export const createDeployment = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().sessionId, }, body: JSON.stringify({ project_uuid: projectUuid, @@ -234,6 +234,7 @@ export const getDeploymentStatus = async ( 'X-Auth-Token': accessToken, Accept: 'text/event-stream', Connection: 'keep-alive', + 'X-Correlation-Id': getTelemetry().sessionId, }, }, ); @@ -317,6 +318,7 @@ export const fetchProject = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().sessionId, }, }, ); @@ -370,90 +372,82 @@ export const deploy = new Command('deploy') 'Skip the build step. Requires .bigcommerce/dist/ to already contain build output.', ) .action(async (options) => { - try { - const config = getProjectConfig(); - const storeHash = options.storeHash ?? config.get('storeHash'); - const accessToken = options.accessToken ?? config.get('accessToken'); - const projectUuid = options.projectUuid ?? config.get('projectUuid'); + const config = getProjectConfig(); + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + const telemetry = getTelemetry(); + const projectUuid = options.projectUuid ?? config.get('projectUuid'); + + 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 .', + ); + } - 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 .', - ); - } + if (!storeHash) { + throw new Error( + 'Store hash is required. Provide --store-hash (or set CATALYST_STORE_HASH), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + ); + } - if (!storeHash) { + if (!accessToken) { + throw new Error( + 'Access token is required. Provide --access-token (or set CATALYST_ACCESS_TOKEN), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + ); + } + + await telemetry.identify(storeHash); + + if (options.prebuilt) { + const distDir = join(process.cwd(), '.bigcommerce', 'dist'); + + try { + await access(distDir); + } catch { throw new Error( - 'Store hash is required. Provide --store-hash (or set CATALYST_STORE_HASH), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', ); } - if (!accessToken) { + const contents = await readdir(distDir); + + if (contents.length === 0) { throw new Error( - 'Access token is required. Provide --access-token (or set CATALYST_ACCESS_TOKEN), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', ); } - await telemetry.identify(storeHash); - - if (options.prebuilt) { - const distDir = join(process.cwd(), '.bigcommerce', 'dist'); - - try { - await access(distDir); - } catch { - throw new Error( - 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', - ); - } - - const contents = await readdir(distDir); - - if (contents.length === 0) { - throw new Error( - 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', - ); - } - - consola.info('Using existing build output (--prebuilt).'); - } else { - await buildCatalystProject(projectUuid); - } + consola.info('Using existing build output (--prebuilt).'); + } else { + await buildCatalystProject(projectUuid); + } - await generateBundleZip(); + await generateBundleZip(); - if (options.dryRun) { - consola.info('Dry run enabled — skipping upload and deployment steps.'); - consola.info('Next steps (skipped):'); - consola.info('- Generate upload signature'); - consola.info('- Upload bundle.zip'); - consola.info('- Create deployment'); + if (options.dryRun) { + consola.info('Dry run enabled — skipping upload and deployment steps.'); + consola.info('Next steps (skipped):'); + consola.info('- Generate upload signature'); + consola.info('- Upload bundle.zip'); + consola.info('- Create deployment'); - process.exit(0); - } + process.exit(0); + } - const uploadSignature = await generateUploadSignature( - storeHash, - accessToken, - options.apiHost, - ); + const uploadSignature = await generateUploadSignature(storeHash, accessToken, options.apiHost); - await uploadBundleZip(uploadSignature.upload_url); + await uploadBundleZip(uploadSignature.upload_url); - const environmentVariables = parseEnvironmentVariables(options.secret); + const environmentVariables = parseEnvironmentVariables(options.secret); - const { deployment_uuid: deploymentUuid } = await createDeployment( - projectUuid, - uploadSignature.upload_uuid, - storeHash, - accessToken, - options.apiHost, - environmentVariables, - ); + const { deployment_uuid: deploymentUuid } = await createDeployment( + projectUuid, + uploadSignature.upload_uuid, + storeHash, + accessToken, + options.apiHost, + environmentVariables, + ); - await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost); - } catch (error) { - consola.error(error); - process.exit(1); - } + await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost); }); diff --git a/packages/catalyst/src/cli/commands/dev.ts b/packages/catalyst/src/cli/commands/dev.ts index 2d9119a24c..4e135be0e7 100644 --- a/packages/catalyst/src/cli/commands/dev.ts +++ b/packages/catalyst/src/cli/commands/dev.ts @@ -3,8 +3,6 @@ import { execa } from 'execa'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { consola } from '../lib/logger'; - export const dev = new Command('dev') .description('Start the Catalyst development server.') // Proxy `--help` to the underlying `next dev` command @@ -16,21 +14,16 @@ export const dev = new Command('dev') 'Next.js `dev` options (see: https://nextjs.org/docs/app/api-reference/cli/next#next-dev-options)', ) .action(async (options) => { - try { - const nextBin = join('node_modules', '.bin', 'next'); - - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${process.cwd()}. Are you in a valid Next.js project?`, - ); - } + const nextBin = join('node_modules', '.bin', 'next'); - await execa(nextBin, ['dev', ...options], { - stdio: 'inherit', - cwd: process.cwd(), - }); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); - process.exit(1); + if (!existsSync(nextBin)) { + throw new Error( + `Next.js is not installed in ${process.cwd()}. Are you in a valid Next.js project?`, + ); } + + await execa(nextBin, ['dev', ...options], { + stdio: 'inherit', + cwd: process.cwd(), + }); }); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 084abe8333..a23117e726 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -31,17 +31,22 @@ beforeAll(async () => { consola.mockTypes(() => vi.fn()); vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + sessionId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + return { - Telemetry: vi.fn().mockImplementation(() => { - return { - identify: mockIdentify, - isEnabled: vi.fn(() => true), - track: vi.fn(), - analytics: { - closeAndFlush: vi.fn(), - }, - }; - }), + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), }; }); @@ -157,25 +162,22 @@ describe('project create', () => { const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('Duplicate'); - await program.parseAsync([ - 'node', - 'catalyst', - 'project', - 'create', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--root-dir', - tmpDir, - ]); + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]), + ).rejects.toThrow('Failed to create project, is the name already in use?'); promptMock.mockRestore(); - - expect(consola.error).toHaveBeenCalledWith( - 'Failed to create project, is the name already in use?', - ); - expect(exitMock).toHaveBeenCalledWith(1); }); }); @@ -411,30 +413,26 @@ describe('project link', () => { return new Promise((resolve) => resolve('New Project')); }); - await program.parseAsync([ - 'node', - 'catalyst', - 'project', - 'link', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--root-dir', - tmpDir, - ]); + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]), + ).rejects.toThrow('Failed to create project, is the name already in use?'); 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(); }); @@ -445,25 +443,26 @@ describe('project link', () => { ), ); - await program.parseAsync([ - 'node', - 'catalyst', - 'project', - 'link', - '--store-hash', - storeHash, - '--access-token', - accessToken, - '--root-dir', - tmpDir, - ]); + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]), + ).rejects.toThrow( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); 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 () => { diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 59692594d1..cc77569417 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -3,9 +3,7 @@ import { Command, Option } from 'commander'; import { consola } from '../lib/logger'; import { createProject, fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; -import { Telemetry } from '../lib/telemetry'; - -const telemetry = new Telemetry(); +import { getTelemetry } from '../lib/telemetry'; const list = new Command('list') .description('List BigCommerce infrastructure projects for your store.') @@ -27,45 +25,40 @@ const list = new Command('list') .default('api.bigcommerce.com'), ) .action(async (options) => { - try { - 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( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN), or run from a project that has been linked with credentials.', - ); - process.exit(1); + 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( + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN), or run from a project that has been linked with credentials.', + ); + process.exit(1); - return; - } + return; + } - await telemetry.identify(storeHash); + await getTelemetry().identify(storeHash); - consola.start('Fetching projects...'); + consola.start('Fetching projects...'); - const projects = await fetchProjects(storeHash, accessToken, options.apiHost); + const projects = await fetchProjects(storeHash, accessToken, options.apiHost); - consola.success('Projects fetched.'); + consola.success('Projects fetched.'); - if (projects.length === 0) { - consola.info('No projects found.'); - process.exit(0); + if (projects.length === 0) { + consola.info('No projects found.'); + process.exit(0); - return; - } + return; + } - projects.forEach((p) => { - consola.log(`${p.name} (${p.uuid})`); - }); + projects.forEach((p) => { + consola.log(`${p.name} (${p.uuid})`); + }); - process.exit(0); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); - process.exit(1); - } + process.exit(0); }); const create = new Command('create') @@ -95,46 +88,41 @@ const create = new Command('create') process.cwd(), ) .action(async (options) => { - try { - if (!options.storeHash || !options.accessToken) { - consola.error('Insufficient information to create a project.'); - consola.info( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', - ); - process.exit(1); + if (!options.storeHash || !options.accessToken) { + consola.error('Insufficient information to create a project.'); + consola.info( + 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', + ); + process.exit(1); - return; - } + return; + } - await telemetry.identify(options.storeHash); + await getTelemetry().identify(options.storeHash); - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); + 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, + options.storeHash, + options.accessToken, + options.apiHost, + ); - consola.success(`Project "${data.name}" created successfully.`); + consola.success(`Project "${data.name}" created successfully.`); - const config = getProjectConfig(options.rootDir); + 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', options.storeHash); - config.set('accessToken', options.accessToken); - consola.success('Project UUID written to .bigcommerce/project.json.'); + consola.start('Writing project UUID to .bigcommerce/project.json...'); + config.set('projectUuid', data.uuid); + config.set('framework', 'catalyst'); + config.set('storeHash', options.storeHash); + config.set('accessToken', options.accessToken); + consola.success('Project UUID written to .bigcommerce/project.json.'); - process.exit(0); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); - process.exit(1); - } + process.exit(0); }); export const link = new Command('link') @@ -168,99 +156,90 @@ export const link = new Command('link') process.cwd(), ) .action(async (options) => { - try { - const config = getProjectConfig(options.rootDir); + const config = getProjectConfig(options.rootDir); - const writeProjectConfig = ( - uuid: string, - credentials?: { storeHash: string; accessToken: string }, - ) => { - consola.start('Writing project UUID to .bigcommerce/project.json...'); - config.set('projectUuid', uuid); - config.set('framework', 'catalyst'); - - if (credentials) { - config.set('storeHash', credentials.storeHash); - config.set('accessToken', credentials.accessToken); - } - - consola.success('Project UUID written to .bigcommerce/project.json.'); - }; - - if (options.projectUuid) { - writeProjectConfig(options.projectUuid); + const writeProjectConfig = ( + uuid: string, + credentials?: { storeHash: string; accessToken: string }, + ) => { + consola.start('Writing project UUID to .bigcommerce/project.json...'); + config.set('projectUuid', uuid); + config.set('framework', 'catalyst'); - process.exit(0); + if (credentials) { + config.set('storeHash', credentials.storeHash); + config.set('accessToken', credentials.accessToken); } - if (options.storeHash && options.accessToken) { - await telemetry.identify(options.storeHash); + consola.success('Project UUID written to .bigcommerce/project.json.'); + }; - consola.start('Fetching projects...'); + if (options.projectUuid) { + writeProjectConfig(options.projectUuid); - const projects = await fetchProjects( - options.storeHash, - options.accessToken, - options.apiHost, - ); + process.exit(0); + } - consola.success('Projects fetched.'); - - const promptOptions = [ - ...projects.map((proj) => ({ - label: proj.name, - value: proj.uuid, - hint: proj.uuid, - })), - { - label: 'Create a new project', - value: 'create', - hint: 'Create a new infrastructure project for this BigCommerce store.', - }, - ]; - - let projectUuid = await consola.prompt( - 'Select a project or create a new project (Press to select).', - { - type: 'select', - options: promptOptions, - cancel: 'reject', - }, - ); + if (options.storeHash && options.accessToken) { + await getTelemetry().identify(options.storeHash); - if (projectUuid === 'create') { - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); + consola.start('Fetching projects...'); - const data = await createProject( - newProjectName, - options.storeHash, - options.accessToken, - options.apiHost, - ); + const projects = await fetchProjects(options.storeHash, options.accessToken, options.apiHost); - projectUuid = data.uuid; + consola.success('Projects fetched.'); - consola.success(`Project "${data.name}" created successfully.`); - } + const promptOptions = [ + ...projects.map((proj) => ({ + label: proj.name, + value: proj.uuid, + hint: proj.uuid, + })), + { + label: 'Create a new project', + value: 'create', + hint: 'Create a new infrastructure project for this BigCommerce store.', + }, + ]; + + let projectUuid = await consola.prompt( + 'Select a project or create a new project (Press to select).', + { + type: 'select', + options: promptOptions, + cancel: 'reject', + }, + ); - writeProjectConfig(projectUuid, { - storeHash: options.storeHash, - accessToken: options.accessToken, + if (projectUuid === 'create') { + const newProjectName = await consola.prompt('Enter a name for the new project:', { + type: 'text', }); - process.exit(0); + const data = await createProject( + newProjectName, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + projectUuid = data.uuid; + + consola.success(`Project "${data.name}" created successfully.`); } - 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.'); - process.exit(1); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); - process.exit(1); + writeProjectConfig(projectUuid, { + storeHash: options.storeHash, + accessToken: options.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.'); + process.exit(1); }); export const project = new Command('project') diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index 2a0c68cbe3..f72257ec0f 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -3,7 +3,6 @@ import { execa } from 'execa'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; export const start = new Command('start') @@ -21,41 +20,37 @@ export const start = new Command('start') ]), ) .action(async (startOptions, options) => { - try { - const config = getProjectConfig(); - const framework = options.framework ?? config.get('framework'); + const config = getProjectConfig(); + const framework = options.framework ?? config.get('framework'); - if (framework === 'nextjs') { - const nextBin = join('node_modules', '.bin', 'next'); + if (framework === 'nextjs') { + const nextBin = join('node_modules', '.bin', 'next'); - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${process.cwd()}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['start', ...startOptions], { - stdio: 'inherit', - cwd: process.cwd(), - }); + if (!existsSync(nextBin)) { + throw new Error( + `Next.js is not installed in ${process.cwd()}. Are you in a valid Next.js project?`, + ); } - await execa( - 'pnpm', - [ - 'exec', - 'opennextjs-cloudflare', - 'preview', - '--config', - join('.bigcommerce', 'wrangler.jsonc'), - ...startOptions, - ], - { - stdio: 'inherit', - cwd: process.cwd(), - }, - ); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); + await execa(nextBin, ['start', ...startOptions], { + stdio: 'inherit', + cwd: process.cwd(), + }); } + + await execa( + 'pnpm', + [ + 'exec', + 'opennextjs-cloudflare', + 'preview', + '--config', + join('.bigcommerce', 'wrangler.jsonc'), + ...startOptions, + ], + { + stdio: 'inherit', + cwd: process.cwd(), + }, + ); }); diff --git a/packages/catalyst/src/cli/commands/telemetry.ts b/packages/catalyst/src/cli/commands/telemetry.ts index a08958374e..253704d3e2 100644 --- a/packages/catalyst/src/cli/commands/telemetry.ts +++ b/packages/catalyst/src/cli/commands/telemetry.ts @@ -2,9 +2,9 @@ import { Argument, Command, Option } from 'commander'; import { colorize } from 'consola/utils'; import { consola } from '../lib/logger'; -import { Telemetry } from '../lib/telemetry'; +import { getTelemetry } from '../lib/telemetry'; -const telemetryService = new Telemetry(); +const telemetryService = getTelemetry(); let isEnabled = telemetryService.isEnabled(); export const telemetry = new Command('telemetry') diff --git a/packages/catalyst/src/cli/hooks/telemetry.ts b/packages/catalyst/src/cli/hooks/telemetry.ts index c9e3de7a80..045203d030 100644 --- a/packages/catalyst/src/cli/hooks/telemetry.ts +++ b/packages/catalyst/src/cli/hooks/telemetry.ts @@ -1,8 +1,6 @@ -import { Command } from '@commander-js/extra-typings'; +import { Command, type CommandUnknownOpts } from '@commander-js/extra-typings'; -import { Telemetry } from '../lib/telemetry'; - -const telemetry = new Telemetry(); +import { getTelemetry } from '../lib/telemetry'; const allowlistArguments = ['--keep-temp-dir', '--api-host', '--project-uuid']; @@ -35,16 +33,34 @@ function parseArguments(args: string[]) { }, {}); } -export const telemetryPreHook = async (command: Command) => { - const [commandName, ...args] = command.args; +function getCommandPath(cmd: CommandUnknownOpts): string { + const parts: string[] = []; + let current: CommandUnknownOpts | null = cmd; + + while (current.parent) { + parts.unshift(current.name()); + current = current.parent; + } + + return parts.join(' '); +} + +export const telemetryPreHook = async (thisCommand: Command, actionCommand: CommandUnknownOpts) => { + const telemetry = getTelemetry(); + const commandName = getCommandPath(actionCommand); + + telemetry.commandName = commandName; // Return the await to get a proper stack trace. // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression return await telemetry.track(commandName, { - ...parseArguments(args), + ...parseArguments(thisCommand.args), }); }; export const telemetryPostHook = async () => { + const telemetry = getTelemetry(); + + await telemetry.track('command_completed', { durationMs: telemetry.durationMs() }); await telemetry.analytics.closeAndFlush(); }; diff --git a/packages/catalyst/src/cli/index.ts b/packages/catalyst/src/cli/index.ts index 42c5a8a765..bcbcfad8cf 100644 --- a/packages/catalyst/src/cli/index.ts +++ b/packages/catalyst/src/cli/index.ts @@ -1,4 +1,53 @@ #!/usr/bin/env node +import { consola } from './lib/logger'; +import { getTelemetry } from './lib/telemetry'; import { program } from './program'; -program.parse(process.argv); +const handleFatalError = async (error: unknown) => { + const telemetry = getTelemetry(); + + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + + try { + await telemetry.track('error', { + commandName: telemetry.commandName, + errorMessage, + errorStack, + durationMs: telemetry.durationMs(), + }); + await telemetry.analytics.closeAndFlush(); + } catch { + // Don't mask the original error + } + + consola.error(errorMessage); + + if (telemetry.isEnabled()) { + consola.info( + `\nTrace ID: ${telemetry.sessionId}\nShare this Trace ID with BigCommerce support.`, + ); + } else { + consola.info( + '\nEnable telemetry (`catalyst telemetry enable`) for improved troubleshooting with BigCommerce support.', + ); + } + + process.exit(1); +}; + +process.on('uncaughtException', (error) => { + void handleFatalError(error); +}); + +process.on('unhandledRejection', (reason) => { + void handleFatalError(reason); +}); + +void (async () => { + try { + await program.parseAsync(process.argv); + } catch (error) { + await handleFatalError(error); + } +})(); diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index ffdb319902..45d0a2d3a1 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { getTelemetry } from './telemetry'; + const fetchProjectsSchema = z.object({ data: z.array( z.object({ @@ -25,6 +27,7 @@ export async function fetchProjects( method: 'GET', headers: { 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().sessionId, }, }, ); @@ -76,6 +79,7 @@ export async function createProject( 'X-Auth-Token': accessToken, Accept: 'application/json', 'Content-Type': 'application/json', + 'X-Correlation-Id': getTelemetry().sessionId, }, body: JSON.stringify({ name }), }, diff --git a/packages/catalyst/src/cli/lib/telemetry.spec.ts b/packages/catalyst/src/cli/lib/telemetry.spec.ts new file mode 100644 index 0000000000..358dc195f5 --- /dev/null +++ b/packages/catalyst/src/cli/lib/telemetry.spec.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.unmock('./telemetry'); + +// eslint-disable-next-line import/dynamic-import-chunkname +const { Telemetry, getTelemetry, resetTelemetry } = await import('./telemetry'); + +beforeEach(() => { + resetTelemetry(); +}); + +afterEach(() => { + resetTelemetry(); +}); + +describe('Telemetry', () => { + test('sessionId is a valid UUID', () => { + const telemetry = new Telemetry(); + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + expect(telemetry.sessionId).toMatch(uuidRegex); + }); + + test('commandName defaults to unknown', () => { + const telemetry = new Telemetry(); + + expect(telemetry.commandName).toBe('unknown'); + }); + + test('durationMs returns elapsed time', async () => { + const telemetry = new Telemetry(); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(telemetry.durationMs()).toBeGreaterThanOrEqual(40); + }); +}); + +describe('getTelemetry', () => { + test('returns same instance on repeated calls', () => { + const first = getTelemetry(); + const second = getTelemetry(); + + expect(first).toBe(second); + }); + + test('resetTelemetry causes fresh instance', () => { + const first = getTelemetry(); + + resetTelemetry(); + + const second = getTelemetry(); + + expect(first).not.toBe(second); + expect(first.sessionId).not.toBe(second.sessionId); + }); +}); diff --git a/packages/catalyst/src/cli/lib/telemetry.ts b/packages/catalyst/src/cli/lib/telemetry.ts index b6480edd98..d4b7d3658f 100644 --- a/packages/catalyst/src/cli/lib/telemetry.ts +++ b/packages/catalyst/src/cli/lib/telemetry.ts @@ -1,6 +1,6 @@ import { Analytics } from '@segment/analytics-node'; import Conf from 'conf'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import PACKAGE_INFO from '../../../package.json'; @@ -12,6 +12,8 @@ const TELEMETRY_KEY_ID = `telemetry.anonymousId`; export class Telemetry { readonly sessionId: string; readonly analytics: Analytics; + readonly startTime: number; + commandName = 'unknown'; private projectConfig: Conf; private CATALYST_TELEMETRY_DISABLED: string | undefined; @@ -24,12 +26,17 @@ export class Telemetry { this.projectConfig = getProjectConfig(); - this.sessionId = randomBytes(32).toString('hex'); + this.sessionId = randomUUID(); + this.startTime = Date.now(); this.analytics = new Analytics({ writeKey: process.env.CLI_SEGMENT_WRITE_KEY ?? 'not-a-valid-segment-write-key', }); } + durationMs(): number { + return Date.now() - this.startTime; + } + async track(eventName: string, payload: Record) { if (!this.isEnabled()) { return Promise.resolve(undefined); @@ -41,6 +48,10 @@ export class Telemetry { properties: { ...payload, sessionId: this.sessionId, + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + cliVersion: PACKAGE_INFO.version, }, context: { app: { @@ -99,3 +110,17 @@ export class Telemetry { return generated; } } + +let telemetryInstance: Telemetry | undefined; + +// Singleton so the pre-hook, post-hook, error handler, and command bodies all +// share one sessionId for correlation. resetTelemetry() is for test isolation. +export function getTelemetry(): Telemetry { + telemetryInstance ??= new Telemetry(); + + return telemetryInstance; +} + +export function resetTelemetry(): void { + telemetryInstance = undefined; +} diff --git a/packages/catalyst/vitest.setup.ts b/packages/catalyst/vitest.setup.ts index 26c64cc33c..26e1439cc5 100644 --- a/packages/catalyst/vitest.setup.ts +++ b/packages/catalyst/vitest.setup.ts @@ -2,19 +2,26 @@ import { afterAll, afterEach, beforeAll, vi } from 'vitest'; import { server } from './tests/mocks/node'; +const mockTelemetryInstance = { + sessionId: 'test-session-uuid', + commandName: 'unknown', + analytics: { + track: vi.fn(), + identify: vi.fn(), + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + track: vi.fn().mockResolvedValue(undefined), + identify: vi.fn().mockResolvedValue(undefined), + startTime: 0, + durationMs: vi.fn().mockReturnValue(0), + setEnabled: vi.fn(), + isEnabled: vi.fn().mockReturnValue(false), +}; + vi.mock('../src/lib/telemetry', () => ({ - Telemetry: vi.fn().mockImplementation(() => ({ - sessionId: 'test-session-id', - analytics: { - track: vi.fn(), - identify: vi.fn(), - closeAndFlush: vi.fn().mockResolvedValue(undefined), - }, - track: vi.fn().mockResolvedValue(undefined), - identify: vi.fn().mockResolvedValue(undefined), - setEnabled: vi.fn(), - isEnabled: vi.fn().mockReturnValue(false), - })), + Telemetry: vi.fn().mockImplementation(() => mockTelemetryInstance), + getTelemetry: vi.fn(() => mockTelemetryInstance), + resetTelemetry: vi.fn(), })); beforeAll(() => server.listen()); From b4ee0577863e4da8121522ddb1218cb6a450a9e4 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Tue, 17 Feb 2026 16:10:46 -0600 Subject: [PATCH 23/90] feat(cli): add auth login and auth logout commands (#2887) OAuth device code flow for browser-based authentication. Stores credentials in .bigcommerce/project.json. --- packages/catalyst/package.json | 1 + .../catalyst/src/cli/commands/auth.spec.ts | 208 ++++++++++++++++++ packages/catalyst/src/cli/commands/auth.ts | 117 +++++++++- packages/catalyst/src/cli/index.spec.ts | 7 + packages/catalyst/src/cli/lib/auth.spec.ts | 140 ++++++++++++ packages/catalyst/src/cli/lib/auth.ts | 81 +++++++ packages/catalyst/tests/mocks/handlers.ts | 25 +++ pnpm-lock.yaml | 6 +- 8 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 packages/catalyst/src/cli/commands/auth.spec.ts create mode 100644 packages/catalyst/src/cli/lib/auth.spec.ts create mode 100644 packages/catalyst/src/cli/lib/auth.ts diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index a835d4ac3a..eda6b1d7b2 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -29,6 +29,7 @@ "consola": "^3.4.2", "dotenv": "^16.5.0", "execa": "^9.6.0", + "open": "^10.1.0", "yocto-spinner": "^1.0.0", "zod": "^4.0.5" }, diff --git a/packages/catalyst/src/cli/commands/auth.spec.ts b/packages/catalyst/src/cli/commands/auth.spec.ts new file mode 100644 index 0000000000..5e75d8b409 --- /dev/null +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -0,0 +1,208 @@ +import { Command } from 'commander'; +import { http, HttpResponse } from 'msw'; +import { realpath } from 'node:fs/promises'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + MockInstance, + test, + vi, +} from 'vitest'; + +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 { getProjectConfig } from '../lib/project-config'; +import { program } from '../program'; + +import { auth } from './auth'; + +// eslint-disable-next-line import/dynamic-import-chunkname +vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; + +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); +}); + +beforeEach(() => { + process.chdir(tmpDir); +}); + +afterEach(() => { + vi.clearAllMocks(); + textHistory.length = 0; + + // Clean up config between tests + try { + const config = getProjectConfig(); + + config.delete('storeHash'); + config.delete('accessToken'); + } catch { + // ignore if config doesn't exist + } +}); + +afterAll(async () => { + await cleanup(); +}); + +test('auth is a properly configured Command instance', () => { + expect(auth).toBeInstanceOf(Command); + expect(auth.name()).toBe('auth'); + expect(auth.description()).toBe('Manage authentication for the BigCommerce CLI.'); + + const subcommands = auth.commands.map((cmd) => cmd.name()); + + expect(subcommands).toContain('whoami'); + expect(subcommands).toContain('login'); + expect(subcommands).toContain('logout'); +}); + +describe('whoami', () => { + test('displays store info when credentials are valid', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'test-store'); + config.set('accessToken', 'test-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); + + expect(consola.info).toHaveBeenCalledWith('Logged in to Test Store (test-store)'); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('reports no credentials found', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); + + expect(consola.info).toHaveBeenCalledWith('Not logged in: no credentials found.'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('reports invalid credentials on 401', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'test-store'); + config.set('accessToken', 'bad-token'); + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/settings/store/profile', + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + + await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('Not logged in: invalid credentials'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('login', () => { + test('completes OAuth device flow and stores credentials', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('MOCK-CODE')); + expect(consola.success).toHaveBeenCalledWith('Logged in to store mock-store-hash.'); + expect(exitMock).toHaveBeenCalledWith(0); + + // Verify credentials were stored + const config = getProjectConfig(); + + expect(config.get('storeHash')).toBe('mock-store-hash'); + expect(config.get('accessToken')).toBe('mock-access-token'); + }); + + test('exits early when already logged in', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'existing-store'); + config.set('accessToken', 'existing-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.info).toHaveBeenCalledWith('Already logged in to store existing-store.'); + expect(consola.info).toHaveBeenCalledWith( + 'Run `catalyst auth logout` first to re-authenticate.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('handles API error during device code request', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 500, statusText: 'Internal Server Error' }), + ), + ); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Login failed')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('handles browser open failure gracefully', async () => { + // eslint-disable-next-line import/dynamic-import-chunkname + const openMock = await import('open'); + + vi.mocked(openMock.default).mockRejectedValueOnce(new Error('No browser')); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('Open https://login.bigcommerce.com/device in your browser'), + ); + expect(consola.success).toHaveBeenCalledWith('Logged in to store mock-store-hash.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('shows spinner during authentication polling', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(textHistory).toContain('Waiting for authentication...'); + expect(textHistory).toContain('Authentication complete.'); + }); +}); + +describe('logout', () => { + test('clears stored credentials', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'test-store'); + config.set('accessToken', 'test-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'logout']); + + expect(consola.success).toHaveBeenCalledWith('Logged out from store test-store.'); + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('storeHash')).toBeUndefined(); + expect(config.get('accessToken')).toBeUndefined(); + }); + + test('reports not logged in when no credentials exist', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'logout']); + + expect(consola.info).toHaveBeenCalledWith('Not logged in: no credentials found.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts index 38c94989c9..15582c42b8 100644 --- a/packages/catalyst/src/cli/commands/auth.ts +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -1,6 +1,10 @@ import { Command, Option } from 'commander'; +import { colorize } from 'consola/utils'; +import open from 'open'; +import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { DEFAULT_LOGIN_URL, requestDeviceCode, waitForDeviceToken } from '../lib/auth'; import { consola } from '../lib/logger'; import { fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; @@ -25,9 +29,13 @@ async function fetchStoreProfile(storeHash: string, accessToken: string, apiHost } const res: unknown = await response.json(); - const { data } = StoreProfileSchema.parse(res); + const result = StoreProfileSchema.safeParse(res); - return data; + if (!result.success) { + throw new Error('Unexpected response from store profile API'); + } + + return result.data.data; } const whoami = new Command('whoami') @@ -101,6 +109,109 @@ const whoami = new Command('whoami') } }); +const login = new Command('login') + .description('Authenticate via browser using the OAuth device code flow.') + .addOption( + new Option( + '--store-hash ', + 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', + ).env('CATALYST_STORE_HASH'), + ) + .addOption( + new Option( + '--access-token ', + 'BigCommerce access token. Can be found after creating a store-level API account.', + ).env('CATALYST_ACCESS_TOKEN'), + ) + .addOption( + new Option('--login-url ', 'BigCommerce login URL.') + .env('BIGCOMMERCE_LOGIN_URL') + .default(DEFAULT_LOGIN_URL), + ) + .action(async (options) => { + try { + const config = getProjectConfig(); + + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (storeHash && accessToken) { + consola.info(`Already logged in to store ${storeHash}.`); + consola.info('Run `catalyst auth logout` first to re-authenticate.'); + process.exit(0); + + return; + } + + const deviceCode = await requestDeviceCode(options.loginUrl); + + consola.info( + `${colorize('yellow', 'Your one-time code:')} ${colorize('bold', deviceCode.user_code)}`, + ); + + try { + await open(deviceCode.verification_uri); + consola.info(`Opened ${deviceCode.verification_uri} in your browser.`); + } catch { + consola.info( + `Open ${deviceCode.verification_uri} in your browser and enter the code above.`, + ); + } + + const spinner = yoctoSpinner().start('Waiting for authentication...'); + + const credentials = await waitForDeviceToken( + options.loginUrl, + deviceCode.device_code, + deviceCode.interval, + ); + + spinner.success('Authentication complete.'); + + config.set('storeHash', credentials.store_hash); + config.set('accessToken', credentials.access_token); + + consola.success(`Logged in to store ${credentials.store_hash}.`); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + consola.error(`Login failed: ${message}`); + process.exit(1); + } + }); + +const logout = new Command('logout') + .description('Remove stored credentials for the current project.') + .action(() => { + try { + const config = getProjectConfig(); + + const storeHash = config.get('storeHash'); + const accessToken = config.get('accessToken'); + + if (!storeHash && !accessToken) { + consola.info('Not logged in: no credentials found.'); + process.exit(0); + + return; + } + + config.delete('storeHash'); + config.delete('accessToken'); + + consola.success(`Logged out from store ${storeHash ?? 'unknown'}.`); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + consola.error(`Logout failed: ${message}`); + process.exit(1); + } + }); + export const auth = new Command('auth') .description('Manage authentication for the BigCommerce CLI.') - .addCommand(whoami); + .addCommand(whoami) + .addCommand(login) + .addCommand(logout); diff --git a/packages/catalyst/src/cli/index.spec.ts b/packages/catalyst/src/cli/index.spec.ts index 76f750de9b..f8dad3c35f 100644 --- a/packages/catalyst/src/cli/index.spec.ts +++ b/packages/catalyst/src/cli/index.spec.ts @@ -26,12 +26,19 @@ describe('CLI program', () => { expect(commands).toContain('build'); expect(commands).toContain('deploy'); expect(commands).toContain('project'); + expect(commands).toContain('auth'); const projectCmd = program.commands.find((cmd) => cmd.name() === 'project'); expect(projectCmd?.commands.map((c) => c.name())).toEqual( expect.arrayContaining(['create', 'list', 'link']), ); + + const authCmd = program.commands.find((cmd) => cmd.name() === 'auth'); + + expect(authCmd?.commands.map((c) => c.name())).toEqual( + expect.arrayContaining(['whoami', 'login', 'logout']), + ); }); test('telemetry hooks are called when executing version command', async () => { diff --git a/packages/catalyst/src/cli/lib/auth.spec.ts b/packages/catalyst/src/cli/lib/auth.spec.ts new file mode 100644 index 0000000000..d086ccf920 --- /dev/null +++ b/packages/catalyst/src/cli/lib/auth.spec.ts @@ -0,0 +1,140 @@ +import { http, HttpResponse } from 'msw'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { + DEFAULT_LOGIN_URL, + DEVICE_OAUTH_CLIENT_ID, + DEVICE_OAUTH_SCOPES, + pollDeviceToken, + requestDeviceCode, + waitForDeviceToken, +} from './auth'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('requestDeviceCode', () => { + test('returns device code response on success', async () => { + const result = await requestDeviceCode(DEFAULT_LOGIN_URL); + + expect(result).toEqual({ + device_code: 'mock-device-code', + user_code: 'MOCK-CODE', + verification_uri: 'https://login.bigcommerce.com/device', + expires_in: 600, + interval: 5, + }); + }); + + test('throws on non-OK response', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 500, statusText: 'Internal Server Error' }), + ), + ); + + await expect(requestDeviceCode(DEFAULT_LOGIN_URL)).rejects.toThrow( + 'Failed to request device code: 500 Internal Server Error', + ); + }); + + test('throws on malformed response', async () => { + server.use( + http.post('https://login.bigcommerce.com/device/token', () => + HttpResponse.json({ invalid: 'data' }), + ), + ); + + await expect(requestDeviceCode(DEFAULT_LOGIN_URL)).rejects.toThrow(); + }); +}); + +describe('pollDeviceToken', () => { + test('returns null on non-200 response', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 400 }), + ), + ); + + const result = await pollDeviceToken(DEFAULT_LOGIN_URL, 'mock-device-code'); + + expect(result).toBeNull(); + }); + + test('returns credentials on 200 response', async () => { + const result = await pollDeviceToken(DEFAULT_LOGIN_URL, 'mock-device-code'); + + expect(result).toEqual({ + access_token: 'mock-access-token', + store_hash: 'mock-store-hash', + context: 'stores/mock-store-hash', + api_uri: 'https://api.bigcommerce.com', + }); + }); +}); + +describe('waitForDeviceToken', () => { + test('polls until success', async () => { + vi.useFakeTimers(); + + let callCount = 0; + + server.use( + http.post('https://login.bigcommerce.com/device/token', () => { + callCount += 1; + + if (callCount < 3) { + return new HttpResponse(null, { status: 400 }); + } + + return HttpResponse.json({ + access_token: 'mock-access-token', + store_hash: 'mock-store-hash', + context: 'stores/mock-store-hash', + api_uri: 'https://api.bigcommerce.com', + }); + }), + ); + + const promise = waitForDeviceToken(DEFAULT_LOGIN_URL, 'mock-device-code', 1); + + // Advance timers to allow polling + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + const result = await promise; + + expect(result).toEqual({ + access_token: 'mock-access-token', + store_hash: 'mock-store-hash', + context: 'stores/mock-store-hash', + api_uri: 'https://api.bigcommerce.com', + }); + + vi.useRealTimers(); + }); +}); + +describe('constants', () => { + test('DEVICE_OAUTH_CLIENT_ID matches create-catalyst', () => { + expect(DEVICE_OAUTH_CLIENT_ID).toBe('s1q4io7mah2lm1i6uwp9yl1eit80n3b'); + }); + + test('DEVICE_OAUTH_SCOPES contains expected scopes', () => { + expect(DEVICE_OAUTH_SCOPES).toContain('store_v2_information'); + expect(DEVICE_OAUTH_SCOPES).toContain('store_infrastructure_deployments_manage'); + expect(DEVICE_OAUTH_SCOPES).toContain('store_infrastructure_logs_read_only'); + expect(DEVICE_OAUTH_SCOPES).toContain('store_infrastructure_projects_manage'); + }); + + test('DEFAULT_LOGIN_URL is correct', () => { + expect(DEFAULT_LOGIN_URL).toBe('https://login.bigcommerce.com'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/auth.ts b/packages/catalyst/src/cli/lib/auth.ts new file mode 100644 index 0000000000..a298c751b1 --- /dev/null +++ b/packages/catalyst/src/cli/lib/auth.ts @@ -0,0 +1,81 @@ +import { z } from 'zod'; + +export const DEVICE_OAUTH_CLIENT_ID = 's1q4io7mah2lm1i6uwp9yl1eit80n3b'; +export const DEVICE_OAUTH_SCOPES = [ + 'store_v2_information', + 'store_infrastructure_deployments_manage', + 'store_infrastructure_logs_read_only', + 'store_infrastructure_projects_manage', +].join(' '); +export const DEFAULT_LOGIN_URL = 'https://login.bigcommerce.com'; + +export const DeviceCodeResponseSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string(), + expires_in: z.number(), + interval: z.number(), +}); + +export const DeviceCodeSuccessSchema = z.object({ + access_token: z.string(), + store_hash: z.string(), + context: z.string(), + api_uri: z.string(), +}); + +export async function requestDeviceCode(loginUrl: string) { + const response = await fetch(`${loginUrl}/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: DEVICE_OAUTH_CLIENT_ID, + scopes: DEVICE_OAUTH_SCOPES, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to request device code: ${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + + return DeviceCodeResponseSchema.parse(res); +} + +export async function pollDeviceToken(loginUrl: string, deviceCode: string) { + const response = await fetch(`${loginUrl}/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_code: deviceCode, + client_id: DEVICE_OAUTH_CLIENT_ID, + }), + }); + + if (response.status !== 200) { + return null; + } + + const res: unknown = await response.json(); + + return DeviceCodeSuccessSchema.parse(res); +} + +export async function waitForDeviceToken( + loginUrl: string, + deviceCode: string, + interval: number, +): Promise> { + const credentials = await pollDeviceToken(loginUrl, deviceCode); + + if (credentials) { + return credentials; + } + + await new Promise((resolve) => { + setTimeout(resolve, interval * 1000); + }); + + return waitForDeviceToken(loginUrl, deviceCode, interval); +} diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index a7557d8f4d..a63e8113e6 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -83,6 +83,31 @@ export const handlers = [ }), ), + // Handler for device code OAuth flow (auth login) + http.post('https://login.bigcommerce.com/device/token', async ({ request }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const body = (await request.json()) as Record; + + // Poll request (has device_code) — return credentials + if (body.device_code) { + return HttpResponse.json({ + access_token: 'mock-access-token', + store_hash: 'mock-store-hash', + context: 'stores/mock-store-hash', + api_uri: 'https://api.bigcommerce.com', + }); + } + + // Initial request (has scopes) — return device code + return HttpResponse.json({ + device_code: 'mock-device-code', + user_code: 'MOCK-CODE', + verification_uri: 'https://login.bigcommerce.com/device', + expires_in: 600, + interval: 5, + }); + }), + // Handle for createProjects http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => HttpResponse.json({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dcb7d3b37..e31f953226 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,6 +330,9 @@ importers: execa: specifier: ^9.6.0 version: 9.6.0 + open: + specifier: ^10.1.0 + version: 10.1.2 yocto-spinner: specifier: ^1.0.0 version: 1.0.0 @@ -5305,7 +5308,6 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} @@ -9527,7 +9529,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} From 9d9b7d6ad49fb0436bda780bbe14f489b8875fdc Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Tue, 17 Feb 2026 16:16:46 -0600 Subject: [PATCH 24/90] feat(cli): centralize credential resolution with resolveCredentials utility (#2888) Add resolveCredentials() that resolves storeHash/accessToken from flags, env vars, or stored config, with unified error message mentioning `catalyst auth login`. Apply to project create/list/link and deploy. --- .../catalyst/src/cli/commands/auth.spec.ts | 3 + packages/catalyst/src/cli/commands/auth.ts | 2 +- .../catalyst/src/cli/commands/deploy.spec.ts | 10 +- packages/catalyst/src/cli/commands/deploy.ts | 16 +- .../catalyst/src/cli/commands/project.spec.ts | 28 ++-- packages/catalyst/src/cli/commands/project.ts | 139 +++++++----------- .../src/cli/lib/resolve-credentials.spec.ts | 80 ++++++++++ .../src/cli/lib/resolve-credentials.ts | 25 ++++ 8 files changed, 189 insertions(+), 114 deletions(-) create mode 100644 packages/catalyst/src/cli/lib/resolve-credentials.spec.ts create mode 100644 packages/catalyst/src/cli/lib/resolve-credentials.ts diff --git a/packages/catalyst/src/cli/commands/auth.spec.ts b/packages/catalyst/src/cli/commands/auth.spec.ts index 5e75d8b409..7a9b40126a 100644 --- a/packages/catalyst/src/cli/commands/auth.spec.ts +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -91,6 +91,9 @@ describe('whoami', () => { await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); expect(consola.info).toHaveBeenCalledWith('Not logged in: no credentials found.'); + expect(consola.info).toHaveBeenCalledWith( + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', + ); expect(exitMock).toHaveBeenCalledWith(1); }); diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts index 15582c42b8..941826fac7 100644 --- a/packages/catalyst/src/cli/commands/auth.ts +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -67,7 +67,7 @@ const whoami = new Command('whoami') if (!storeHash || !accessToken) { consola.info('Not logged in: no credentials found.'); consola.info( - 'Provide --store-hash and --access-token flags, set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables, or run `catalyst project create` / `catalyst project link` with credentials.', + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', ); process.exit(1); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 5266bdc6ba..36f4de2bbd 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -339,9 +339,12 @@ test('errors when store hash is missing and not in .bigcommerce/project.json', a vi.stubEnv('CATALYST_STORE_HASH', undefined); await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( - 'Store hash is required', + 'Missing credentials', ); + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + vi.unstubAllEnvs(); }); @@ -355,9 +358,12 @@ test('errors when access token is missing and not in .bigcommerce/project.json', vi.stubEnv('CATALYST_ACCESS_TOKEN', undefined); await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( - 'Access token is required', + 'Missing credentials', ); + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + vi.unstubAllEnvs(); }); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 99094372a3..8341e04ecd 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import { getDeploymentErrorMessage } from '../lib/deployment-errors'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; import { getTelemetry } from '../lib/telemetry'; import { buildCatalystProject } from './build'; @@ -373,8 +374,7 @@ export const deploy = new Command('deploy') ) .action(async (options) => { const config = getProjectConfig(); - const storeHash = options.storeHash ?? config.get('storeHash'); - const accessToken = options.accessToken ?? config.get('accessToken'); + const { storeHash, accessToken } = resolveCredentials(options, config); const telemetry = getTelemetry(); const projectUuid = options.projectUuid ?? config.get('projectUuid'); @@ -384,18 +384,6 @@ export const deploy = new Command('deploy') ); } - if (!storeHash) { - throw new Error( - 'Store hash is required. Provide --store-hash (or set CATALYST_STORE_HASH), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', - ); - } - - if (!accessToken) { - throw new Error( - 'Access token is required. Provide --access-token (or set CATALYST_ACCESS_TOKEN), or run `catalyst project create` or `catalyst project link` with credentials to save it to .bigcommerce/project.json.', - ); - } - await telemetry.identify(storeHash); if (options.prebuilt) { diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index a23117e726..315e42b8f0 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -60,6 +60,9 @@ beforeAll(async () => { afterEach(() => { vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); }); afterAll(async () => { @@ -141,14 +144,16 @@ describe('project create', () => { delete process.env.CATALYST_STORE_HASH; delete process.env.CATALYST_ACCESS_TOKEN; - await program.parseAsync(['node', 'catalyst', 'project', 'create', '--root-dir', tmpDir]); + await expect( + program.parseAsync(['node', 'catalyst', 'project', 'create', '--root-dir', tmpDir]), + ).rejects.toThrow('Missing credentials'); 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.error).toHaveBeenCalledWith('Missing credentials.'); expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', ); expect(exitMock).toHaveBeenCalledWith(1); }); @@ -209,14 +214,16 @@ describe('project list', () => { delete process.env.CATALYST_STORE_HASH; delete process.env.CATALYST_ACCESS_TOKEN; - await program.parseAsync(['node', 'catalyst', 'project', 'list']); + await expect(program.parseAsync(['node', 'catalyst', 'project', 'list'])).rejects.toThrow( + 'Missing credentials', + ); 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.error).toHaveBeenCalledWith('Missing credentials.'); expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN), or run from a project that has been linked with credentials.', + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', ); expect(exitMock).toHaveBeenCalledWith(1); }); @@ -466,14 +473,15 @@ describe('project link', () => { }); test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { - await program.parseAsync(['node', 'catalyst', 'project', 'link', '--root-dir', tmpDir]); + await expect( + program.parseAsync(['node', 'catalyst', 'project', 'link', '--root-dir', tmpDir]), + ).rejects.toThrow('Missing credentials'); 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.error).toHaveBeenCalledWith('Missing credentials.'); expect(consola.info).toHaveBeenCalledWith( - 'Provide both --store-hash and --access-token to fetch and select a project.', + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', ); expect(exitMock).toHaveBeenCalledWith(1); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index cc77569417..7b9edfcd20 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -3,6 +3,7 @@ import { Command, Option } from 'commander'; import { consola } from '../lib/logger'; import { createProject, fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; import { getTelemetry } from '../lib/telemetry'; const list = new Command('list') @@ -26,18 +27,7 @@ const list = new Command('list') ) .action(async (options) => { 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( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN), or run from a project that has been linked with credentials.', - ); - process.exit(1); - - return; - } + const { storeHash, accessToken } = resolveCredentials(options, config); await getTelemetry().identify(storeHash); @@ -88,38 +78,24 @@ const create = new Command('create') process.cwd(), ) .action(async (options) => { - if (!options.storeHash || !options.accessToken) { - consola.error('Insufficient information to create a project.'); - consola.info( - 'Provide both --store-hash and --access-token (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN).', - ); - process.exit(1); - - return; - } + const config = getProjectConfig(options.rootDir); + const { storeHash, accessToken } = resolveCredentials(options, config); - await getTelemetry().identify(options.storeHash); + await getTelemetry().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', options.storeHash); - config.set('accessToken', options.accessToken); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); consola.success('Project UUID written to .bigcommerce/project.json.'); process.exit(0); @@ -178,68 +154,57 @@ export const link = new Command('link') writeProjectConfig(options.projectUuid); process.exit(0); + + return; } - if (options.storeHash && options.accessToken) { - await getTelemetry().identify(options.storeHash); - - consola.start('Fetching projects...'); - - const projects = await fetchProjects(options.storeHash, options.accessToken, options.apiHost); - - consola.success('Projects fetched.'); - - const promptOptions = [ - ...projects.map((proj) => ({ - label: proj.name, - value: proj.uuid, - hint: proj.uuid, - })), - { - label: 'Create a new project', - value: 'create', - hint: 'Create a new infrastructure project for this BigCommerce store.', - }, - ]; - - let projectUuid = await consola.prompt( - 'Select a project or create a new project (Press to select).', - { - type: 'select', - options: promptOptions, - cancel: 'reject', - }, - ); - - if (projectUuid === 'create') { - 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, - ); - - projectUuid = data.uuid; - - consola.success(`Project "${data.name}" created successfully.`); - } + const { storeHash, accessToken } = resolveCredentials(options, config); - writeProjectConfig(projectUuid, { - storeHash: options.storeHash, - accessToken: options.accessToken, + await getTelemetry().identify(storeHash); + + consola.start('Fetching projects...'); + + const projects = await fetchProjects(storeHash, accessToken, options.apiHost); + + consola.success('Projects fetched.'); + + const promptOptions = [ + ...projects.map((proj) => ({ + label: proj.name, + value: proj.uuid, + hint: proj.uuid, + })), + { + label: 'Create a new project', + value: 'create', + hint: 'Create a new infrastructure project for this BigCommerce store.', + }, + ]; + + let projectUuid = await consola.prompt( + 'Select a project or create a new project (Press to select).', + { + type: 'select', + options: promptOptions, + cancel: 'reject', + }, + ); + + if (projectUuid === 'create') { + const newProjectName = await consola.prompt('Enter a name for the new project:', { + type: 'text', }); - process.exit(0); + const data = await createProject(newProjectName, storeHash, accessToken, options.apiHost); + + projectUuid = data.uuid; + + consola.success(`Project "${data.name}" created successfully.`); } - 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.'); - process.exit(1); + writeProjectConfig(projectUuid, { storeHash, accessToken }); + + process.exit(0); }); export const project = new Command('project') diff --git a/packages/catalyst/src/cli/lib/resolve-credentials.spec.ts b/packages/catalyst/src/cli/lib/resolve-credentials.spec.ts new file mode 100644 index 0000000000..add50dbfab --- /dev/null +++ b/packages/catalyst/src/cli/lib/resolve-credentials.spec.ts @@ -0,0 +1,80 @@ +import Conf from 'conf'; +import { afterAll, afterEach, beforeAll, expect, MockInstance, test, vi } from 'vitest'; + +import { consola } from './logger'; +import { mkTempDir } from './mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from './project-config'; +import { resolveCredentials } from './resolve-credentials'; + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +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(); + config = getProjectConfig(tmpDir); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + await cleanup(); +}); + +test('returns credentials from options when both provided', () => { + const result = resolveCredentials({ storeHash: 'opt-hash', accessToken: 'opt-token' }, config); + + expect(result).toEqual({ storeHash: 'opt-hash', accessToken: 'opt-token' }); + expect(exitMock).not.toHaveBeenCalled(); +}); + +test('falls back to config when options missing', () => { + config.set('storeHash', 'cfg-hash'); + config.set('accessToken', 'cfg-token'); + + const result = resolveCredentials({}, config); + + expect(result).toEqual({ storeHash: 'cfg-hash', accessToken: 'cfg-token' }); + expect(exitMock).not.toHaveBeenCalled(); +}); + +test('options take precedence over config', () => { + config.set('storeHash', 'cfg-hash'); + config.set('accessToken', 'cfg-token'); + + const result = resolveCredentials({ storeHash: 'opt-hash', accessToken: 'opt-token' }, config); + + expect(result).toEqual({ storeHash: 'opt-hash', accessToken: 'opt-token' }); +}); + +test('mixed resolution: option storeHash + config accessToken', () => { + config.set('accessToken', 'cfg-token'); + + const result = resolveCredentials({ storeHash: 'opt-hash' }, config); + + expect(result).toEqual({ storeHash: 'opt-hash', accessToken: 'cfg-token' }); +}); + +test('exits with error when no credentials found anywhere', () => { + expect(() => resolveCredentials({}, config)).toThrow('Missing credentials'); + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); +}); + +test('error message mentions catalyst auth login', () => { + expect(() => resolveCredentials({}, config)).toThrow(); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst auth login')); +}); diff --git a/packages/catalyst/src/cli/lib/resolve-credentials.ts b/packages/catalyst/src/cli/lib/resolve-credentials.ts new file mode 100644 index 0000000000..670e27d407 --- /dev/null +++ b/packages/catalyst/src/cli/lib/resolve-credentials.ts @@ -0,0 +1,25 @@ +import Conf from 'conf'; + +import { consola } from './logger'; +import { ProjectConfigSchema } from './project-config'; + +export function resolveCredentials( + options: { storeHash?: string; accessToken?: string }, + config: Conf, +): { storeHash: string; accessToken: string } { + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (!storeHash || !accessToken) { + consola.error('Missing credentials.'); + consola.info( + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', + ); + process.exit(1); + + // Unreachable in production; prevents continuation when process.exit is mocked in tests. + throw new Error('Missing credentials'); + } + + return { storeHash, accessToken }; +} From 747a972da96cbf63917f93a86f9d9ab2903e0905 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Thu, 19 Feb 2026 16:00:56 -0600 Subject: [PATCH 25/90] feat(cli): remove --root-dir flag from project create and link commands (#2892) --- .changeset/remove-root-dir-opt.md | 5 +++ .../catalyst/src/cli/commands/project.spec.ts | 31 ++++++------------- packages/catalyst/src/cli/commands/project.ts | 14 ++------- .../src/cli/lib/project-config.spec.ts | 7 +++-- .../catalyst/src/cli/lib/project-config.ts | 4 +-- .../src/cli/lib/resolve-credentials.spec.ts | 3 +- 6 files changed, 25 insertions(+), 39 deletions(-) create mode 100644 .changeset/remove-root-dir-opt.md diff --git a/.changeset/remove-root-dir-opt.md b/.changeset/remove-root-dir-opt.md new file mode 100644 index 0000000000..4cd1e2252f --- /dev/null +++ b/.changeset/remove-root-dir-opt.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Remove `--root-dir` flag from `project create` and `project link` commands. Use `process.cwd()` as a hardcoded default for usage simplicity. diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 315e42b8f0..c023d19427 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -55,7 +55,9 @@ beforeAll(async () => { [tmpDir, cleanup] = await mkTempDir(); - config = getProjectConfig(tmpDir); + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); }); afterEach(() => { @@ -110,8 +112,6 @@ describe('project create', () => { storeHash, '--access-token', accessToken, - '--root-dir', - tmpDir, ]); expect(mockIdentify).toHaveBeenCalledWith(storeHash); @@ -144,9 +144,9 @@ describe('project create', () => { delete process.env.CATALYST_STORE_HASH; delete process.env.CATALYST_ACCESS_TOKEN; - await expect( - program.parseAsync(['node', 'catalyst', 'project', 'create', '--root-dir', tmpDir]), - ).rejects.toThrow('Missing credentials'); + await expect(program.parseAsync(['node', 'catalyst', 'project', 'create'])).rejects.toThrow( + 'Missing credentials', + ); if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; @@ -177,8 +177,6 @@ describe('project create', () => { storeHash, '--access-token', accessToken, - '--root-dir', - tmpDir, ]), ).rejects.toThrow('Failed to create project, is the name already in use?'); @@ -245,7 +243,6 @@ describe('project link', () => { defaultValue: 'api.bigcommerce.com', }), expect.objectContaining({ flags: '--project-uuid ' }), - expect.objectContaining({ flags: '--root-dir ', defaultValue: process.cwd() }), ]), ); }); @@ -258,8 +255,6 @@ describe('project link', () => { 'link', '--project-uuid', projectUuid1, - '--root-dir', - tmpDir, ]); expect(consola.start).toHaveBeenCalledWith( @@ -304,8 +299,6 @@ describe('project link', () => { storeHash, '--access-token', accessToken, - '--root-dir', - tmpDir, ]); expect(mockIdentify).toHaveBeenCalledWith(storeHash); @@ -366,8 +359,6 @@ describe('project link', () => { storeHash, '--access-token', accessToken, - '--root-dir', - tmpDir, ]); expect(mockIdentify).toHaveBeenCalledWith(storeHash); @@ -430,8 +421,6 @@ describe('project link', () => { storeHash, '--access-token', accessToken, - '--root-dir', - tmpDir, ]), ).rejects.toThrow('Failed to create project, is the name already in use?'); @@ -460,8 +449,6 @@ describe('project link', () => { storeHash, '--access-token', accessToken, - '--root-dir', - tmpDir, ]), ).rejects.toThrow( 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', @@ -473,9 +460,9 @@ describe('project link', () => { }); test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { - await expect( - program.parseAsync(['node', 'catalyst', 'project', 'link', '--root-dir', tmpDir]), - ).rejects.toThrow('Missing credentials'); + await expect(program.parseAsync(['node', 'catalyst', 'project', 'link'])).rejects.toThrow( + 'Missing credentials', + ); expect(consola.start).not.toHaveBeenCalled(); expect(consola.success).not.toHaveBeenCalled(); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 7b9edfcd20..1a8c479ac9 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -72,13 +72,8 @@ const create = new Command('create') .env('BIGCOMMERCE_API_HOST') .default('api.bigcommerce.com'), ) - .option( - '--root-dir ', - 'Path to the root directory of your Catalyst project (default: current working directory).', - process.cwd(), - ) .action(async (options) => { - const config = getProjectConfig(options.rootDir); + const config = getProjectConfig(); const { storeHash, accessToken } = resolveCredentials(options, config); await getTelemetry().identify(storeHash); @@ -126,13 +121,8 @@ export const link = new Command('link') '--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.', ) - .option( - '--root-dir ', - 'Path to the root directory of your Catalyst project (default: current working directory).', - process.cwd(), - ) .action(async (options) => { - const config = getProjectConfig(options.rootDir); + const config = getProjectConfig(); const writeProjectConfig = ( uuid: string, diff --git a/packages/catalyst/src/cli/lib/project-config.spec.ts b/packages/catalyst/src/cli/lib/project-config.spec.ts index 432a3665fc..3f2371357a 100644 --- a/packages/catalyst/src/cli/lib/project-config.spec.ts +++ b/packages/catalyst/src/cli/lib/project-config.spec.ts @@ -1,7 +1,7 @@ import Conf from 'conf'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { afterAll, beforeAll, expect, test } from 'vitest'; +import { afterAll, beforeAll, expect, test, vi } from 'vitest'; import { mkTempDir } from './mk-temp-dir'; import { getProjectConfig, ProjectConfigSchema } from './project-config'; @@ -15,10 +15,13 @@ const projectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; beforeAll(async () => { [tmpDir, cleanup] = await mkTempDir(); - config = getProjectConfig(tmpDir); + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); }); afterAll(async () => { + vi.restoreAllMocks(); await cleanup(); }); diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 3ee0770b74..f6de2584a6 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -12,9 +12,9 @@ export interface ProjectConfigSchema { }; } -export function getProjectConfig(rootDir = process.cwd()) { +export function getProjectConfig() { return new Conf({ - cwd: join(rootDir, '.bigcommerce'), + cwd: join(process.cwd(), '.bigcommerce'), projectSuffix: '', configName: 'project', schema: { diff --git a/packages/catalyst/src/cli/lib/resolve-credentials.spec.ts b/packages/catalyst/src/cli/lib/resolve-credentials.spec.ts index add50dbfab..ffe39723ce 100644 --- a/packages/catalyst/src/cli/lib/resolve-credentials.spec.ts +++ b/packages/catalyst/src/cli/lib/resolve-credentials.spec.ts @@ -17,7 +17,8 @@ beforeAll(async () => { exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); [tmpDir, cleanup] = await mkTempDir(); - config = getProjectConfig(tmpDir); + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + config = getProjectConfig(); }); afterEach(() => { From 50f2433000ad1d6d3a3607532b5904bc051f9791 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Thu, 19 Feb 2026 16:10:14 -0600 Subject: [PATCH 26/90] feat(cli): remove dev command (#2893) users should run next dev directly instead --- .changeset/remove-dev-command.md | 5 ++++ packages/catalyst/src/cli/commands/dev.ts | 29 ----------------------- packages/catalyst/src/cli/index.spec.ts | 1 - packages/catalyst/src/cli/program.ts | 2 -- 4 files changed, 5 insertions(+), 32 deletions(-) create mode 100644 .changeset/remove-dev-command.md delete mode 100644 packages/catalyst/src/cli/commands/dev.ts diff --git a/.changeset/remove-dev-command.md b/.changeset/remove-dev-command.md new file mode 100644 index 0000000000..50777d29ef --- /dev/null +++ b/.changeset/remove-dev-command.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Remove `catalyst dev` command. Use `next dev` directly instead. diff --git a/packages/catalyst/src/cli/commands/dev.ts b/packages/catalyst/src/cli/commands/dev.ts deleted file mode 100644 index 4e135be0e7..0000000000 --- a/packages/catalyst/src/cli/commands/dev.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Command } from 'commander'; -import { execa } from 'execa'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; - -export const dev = new Command('dev') - .description('Start the Catalyst development server.') - // Proxy `--help` to the underlying `next dev` command - .helpOption(false) - .allowUnknownOption(true) - // The unknown options end up in program.args, not in program.opts(). Commander does not take a guess at how to interpret the unknown options. - .argument( - '[options...]', - 'Next.js `dev` options (see: https://nextjs.org/docs/app/api-reference/cli/next#next-dev-options)', - ) - .action(async (options) => { - const nextBin = join('node_modules', '.bin', 'next'); - - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${process.cwd()}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['dev', ...options], { - stdio: 'inherit', - cwd: process.cwd(), - }); - }); diff --git a/packages/catalyst/src/cli/index.spec.ts b/packages/catalyst/src/cli/index.spec.ts index f8dad3c35f..274d837ab2 100644 --- a/packages/catalyst/src/cli/index.spec.ts +++ b/packages/catalyst/src/cli/index.spec.ts @@ -21,7 +21,6 @@ describe('CLI program', () => { const commands = program.commands.map((cmd) => cmd.name()); expect(commands).toContain('version'); - expect(commands).toContain('dev'); expect(commands).toContain('start'); expect(commands).toContain('build'); expect(commands).toContain('deploy'); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 8bebb370fa..3b5a07865f 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -6,7 +6,6 @@ import PACKAGE_INFO from '../../package.json'; import { auth } from './commands/auth'; import { build } from './commands/build'; import { deploy } from './commands/deploy'; -import { dev } from './commands/dev'; import { project } from './commands/project'; import { start } from './commands/start'; import { telemetry } from './commands/telemetry'; @@ -23,7 +22,6 @@ program .version(PACKAGE_INFO.version) .description('CLI tool for Catalyst development') .addCommand(version) - .addCommand(dev) .addCommand(start) .addCommand(build) .addCommand(deploy) From 445ba43497e16982022c73b1dc3ede54f139ffbd Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Thu, 19 Feb 2026 16:10:44 -0600 Subject: [PATCH 27/90] feat(cli): remove next start proxy from start command (#2894) start command now only runs opennextjs-cloudflare preview. vanilla next.js users should run next start directly. --- .changeset/simplify-start-command.md | 5 ++ .../catalyst/src/cli/commands/start.spec.ts | 67 ++----------------- packages/catalyst/src/cli/commands/start.ts | 40 ++--------- 3 files changed, 13 insertions(+), 99 deletions(-) create mode 100644 .changeset/simplify-start-command.md diff --git a/.changeset/simplify-start-command.md b/.changeset/simplify-start-command.md new file mode 100644 index 0000000000..f719e14c7e --- /dev/null +++ b/.changeset/simplify-start-command.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Remove `next start` proxy from `catalyst start` command. The command now only runs the OpenNext Cloudflare local preview. diff --git a/packages/catalyst/src/cli/commands/start.spec.ts b/packages/catalyst/src/cli/commands/start.spec.ts index 6a28be025a..d5f502c968 100644 --- a/packages/catalyst/src/cli/commands/start.spec.ts +++ b/packages/catalyst/src/cli/commands/start.spec.ts @@ -8,27 +8,11 @@ import { program } from '../program'; import { start } from './start'; -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), -})); - vi.mock('execa', () => ({ execa: vi.fn(() => Promise.resolve({})), __esModule: true, })); -vi.mock('../../src/lib/project-config', () => ({ - getProjectConfig: vi.fn(() => ({ - get: vi.fn((key) => { - if (key === 'framework') { - return 'catalyst'; - } - - return undefined; - }), - })), -})); - beforeAll(() => { consola.wrapAll(); }); @@ -44,60 +28,17 @@ afterEach(() => { test('properly configured Command instance', () => { expect(start).toBeInstanceOf(Command); expect(start.name()).toBe('start'); - expect(start.description()).toBe('Start your Catalyst storefront in optimized production mode.'); - expect(start.options).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - flags: '--framework ', - argChoices: ['catalyst', 'nextjs'], - }), - ]), - ); -}); - -test('calls execa with Next.js production optimized server', async () => { - await program.parseAsync([ - 'node', - 'catalyst', - 'start', - '--port', - '3001', - '--framework', - 'nextjs', - ]); - - expect(execa).toHaveBeenCalledWith( - join('node_modules', '.bin', 'next'), - ['start', '--port', '3001'], - expect.objectContaining({ - stdio: 'inherit', - cwd: process.cwd(), - }), + expect(start.description()).toBe( + 'Start a local preview of your Catalyst storefront using the OpenNext Cloudflare adapter.', ); }); test('calls execa with OpenNext production optimized server', async () => { - await program.parseAsync([ - 'node', - 'catalyst', - 'start', - '--port', - '3001', - '--framework', - 'catalyst', - ]); + await program.parseAsync(['node', 'catalyst', 'start']); expect(execa).toHaveBeenCalledWith( 'pnpm', - [ - 'exec', - 'opennextjs-cloudflare', - 'preview', - '--config', - join('.bigcommerce', 'wrangler.jsonc'), - '--port', - '3001', - ], + ['exec', 'opennextjs-cloudflare', 'preview', '--config', '.bigcommerce/wrangler.jsonc'], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd(), diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index f72257ec0f..7a58922b3e 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -1,43 +1,12 @@ -import { Command, Option } from 'commander'; +import { Command } from 'commander'; import { execa } from 'execa'; -import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { getProjectConfig } from '../lib/project-config'; - export const start = new Command('start') - .description('Start your Catalyst storefront in optimized production mode.') - .allowUnknownOption(true) - // The unknown options end up in program.args, not in program.opts(). Commander does not take a guess at how to interpret the unknown options. - .argument( - '[start-options...]', - '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', - ]), + .description( + 'Start a local preview of your Catalyst storefront using the OpenNext Cloudflare adapter.', ) - .action(async (startOptions, options) => { - const config = getProjectConfig(); - const framework = options.framework ?? config.get('framework'); - - if (framework === 'nextjs') { - const nextBin = join('node_modules', '.bin', 'next'); - - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${process.cwd()}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['start', ...startOptions], { - stdio: 'inherit', - cwd: process.cwd(), - }); - } - + .action(async () => { await execa( 'pnpm', [ @@ -46,7 +15,6 @@ export const start = new Command('start') 'preview', '--config', join('.bigcommerce', 'wrangler.jsonc'), - ...startOptions, ], { stdio: 'inherit', From 2fca759c3da1b7e72bdc52c154b1f13e7d9c7d61 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 12:58:28 -0500 Subject: [PATCH 28/90] add --env-file flag allowing user to pass path to environment variable file to load --- packages/catalyst/src/cli/program.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 3b5a07865f..4abd505add 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -1,5 +1,7 @@ import { Command } from 'commander'; import { colorize } from 'consola/utils'; +import { config } from 'dotenv'; +import { resolve } from 'node:path'; import PACKAGE_INFO from '../../package.json'; @@ -15,12 +17,33 @@ import { consola } from './lib/logger'; export const program = new Command(); +function loadEnvFilePreHook(thisCommand: Command) { + let root: typeof thisCommand | undefined = thisCommand; + + while (root.parent) { + root = root.parent as typeof thisCommand; + } + + const opts = root.opts() as { envFile?: string }; + const envFile = opts.envFile; + + if (envFile) { + const resolvedPath = resolve(process.cwd(), envFile); + + config({ path: resolvedPath, override: true }); + } +} + 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 environment file to load (relative to current working directory)', + ) .addCommand(version) .addCommand(start) .addCommand(build) @@ -28,5 +51,6 @@ program .addCommand(project) .addCommand(auth) .addCommand(telemetry) + .hook('preAction', loadEnvFilePreHook) .hook('preAction', telemetryPreHook) .hook('postAction', telemetryPostHook); From 66533b6b1aab3ac573c2f531a3fac442d85b67d6 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Tue, 17 Feb 2026 09:50:39 -0600 Subject: [PATCH 29/90] rebase on alpha --- packages/catalyst/src/cli/index.ts | 3 ++- packages/catalyst/src/cli/program.ts | 40 +++++++++++++++++++--------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/catalyst/src/cli/index.ts b/packages/catalyst/src/cli/index.ts index bcbcfad8cf..8d8dacb6be 100644 --- a/packages/catalyst/src/cli/index.ts +++ b/packages/catalyst/src/cli/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { consola } from './lib/logger'; import { getTelemetry } from './lib/telemetry'; -import { program } from './program'; +import { loadEnvFileFromArgv, program } from './program'; const handleFatalError = async (error: unknown) => { const telemetry = getTelemetry(); @@ -46,6 +46,7 @@ process.on('unhandledRejection', (reason) => { void (async () => { try { + loadEnvFileFromArgv(process.argv); await program.parseAsync(process.argv); } catch (error) { await handleFatalError(error); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 4abd505add..644f9f1af0 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -15,25 +15,40 @@ import { version } from './commands/version'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; -export const program = new Command(); - -function loadEnvFilePreHook(thisCommand: Command) { - let root: typeof thisCommand | undefined = thisCommand; +/** + * Load env file from --env-file in argv before Commander parses. + * Must run before program.parse() so process.env is populated when + * Commander reads .env('VAR') for option defaults. + */ +export function loadEnvFileFromArgv(argv: string[]): void { + const envFileIdx = argv.findIndex((arg) => arg === '--env-file' || arg.startsWith('--env-file=')); - while (root.parent) { - root = root.parent as typeof thisCommand; - } + if (envFileIdx === -1) return; - const opts = root.opts() as { envFile?: string }; - const envFile = opts.envFile; + const arg = argv[envFileIdx]; + const value = arg.startsWith('--env-file=') + ? arg.slice('--env-file='.length) + : argv[envFileIdx + 1]; - if (envFile) { - const resolvedPath = resolve(process.cwd(), envFile); + if (value && !value.startsWith('-')) { + const resolvedPath = resolve(process.cwd(), value); + const result = config({ path: resolvedPath, override: true }); - config({ path: resolvedPath, override: true }); + if (result.error) { + const err = result.error as NodeJS.ErrnoException; + console.log(result.error?.message); + console.log(result.error?.name); + consola.warn( + err.code === 'ENOENT' + ? `Env file not found: ${resolvedPath}` + : `Failed to load --env-file ${value}: ${result.error.message}`, + ); + } } } +export const program = new Command(); + consola.log(colorize('cyanBright', `◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.version}\n`)); program @@ -51,6 +66,5 @@ program .addCommand(project) .addCommand(auth) .addCommand(telemetry) - .hook('preAction', loadEnvFilePreHook) .hook('preAction', telemetryPreHook) .hook('postAction', telemetryPostHook); From dff0758224b8b6a8bcc5fb8153b41d627de8c1ac Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 11 Feb 2026 16:59:13 -0500 Subject: [PATCH 30/90] added changeset --- .changeset/legal-colts-matter.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/legal-colts-matter.md diff --git a/.changeset/legal-colts-matter.md b/.changeset/legal-colts-matter.md new file mode 100644 index 0000000000..4c2fc70a40 --- /dev/null +++ b/.changeset/legal-colts-matter.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Add --env-file flag to allow users to pass the relative path to an environment variable file to load variables from From 08842bc8d2182fc43155e619ed6ed8ead1b75fc9 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 11:07:13 -0600 Subject: [PATCH 31/90] rename variable to fix linting issue --- packages/catalyst/src/cli/program.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 644f9f1af0..ef67f20dab 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -15,19 +15,14 @@ import { version } from './commands/version'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; -/** - * Load env file from --env-file in argv before Commander parses. - * Must run before program.parse() so process.env is populated when - * Commander reads .env('VAR') for option defaults. - */ export function loadEnvFileFromArgv(argv: string[]): void { const envFileIdx = argv.findIndex((arg) => arg === '--env-file' || arg.startsWith('--env-file=')); if (envFileIdx === -1) return; - const arg = argv[envFileIdx]; - const value = arg.startsWith('--env-file=') - ? arg.slice('--env-file='.length) + const envFileArg = argv[envFileIdx]; + const value = envFileArg.startsWith('--env-file=') + ? envFileArg.slice('--env-file='.length) : argv[envFileIdx + 1]; if (value && !value.startsWith('-')) { @@ -36,8 +31,9 @@ export function loadEnvFileFromArgv(argv: string[]): void { if (result.error) { const err = result.error as NodeJS.ErrnoException; - console.log(result.error?.message); - console.log(result.error?.name); + + console.log(result.error.message); + console.log(result.error.name); consola.warn( err.code === 'ENOENT' ? `Env file not found: ${resolvedPath}` From 8fc765ea23a196aa9bc4f662eef87ff7b14fd121 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 11:11:10 -0600 Subject: [PATCH 32/90] fix linting --- packages/catalyst/src/cli/program.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index ef67f20dab..f6896c727f 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -30,12 +30,15 @@ export function loadEnvFileFromArgv(argv: string[]): void { const result = config({ path: resolvedPath, override: true }); if (result.error) { - const err = result.error as NodeJS.ErrnoException; + const errCode = + 'code' in result.error && typeof result.error.code === 'string' + ? result.error.code + : undefined; console.log(result.error.message); console.log(result.error.name); consola.warn( - err.code === 'ENOENT' + errCode === 'ENOENT' ? `Env file not found: ${resolvedPath}` : `Failed to load --env-file ${value}: ${result.error.message}`, ); From 80ed80893e11ca883b65f54865f1049d2e71cd54 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 11:31:59 -0600 Subject: [PATCH 33/90] remove changeset --- .changeset/legal-colts-matter.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/legal-colts-matter.md diff --git a/.changeset/legal-colts-matter.md b/.changeset/legal-colts-matter.md deleted file mode 100644 index 4c2fc70a40..0000000000 --- a/.changeset/legal-colts-matter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": patch ---- - -Add --env-file flag to allow users to pass the relative path to an environment variable file to load variables from From 9a1cd64990d018c7fa2c688085ace6ed4efe421e Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Mon, 16 Feb 2026 12:39:26 -0600 Subject: [PATCH 34/90] throw error on failed load of env file --- packages/catalyst/src/cli/program.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index f6896c727f..71eec14b73 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -35,13 +35,12 @@ export function loadEnvFileFromArgv(argv: string[]): void { ? result.error.code : undefined; - console.log(result.error.message); - console.log(result.error.name); - consola.warn( + const message = errCode === 'ENOENT' ? `Env file not found: ${resolvedPath}` - : `Failed to load --env-file ${value}: ${result.error.message}`, - ); + : `Failed to load --env-file ${value}: ${result.error.message}`; + + throw new Error(message); } } } From b8f93be6a22f31a0005e50942401ceb47af59042 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 18 Feb 2026 11:07:43 -0600 Subject: [PATCH 35/90] add arg parsing for env path --- packages/catalyst/src/cli/index.ts | 3 +- packages/catalyst/src/cli/program.ts | 64 ++++++++++++++-------------- packages/catalyst/tsup.config.ts | 1 + 3 files changed, 33 insertions(+), 35 deletions(-) diff --git a/packages/catalyst/src/cli/index.ts b/packages/catalyst/src/cli/index.ts index 8d8dacb6be..bcbcfad8cf 100644 --- a/packages/catalyst/src/cli/index.ts +++ b/packages/catalyst/src/cli/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { consola } from './lib/logger'; import { getTelemetry } from './lib/telemetry'; -import { loadEnvFileFromArgv, program } from './program'; +import { program } from './program'; const handleFatalError = async (error: unknown) => { const telemetry = getTelemetry(); @@ -46,7 +46,6 @@ process.on('unhandledRejection', (reason) => { void (async () => { try { - loadEnvFileFromArgv(process.argv); await program.parseAsync(process.argv); } catch (error) { await handleFatalError(error); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 71eec14b73..d6f7d990bb 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { colorize } from 'consola/utils'; import { config } from 'dotenv'; import { resolve } from 'node:path'; +import { Option } from '@commander-js/extra-typings'; import PACKAGE_INFO from '../../package.json'; @@ -15,36 +16,6 @@ import { version } from './commands/version'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; -export function loadEnvFileFromArgv(argv: string[]): void { - const envFileIdx = argv.findIndex((arg) => arg === '--env-file' || arg.startsWith('--env-file=')); - - if (envFileIdx === -1) return; - - const envFileArg = argv[envFileIdx]; - const value = envFileArg.startsWith('--env-file=') - ? envFileArg.slice('--env-file='.length) - : argv[envFileIdx + 1]; - - if (value && !value.startsWith('-')) { - const resolvedPath = resolve(process.cwd(), value); - const result = config({ path: resolvedPath, override: true }); - - if (result.error) { - const errCode = - 'code' in result.error && typeof result.error.code === 'string' - ? result.error.code - : undefined; - - const message = - errCode === 'ENOENT' - ? `Env file not found: ${resolvedPath}` - : `Failed to load --env-file ${value}: ${result.error.message}`; - - throw new Error(message); - } - } -} - export const program = new Command(); consola.log(colorize('cyanBright', `◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.version}\n`)); @@ -53,9 +24,36 @@ program .name(PACKAGE_INFO.name) .version(PACKAGE_INFO.version) .description('CLI tool for Catalyst development') - .option( - '--env-file ', - 'Path to environment file to load (relative to current working directory)', + .addOption( + new Option( + '--env-path ', + 'Path to environment file to load (relative to current working directory)', + // We are using argParser, because commander loads in environment variables before executing hooks. + ).argParser((value) => { + if (value) { + const envFilePath = resolve(process.cwd(), value); + const result = config({ + path: envFilePath, + override: true, + }); + + if (result.error) { + const errCode = + 'code' in result.error && typeof result.error.code === 'string' + ? result.error.code + : undefined; + const message = + errCode === 'ENOENT' + ? `Env file not found: ${envFilePath}` + : `Failed to load --env-path ${value}: ${result.error.message}`; + throw new Error(message); + } + + consola.log(colorize('cyanBright', `Loaded environment variables from ${envFilePath}\n`)); + } + + return value; + }), ) .addCommand(version) .addCommand(start) diff --git a/packages/catalyst/tsup.config.ts b/packages/catalyst/tsup.config.ts index b0d6ffe955..f83dfee78f 100644 --- a/packages/catalyst/tsup.config.ts +++ b/packages/catalyst/tsup.config.ts @@ -11,5 +11,6 @@ export default defineConfig((options: Options) => ({ CLI_SEGMENT_WRITE_KEY: process.env.CLI_SEGMENT_WRITE_KEY ?? 'not-a-valid-segment-write-key', CONSOLA_LEVEL: process.env.NODE_ENV === 'production' ? '2' : '3', }, + external: ['commander', '@commander-js/extra-typings'], ...options, })); From 50e112b7c23180a904de0b4babf13345204ccab6 Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Wed, 18 Feb 2026 11:12:24 -0600 Subject: [PATCH 36/90] fix linting --- packages/catalyst/src/cli/program.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index d6f7d990bb..ed451a3c24 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -1,8 +1,8 @@ +import { Option } from '@commander-js/extra-typings'; import { Command } from 'commander'; import { colorize } from 'consola/utils'; import { config } from 'dotenv'; import { resolve } from 'node:path'; -import { Option } from '@commander-js/extra-typings'; import PACKAGE_INFO from '../../package.json'; @@ -46,6 +46,7 @@ program errCode === 'ENOENT' ? `Env file not found: ${envFilePath}` : `Failed to load --env-path ${value}: ${result.error.message}`; + throw new Error(message); } From 1449ea1503b5cb95e3178d9772a07d975687ff7c Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Thu, 19 Feb 2026 11:37:46 -0600 Subject: [PATCH 37/90] add tests for --env-path flag --- packages/catalyst/src/cli/index.spec.ts | 68 ++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/catalyst/src/cli/index.spec.ts b/packages/catalyst/src/cli/index.spec.ts index 274d837ab2..731a0bbe0e 100644 --- a/packages/catalyst/src/cli/index.spec.ts +++ b/packages/catalyst/src/cli/index.spec.ts @@ -1,5 +1,7 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { Command } from '@commander-js/extra-typings'; -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; vi.mock('./hooks/telemetry', () => ({ telemetryPreHook: vi.fn().mockResolvedValue(undefined), @@ -7,6 +9,7 @@ vi.mock('./hooks/telemetry', () => ({ })); import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; +import { mkTempDir } from './lib/mk-temp-dir'; import { program } from './program'; describe('CLI program', () => { @@ -52,3 +55,66 @@ describe('CLI program', () => { expect(telemetryPreHook).toHaveBeenCalledWith(expect.any(Command), expect.any(Command)); }); }); + +describe('--env-path option', () => { + afterEach(() => { + delete process.env['CATALYST_STORE_HASH']; + delete process.env['CATALYST_ACCESS_TOKEN']; + }); + + test('loads environment variables from file when --env-path points to existing file', async () => { + const [tmpDir, cleanup] = await mkTempDir('catalyst-env-path-'); + const envPath = join(tmpDir, '.env'); + await writeFile( + envPath, + 'CATALYST_STORE_HASH=test-store-hash\nCATALYST_ACCESS_TOKEN=test-access-token', + 'utf-8', + ); + + try { + await program.parseAsync(['--env-path', envPath, 'version'], { from: 'user' }); + + expect(process.env['CATALYST_STORE_HASH']).toBe('test-store-hash'); + expect(process.env['CATALYST_ACCESS_TOKEN']).toBe('test-access-token'); + } finally { + await cleanup(); + } + }); + + test('loads environment variables when --env-path is relative to cwd', async () => { + const [tmpDir, cleanup] = await mkTempDir('catalyst-env-path-'); + const envFileName = '.env.catalyst-test'; + const envPath = join(tmpDir, envFileName); + await writeFile( + envPath, + 'CATALYST_STORE_HASH=test-store-hash\nCATALYST_ACCESS_TOKEN=test-access-token', + 'utf-8', + ); + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + + try { + await program.parseAsync(['--env-path', envFileName, 'version'], { from: 'user' }); + + expect(process.env['CATALYST_STORE_HASH']).toBe('test-store-hash'); + expect(process.env['CATALYST_ACCESS_TOKEN']).toBe('test-access-token'); + } finally { + process.chdir(originalCwd); + await cleanup(); + } + }); + + test('throws when --env-path points to non-existent file', async () => { + const [tmpDir, cleanup] = await mkTempDir('catalyst-env-path-'); + const nonExistentPath = join(tmpDir, '.env.missing'); + + try { + await expect( + program.parseAsync(['--env-path', nonExistentPath, 'version'], { from: 'user' }), + ).rejects.toThrow(/Env file not found/); + } finally { + await cleanup(); + } + }); +}); From 482413b772697fdd6bbf86ef207a72ea4fafea2a Mon Sep 17 00:00:00 2001 From: jamesqquick Date: Thu, 19 Feb 2026 11:38:41 -0600 Subject: [PATCH 38/90] fix linting --- packages/catalyst/src/cli/index.spec.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/catalyst/src/cli/index.spec.ts b/packages/catalyst/src/cli/index.spec.ts index 731a0bbe0e..d028081917 100644 --- a/packages/catalyst/src/cli/index.spec.ts +++ b/packages/catalyst/src/cli/index.spec.ts @@ -1,6 +1,6 @@ +import { Command } from '@commander-js/extra-typings'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { Command } from '@commander-js/extra-typings'; import { afterEach, describe, expect, test, vi } from 'vitest'; vi.mock('./hooks/telemetry', () => ({ @@ -58,13 +58,14 @@ describe('CLI program', () => { describe('--env-path option', () => { afterEach(() => { - delete process.env['CATALYST_STORE_HASH']; - delete process.env['CATALYST_ACCESS_TOKEN']; + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; }); test('loads environment variables from file when --env-path points to existing file', async () => { const [tmpDir, cleanup] = await mkTempDir('catalyst-env-path-'); const envPath = join(tmpDir, '.env'); + await writeFile( envPath, 'CATALYST_STORE_HASH=test-store-hash\nCATALYST_ACCESS_TOKEN=test-access-token', @@ -74,8 +75,8 @@ describe('--env-path option', () => { try { await program.parseAsync(['--env-path', envPath, 'version'], { from: 'user' }); - expect(process.env['CATALYST_STORE_HASH']).toBe('test-store-hash'); - expect(process.env['CATALYST_ACCESS_TOKEN']).toBe('test-access-token'); + expect(process.env.CATALYST_STORE_HASH).toBe('test-store-hash'); + expect(process.env.CATALYST_ACCESS_TOKEN).toBe('test-access-token'); } finally { await cleanup(); } @@ -85,6 +86,7 @@ describe('--env-path option', () => { const [tmpDir, cleanup] = await mkTempDir('catalyst-env-path-'); const envFileName = '.env.catalyst-test'; const envPath = join(tmpDir, envFileName); + await writeFile( envPath, 'CATALYST_STORE_HASH=test-store-hash\nCATALYST_ACCESS_TOKEN=test-access-token', @@ -92,13 +94,14 @@ describe('--env-path option', () => { ); const originalCwd = process.cwd(); + process.chdir(tmpDir); try { await program.parseAsync(['--env-path', envFileName, 'version'], { from: 'user' }); - expect(process.env['CATALYST_STORE_HASH']).toBe('test-store-hash'); - expect(process.env['CATALYST_ACCESS_TOKEN']).toBe('test-access-token'); + expect(process.env.CATALYST_STORE_HASH).toBe('test-store-hash'); + expect(process.env.CATALYST_ACCESS_TOKEN).toBe('test-access-token'); } finally { process.chdir(originalCwd); await cleanup(); From 783d0066d4ecaa90381cb5e6930bf5e691f04f46 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Fri, 20 Feb 2026 11:29:27 -0600 Subject: [PATCH 39/90] feat(cli): remove framework config and simplify build command (#2895) --- .changeset/remove-framework-config.md | 5 ++ .../catalyst/src/cli/commands/build.spec.ts | 31 +----------- packages/catalyst/src/cli/commands/build.ts | 47 +++---------------- .../catalyst/src/cli/commands/project.spec.ts | 4 -- packages/catalyst/src/cli/commands/project.ts | 2 - .../src/cli/lib/project-config.spec.ts | 9 ---- .../catalyst/src/cli/lib/project-config.ts | 7 +-- 7 files changed, 17 insertions(+), 88 deletions(-) create mode 100644 .changeset/remove-framework-config.md diff --git a/.changeset/remove-framework-config.md b/.changeset/remove-framework-config.md new file mode 100644 index 0000000000..603e4ff710 --- /dev/null +++ b/.changeset/remove-framework-config.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Remove `framework` configuration and simplify `catalyst build` to only run the Catalyst build path. diff --git a/packages/catalyst/src/cli/commands/build.spec.ts b/packages/catalyst/src/cli/commands/build.spec.ts index 06f5525693..9de6f02008 100644 --- a/packages/catalyst/src/cli/commands/build.spec.ts +++ b/packages/catalyst/src/cli/commands/build.spec.ts @@ -1,44 +1,15 @@ import { Command } from 'commander'; -import { execa } from 'execa'; -import { join } from 'node:path'; import { expect, test, vi } from 'vitest'; -import { program } from '../program'; - import { build } from './build'; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions vi.spyOn(process, 'exit').mockImplementation(() => null as never); -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), -})); - -vi.mock('execa', () => ({ - execa: vi.fn(() => Promise.resolve({ stdout: '' })), - __esModule: true, -})); - test('properly configured Command instance', () => { expect(build).toBeInstanceOf(Command); expect(build.name()).toBe('build'); expect(build.options).toEqual( - expect.arrayContaining([ - expect.objectContaining({ long: '--framework' }), - expect.objectContaining({ long: '--project-uuid' }), - ]), - ); -}); - -test('calls execa with Next.js build if framework is nextjs', async () => { - await program.parseAsync(['node', 'catalyst', 'build', '--framework', 'nextjs', '--debug']); - - expect(execa).toHaveBeenCalledWith( - join('node_modules', '.bin', 'next'), - ['build', '--debug'], - expect.objectContaining({ - stdio: 'inherit', - cwd: process.cwd(), - }), + expect.arrayContaining([expect.objectContaining({ long: '--project-uuid' })]), ); }); diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index ba717a0c0d..3a33f89559 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -1,6 +1,5 @@ import { Command, Option } from 'commander'; import { execa } from 'execa'; -import { existsSync } from 'node:fs'; import { copyFile, cp, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -77,53 +76,21 @@ export async function buildCatalystProject(projectUuid: string): Promise { } export const build = new Command('build') - .allowUnknownOption() - // The unknown options end up in program.args, not in program.opts(). Commander does not take a guess at how to interpret the unknown options. - .argument( - '[next-build-options...]', - 'Next.js `build` options (see: https://nextjs.org/docs/app/api-reference/cli/next#next-build-options)', - ) .addOption( new Option( '--project-uuid ', 'Project UUID to be included in the deployment configuration.', ).env('CATALYST_PROJECT_UUID'), ) - .addOption( - new Option('--framework ', 'The framework to use for the build.').choices([ - 'nextjs', - 'catalyst', - ]), - ) - .action(async (nextBuildOptions, options) => { - const coreDir = process.cwd(); + .action(async (options) => { const config = getProjectConfig(); - const framework = options.framework ?? config.get('framework'); - - if (framework === 'nextjs') { - const nextBin = join('node_modules', '.bin', 'next'); + const projectUuid = options.projectUuid ?? config.get('projectUuid'); - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${coreDir}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['build', ...nextBuildOptions], { - stdio: 'inherit', - cwd: coreDir, - }); + 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 .', + ); } - if (framework === 'catalyst') { - const projectUuid = options.projectUuid ?? config.get('projectUuid'); - - 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 .', - ); - } - - await buildCatalystProject(projectUuid); - } + await buildCatalystProject(projectUuid); }); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index c023d19427..e16c24bf48 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -129,7 +129,6 @@ describe('project create', () => { expect(exitMock).toHaveBeenCalledWith(0); expect(config.get('projectUuid')).toBe('c23f5785-fd99-4a94-9fb3-945551623925'); - expect(config.get('framework')).toBe('catalyst'); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); @@ -265,7 +264,6 @@ describe('project link', () => { ); 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 () => { @@ -316,7 +314,6 @@ describe('project link', () => { expect(exitMock).toHaveBeenCalledWith(0); expect(config.get('projectUuid')).toBe(projectUuid2); - expect(config.get('framework')).toBe('catalyst'); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); @@ -371,7 +368,6 @@ describe('project link', () => { expect(exitMock).toHaveBeenCalledWith(0); expect(config.get('projectUuid')).toBe(projectUuid3); - expect(config.get('framework')).toBe('catalyst'); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 1a8c479ac9..070dce1119 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -88,7 +88,6 @@ const create = new Command('create') 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.'); @@ -130,7 +129,6 @@ export const link = new Command('link') ) => { consola.start('Writing project UUID to .bigcommerce/project.json...'); config.set('projectUuid', uuid); - config.set('framework', 'catalyst'); if (credentials) { config.set('storeHash', credentials.storeHash); diff --git a/packages/catalyst/src/cli/lib/project-config.spec.ts b/packages/catalyst/src/cli/lib/project-config.spec.ts index 3f2371357a..2dcdb8691c 100644 --- a/packages/catalyst/src/cli/lib/project-config.spec.ts +++ b/packages/catalyst/src/cli/lib/project-config.spec.ts @@ -50,12 +50,3 @@ test('writes and reads field from .bigcommerce/project.json', async () => { expect(config.get('storeHash')).toBe('abc123'); expect(config.get('accessToken')).toBe('secret-token'); }); - -test('sets default framework to nextjs', async () => { - const projectJsonPath = join(tmpDir, '.bigcommerce/project.json'); - - await mkdir(dirname(projectJsonPath), { recursive: true }); - await writeFile(projectJsonPath, JSON.stringify({})); - - expect(config.get('framework')).toBe('nextjs'); -}); diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index f6de2584a6..43c32baa85 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -3,7 +3,8 @@ import { join } from 'path'; export interface ProjectConfigSchema { projectUuid: string; - framework: 'catalyst' | 'nextjs'; + // Reserved for future use + framework: 'catalyst'; storeHash?: string; accessToken?: string; telemetry: { @@ -21,8 +22,8 @@ export function getProjectConfig() { projectUuid: { type: 'string', format: 'uuid' }, framework: { type: 'string', - enum: ['catalyst', 'nextjs'], - default: 'nextjs', + enum: ['catalyst'], + default: 'catalyst', }, storeHash: { type: 'string' }, accessToken: { type: 'string' }, From 1179593f8a3dc62cb2836ca8c746f913ce1d2e3f Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 20 Mar 2026 09:23:07 -0500 Subject: [PATCH 40/90] fix: show readable error when project creation errors with 422 (#2938) --- .../catalyst/src/cli/commands/project.spec.ts | 83 +++++++++++++++++++ packages/catalyst/src/cli/lib/project.ts | 6 ++ 2 files changed, 89 insertions(+) diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index e16c24bf48..ff6b0cd865 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -181,6 +181,33 @@ describe('project create', () => { promptMock.mockRestore(); }); + + test('propagates 422 validation error', async () => { + server.use( + http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 422 }), + ), + ); + + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('bad name'); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow( + "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", + ); + + promptMock.mockRestore(); + }); }); describe('project list', () => { @@ -428,6 +455,62 @@ describe('project link', () => { consolaPromptMock.mockRestore(); }); + test('errors when create project returns 422 validation error', async () => { + server.use( + http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 422 }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + 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' }); + + return new Promise((resolve) => resolve('create')); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return new Promise((resolve) => resolve('bad name')); + }); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow( + "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", + ); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + consolaPromptMock.mockRestore(); + }); + test('errors when infrastructure projects API is not found', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index 45d0a2d3a1..8241f527d4 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -95,6 +95,12 @@ export async function createProject( ); } + if (response.status === 422) { + throw new Error( + "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", + ); + } + if (!response.ok) { throw new Error(`Failed to create project: ${response.statusText}`); } From 2365bc29fe1ee398450e95e32f832e0f02e65daf Mon Sep 17 00:00:00 2001 From: Jordan Arldt Date: Fri, 20 Mar 2026 17:18:43 -0500 Subject: [PATCH 41/90] CATALYST-1718: feat(cli) - Add catalyst logs command (#2921) --- packages/catalyst/AGENTS.md | 96 +++ packages/catalyst/README.md | 34 +- .../catalyst/src/cli/commands/logs.spec.ts | 548 ++++++++++++++++++ packages/catalyst/src/cli/commands/logs.ts | 279 +++++++++ packages/catalyst/src/cli/index.spec.ts | 7 + .../catalyst/src/cli/lib/shared-options.ts | 39 ++ packages/catalyst/src/cli/program.ts | 2 + packages/catalyst/tests/mocks/handlers.ts | 19 + 8 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 packages/catalyst/AGENTS.md create mode 100644 packages/catalyst/src/cli/commands/logs.spec.ts create mode 100644 packages/catalyst/src/cli/commands/logs.ts create mode 100644 packages/catalyst/src/cli/lib/shared-options.ts diff --git a/packages/catalyst/AGENTS.md b/packages/catalyst/AGENTS.md new file mode 100644 index 0000000000..1b968d9392 --- /dev/null +++ b/packages/catalyst/AGENTS.md @@ -0,0 +1,96 @@ +# Catalyst CLI (`@bigcommerce/catalyst`) + +CLI tool for Catalyst development and deployment — handles build, dev server, and deployment to Cloudflare Workers. + +## Directory Structure + +``` +src/cli/ +├── index.ts # Entry point (#!/usr/bin/env node) +├── program.ts # Commander program setup, registers all commands +├── commands/ # CLI command implementations (auth, build, deploy, logs, project, start, telemetry, version) +├── hooks/ # Pre/post action hooks (telemetry) +└── lib/ # Utilities (auth, logger, project config, credentials, wrangler config, telemetry, deployment errors) +templates/ # OpenNext config and public_headers template +tests/mocks/ # MSW handlers and test mocks +dist/cli.js # Bundled output (single ESM file) +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `auth whoami/login/logout` | Manage authentication (device code OAuth flow, credential storage) | +| `build` | Build Catalyst project using OpenNext/Cloudflare adapter | +| `deploy` | Deploy to Cloudflare with bundle upload | +| `logs` | View logs (`tail` default, `query` planned). Supports `--format` (default/json/pretty/short/request) | +| `project create/list/link` | Manage BigCommerce infrastructure projects | +| `start` | Start local preview using OpenNext Cloudflare adapter | +| `telemetry` | Enable/disable/check telemetry | +| `version` | Display version and platform info | + +## Development + +```bash +pnpm dev # Watch mode (rebuilds dist/cli.js on changes) +pnpm build # Production build via tsup +pnpm test # Run tests (vitest) +pnpm test:watch # Watch mode tests +pnpm typecheck # tsc --noEmit +pnpm lint # eslint with 0 warnings threshold +``` + +## Testing Changes + +To test CLI changes directly without a full publish cycle, build the CLI package first, then run the compiled output from inside the `core/` directory using its absolute path: + +```bash +# From packages/catalyst — rebuild the CLI +pnpm build + +# From core/ — run the CLI using the absolute path to the built output +pnpm exec /packages/catalyst/dist/cli.js +``` + +For example: `pnpm exec /packages/catalyst/dist/cli.js project list`. + +## Build + +- **Bundler**: tsup (`tsup.config.ts`) +- **Entry**: `src/cli/index.ts` → `dist/cli.js` (single ESM bundle with source maps) +- **Environment variables** injected at build time: `CLI_SEGMENT_WRITE_KEY`, `CONSOLA_LEVEL` + +## Testing + +- **Framework**: Vitest (`vitest.config.ts`) +- **Coverage threshold**: 100% (strict) +- **Mocking**: MSW for BigCommerce API calls; telemetry always disabled in tests (`vitest.setup.ts`) +- **Test files**: co-located as `*.spec.ts` next to source files + +## Key Dependencies + +- `commander` — CLI framework +- `execa` — process execution (next, pnpm, wrangler) +- `consola` — logging +- `zod` — API response validation +- `conf` — persistent config (`.bigcommerce/project.json`) +- `@segment/analytics-node` — telemetry +- `adm-zip` — bundle zipping for deploy + +## After Making Changes + +Always run `pnpm typecheck` and `pnpm lint` after making code changes, and fix any errors or warnings before considering the work done. The lint config enforces zero warnings (`--max-warnings 0`). Use `pnpm lint --fix` to auto-fix formatting and fixable lint issues before manually editing. + +## Code Style Notes + +- **No `let` when avoidable** — prefer `const` with `while (true)` + `break` over mutable flags. +- **No iterators/generators** — eslint `no-restricted-syntax` disallows `for...of` and generator functions. Use `.forEach()`, `.map()`, etc. +- **Consola for logging** — use `consola` (from `../lib/logger`) instead of `console`. Use `colorize` from `consola/utils` for colored output. +- **Shared CLI options** — commands that need `--store-hash`, `--access-token`, `--api-host`, and `--project-uuid` should use the shared option factories from `lib/shared-options.ts`. Chain `.makeOptionMandatory()` inline where needed to preserve commander's extra-typings inference. +- **SSE stream pattern** — the fetch API's `ReadableStreamDefaultReader` doesn't support async iteration, so `while (true)` + `reader.read()` + `break` is the standard pattern. For long-lived streams, implement a TTL-based reconnect to free connection pool resources, and distinguish server-side disconnects (`TypeError: terminated`) from actual failures for retry logic. + +## Integration Points + +- **BigCommerce Infrastructure API**: `https://api.bigcommerce.com/stores/{storeHash}/v3/infrastructure/` +- **Next.js**: dev/build/start +- **OpenNext + Cloudflare**: serverless build and deploy via Wrangler diff --git a/packages/catalyst/README.md b/packages/catalyst/README.md index ed8f949d92..76eadf49fd 100644 --- a/packages/catalyst/README.md +++ b/packages/catalyst/README.md @@ -1,3 +1,35 @@ # @bigcommerce/catalyst -CLI +CLI tool for Catalyst development and deployment. + +## Developing the CLI + +You'll need two terminal windows: + +### Terminal 1 — Watch mode (rebuilds on changes) + +```bash +cd packages/catalyst +pnpm dev +``` + +This runs `tsup --watch` and rebuilds `dist/cli.js` on every source change. + +### Terminal 2 — Run the CLI + +From the `core/` directory, run the CLI using the absolute path to the built executable: + +```bash +cd core +pnpm exec /packages/catalyst/dist/cli.js +``` + +For example: + +```bash +pnpm exec /packages/catalyst/dist/cli.js project list +pnpm exec /packages/catalyst/dist/cli.js logs tail +pnpm exec /packages/catalyst/dist/cli.js deploy +``` + +Replace `` with the absolute path to your local clone of the `catalyst` repository. diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts new file mode 100644 index 0000000000..cf695a303b --- /dev/null +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -0,0 +1,548 @@ +import { Command } from 'commander'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { consola } from '../lib/logger'; +import { program } from '../program'; + +import { logs, parseSSEEvent, tailLogs } from './logs'; + +let exitMock: MockInstance; +let stdoutWriteMock: MockInstance; + +const projectUuid = '6b202364-10f3-11f1-8bc7-fe9b9d8b14ab'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; + +const encoder = new TextEncoder(); + +const validLogEvent = { + uuid: '0f258256-0a83-4704-a456-03e99b4445c2', + project_uuid: projectUuid, + request: { method: 'GET', url: 'https://example.com/test', status_code: 200 }, + logs: [{ timestamp: '2026-03-11T22:05:28.870Z', level: 'info', messages: ['hello world'] }], + exceptions: [], + timestamp: '2026-03-11T22:05:28.870Z', +}; + +const createSSEStream = (events: string[], closeDelay = 10) => + new ReadableStream({ + start(controller) { + events.forEach((event) => { + controller.enqueue(encoder.encode(event)); + }); + setTimeout(() => controller.close(), closeDelay); + }, + }); + +// Creates a handler that serves SSE events on the first request, +// then returns 404 on subsequent requests to break the reconnect loop. +const createOneShotLogHandler = (events: string[], closeDelay = 10) => { + let called = false; + + return http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + if (called) { + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + } + + called = true; + + return new HttpResponse(createSSEStream(events, closeDelay), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }, + ); +}; + +const callTailLogs = async (format: Parameters[4], events?: string[]) => { + const sseEvents = events ?? [`data: ${JSON.stringify(validLogEvent)}\n\n`]; + + server.use(createOneShotLogHandler(sseEvents)); + + await tailLogs(projectUuid, storeHash, accessToken, apiHost, format).catch(() => { + // Expected: tailLogs throws when the one-shot handler returns 404 on reconnect + }); +}; + +beforeAll(() => { + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + stdoutWriteMock = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('command configuration', () => { + test('logs is a properly configured Command with tail and query subcommands', () => { + expect(logs).toBeInstanceOf(Command); + expect(logs.name()).toBe('logs'); + expect(logs.description()).toBe('View logs from your deployed application.'); + + const subcommands = logs.commands.map((c) => c.name()); + + expect(subcommands).toContain('tail'); + expect(subcommands).toContain('query'); + }); + + test('tail subcommand has correct options', () => { + const tail = logs.commands.find((c) => c.name() === 'tail'); + + expect(tail).toBeDefined(); + expect(tail?.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: '--format ', defaultValue: 'default' }), + ]), + ); + }); + + test('query subcommand has correct options', () => { + const query = logs.commands.find((c) => c.name() === 'query'); + + expect(query).toBeDefined(); + expect(query?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + ]), + ); + }); +}); + +describe('parseSSEEvent', () => { + test('extracts data from a single data line', () => { + expect(parseSSEEvent('data: {"foo":"bar"}')).toBe('{"foo":"bar"}'); + }); + + test('joins multiple data lines with newlines', () => { + expect(parseSSEEvent('data: line1\ndata: line2')).toBe('line1\nline2'); + }); + + test('ignores non-data SSE fields', () => { + expect(parseSSEEvent('event: message\ndata: {"foo":"bar"}\nid: 123')).toBe('{"foo":"bar"}'); + }); + + test('ignores SSE comments', () => { + expect(parseSSEEvent(': this is a comment\ndata: {"foo":"bar"}')).toBe('{"foo":"bar"}'); + }); + + test('returns null for events with no data lines', () => { + expect(parseSSEEvent('event: ping')).toBeNull(); + expect(parseSSEEvent(': comment only')).toBeNull(); + expect(parseSSEEvent('')).toBeNull(); + }); + + test('returns null for heartbeat events with empty data', () => { + expect(parseSSEEvent('data: ')).toBeNull(); + expect(parseSSEEvent('data:')).toBeNull(); + }); +}); + +describe('format: default', () => { + test('logs timestamp, level, and message', async () => { + await callTailLogs('default'); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('[2026-03-11T22:05:28.870Z]')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); +}); + +describe('format: json', () => { + test('writes raw JSON to stdout', async () => { + await callTailLogs('json'); + + expect(stdoutWriteMock).toHaveBeenCalledWith( + expect.stringContaining('"uuid":"0f258256-0a83-4704-a456-03e99b4445c2"'), + ); + }); +}); + +describe('format: pretty', () => { + test('logs pretty-printed JSON', async () => { + await callTailLogs('pretty'); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('"uuid"')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('"hello world"')); + }); + + test('preserves unknown fields via loose schema', async () => { + const eventWithExtra = { ...validLogEvent, extra_field: 'should be preserved' }; + + await callTailLogs('pretty', [`data: ${JSON.stringify(eventWithExtra)}\n\n`]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('should be preserved')); + }); +}); + +describe('format: short', () => { + test('logs only the message', async () => { + await callTailLogs('short'); + + expect(consola.log).toHaveBeenCalledWith('hello world'); + }); +}); + +describe('format: request', () => { + test('logs timestamp, level, request info, and message', async () => { + await callTailLogs('request'); + + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('GET https://example.com/test'), + ); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('(200)')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); +}); + +describe('log event processing', () => { + test.each([ + ['info', 'INFO'], + ['warn', 'WARN'], + ['error', 'ERROR'], + ['debug', 'DEBUG'], + ])('formats %s level as %s', async (level, expected) => { + const event = { ...validLogEvent, logs: [{ ...validLogEvent.logs[0], level }] }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(expected)); + }); + + test('logs exceptions from the event', async () => { + const event = { + ...validLogEvent, + exceptions: [{ message: 'something broke', stack: 'Error: something broke' }], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('EXCEPTION'), + expect.objectContaining({ message: 'something broke' }), + ); + }); + + test('serializes non-string messages as JSON', async () => { + const event = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], messages: [{ nested: 'object' }, 42] }], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('{"nested":"object"}')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('42')); + }); + + test('multiple events in a single chunk are all processed', async () => { + const event1 = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], messages: ['first'] }], + }; + const event2 = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], messages: ['second'] }], + }; + + await callTailLogs('default', [ + `data: ${JSON.stringify(event1)}\n\ndata: ${JSON.stringify(event2)}\n\n`, + ]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('first')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('second')); + }); +}); + +describe('error handling', () => { + test('silently ignores heartbeat events', async () => { + await callTailLogs('default', [`data: \n\ndata: ${JSON.stringify(validLogEvent)}\n\n`]); + + expect(consola.warn).not.toHaveBeenCalled(); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); + + test('warns on invalid JSON in stream', async () => { + await callTailLogs('default', [`data: {not valid json}\n\n`]); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse log event')); + }); + + test('warns on valid JSON that does not match schema', async () => { + await callTailLogs('default', [`data: {"valid":"json","but":"wrong schema"}\n\n`]); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse log event')); + }); + + test('throws on fatal 4xx status codes', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to open log stream: 404 Not Found', + ); + }); + + test('throws on fatal 401 unauthorized', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to open log stream: 401 Unauthorized', + ); + }); +}); + +describe('retry and reconnect', () => { + test('retries on 5xx errors and throws after max retries', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 502, statusText: 'Bad Gateway' }), + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to connect to log stream after 5 retries.', + ); + + // 4 warnings logged for attempts 1-4, then throw on attempt 5 + expect(consola.warn).toHaveBeenCalledTimes(4); + }); + + test('logs retry attempt number in warning messages', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 503, statusText: 'Service Unavailable' }), + ), + ); + + await expect( + tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default'), + ).rejects.toThrow(); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('attempt 1/5'), + expect.anything(), + ); + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('attempt 4/5'), + expect.anything(), + ); + }); + + test('resets retry counter after a successful connection', async () => { + let requestCount = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + // First 3 requests: 502 errors (builds up retries to 3) + if (requestCount <= 3) { + return new HttpResponse(null, { status: 502, statusText: 'Bad Gateway' }); + } + + // 4th request: successful stream (resets retries to 0) + if (requestCount === 4) { + return new HttpResponse( + createSSEStream([`data: ${JSON.stringify(validLogEvent)}\n\n`]), + { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }, + ); + } + + // 5th+ requests: 502 again until retries are exhausted a second time + return new HttpResponse(null, { status: 502, statusText: 'Bad Gateway' }); + }, + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to connect to log stream after 5 retries.', + ); + + // 3 warnings before success + 4 warnings after success (throw on 5th retry) + expect(consola.warn).toHaveBeenCalledTimes(7); + }); + + test('server disconnect does not increment retry counter', async () => { + let requestCount = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + // Return a stream that immediately errors to simulate server disconnect + if (requestCount <= 3) { + const stream = new ReadableStream({ + start(controller) { + controller.error(new TypeError('terminated')); + }, + }); + + return new HttpResponse(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + // After 3 server disconnects, return 404 to break the loop + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + }, + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to open log stream: 404 Not Found', + ); + + // Server disconnect warnings should NOT contain "attempt X/5" + expect(consola.warn).toHaveBeenCalledTimes(3); + expect(consola.warn).toHaveBeenCalledWith('Log stream closed by server, reconnecting...'); + }); + + test('reconnects when connection TTL is reached', { timeout: 3000 }, async () => { + let requestCount = 0; + const ttlMs = 200; + + // Creates a stream that sends data every 30ms via setInterval and never closes. + // The cancel callback cleans up the interval so reader.cancel() resolves. + const createOpenEndedStream = () => { + let intervalId: ReturnType; + + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(validLogEvent)}\n\n`)); + intervalId = setInterval(() => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(validLogEvent)}\n\n`)); + }, 30); + }, + cancel() { + clearInterval(intervalId); + }, + }); + }; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + if (requestCount <= 2) { + return new HttpResponse(createOpenEndedStream(), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + // 3rd request: return 404 to break the reconnect loop + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + }, + ), + ); + + await expect( + tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default', ttlMs), + ).rejects.toThrow('Failed to open log stream: 404 Not Found'); + + // Should have connected 3 times: 2 TTL-triggered reconnects + final 404 + expect(requestCount).toBe(3); + + // Events from both successful connections should have been processed + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); +}); + +describe('query subcommand', () => { + test('exits with error as not yet implemented', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.error).toHaveBeenCalledWith('The query command is not yet implemented.'); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('program integration', () => { + test('logs tail is the default subcommand', async () => { + server.use(createOneShotLogHandler([`data: ${JSON.stringify(validLogEvent)}\n\n`])); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + ]); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('logs tail with --format json', async () => { + server.use(createOneShotLogHandler([`data: ${JSON.stringify(validLogEvent)}\n\n`])); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'tail', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + '--format', + 'json', + ]); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(stdoutWriteMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts new file mode 100644 index 0000000000..bf2ad2371d --- /dev/null +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -0,0 +1,279 @@ +import { Command, Option } from 'commander'; +import { colorize } from 'consola/utils'; +import { z } from 'zod'; + +import { consola } from '../lib/logger'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + resolveProjectUuid, + storeHashOption, +} from '../lib/shared-options'; +import { Telemetry } from '../lib/telemetry'; + +type LogFormat = 'json' | 'pretty' | 'default' | 'short' | 'request'; + +const telemetry = new Telemetry(); + +const DEFAULT_CONNECTION_TTL_MS = 1 * 60 * 1000; // 1 minute +const MAX_RETRIES = 5; + +const isFatalStatusCode = (status: number) => status >= 400 && status < 500; + +const LEVEL_COLORS: Record[0]> = { + INFO: 'green', + WARN: 'yellow', + ERROR: 'red', + DEBUG: 'gray', +}; + +const LogEventSchema = z + .object({ + uuid: z.string(), + project_uuid: z.string(), + request: z.object({ + method: z.string(), + url: z.string(), + status_code: z.number(), + }), + logs: z.array( + z.object({ + timestamp: z.string(), + level: z.string(), + messages: z.array(z.unknown()), + }), + ), + exceptions: z.array(z.unknown()), + timestamp: z.string(), + }) + .loose(); + +class StreamError extends Error { + fatal: boolean; + + constructor(message: string, fatal: boolean) { + super(message); + this.fatal = fatal; + } +} + +const formatMessages = (messages: unknown[]) => + messages.map((m) => (typeof m === 'string' ? m : JSON.stringify(m))).join(' '); + +const formatLogEvent = ( + event: z.infer, + format: 'default' | 'short' | 'request', +) => { + const { request, logs: logEntries, exceptions } = event; + + logEntries.forEach((entry) => { + const msg = formatMessages(entry.messages); + const level = entry.level.toUpperCase(); + const coloredLevel = colorize(LEVEL_COLORS[level] ?? 'white', level); + + switch (format) { + case 'short': + consola.log(msg); + break; + + case 'request': + consola.log( + `[${entry.timestamp}] [${coloredLevel}] ${request.method} ${request.url}` + + ` (${request.status_code}) ${msg}`, + ); + break; + + default: + consola.log(`[${entry.timestamp}] [${coloredLevel}] ${msg}`); + break; + } + }); + + exceptions.forEach((exception) => { + consola.error(`[${event.timestamp}] EXCEPTION`, exception); + }); +}; + +export const parseSSEEvent = (raw: string): string | null => { + const joined = raw + .split('\n') + .flatMap((line) => (line.startsWith('data:') ? [line.slice(5).trim()] : [])) + .join('\n'); + + return joined.length > 0 ? joined : null; +}; + +const processLogEvent = (event: string, format: LogFormat) => { + if (format === 'json') { + process.stdout.write(`${event}\n`); + + return; + } + + try { + const parsed: unknown = JSON.parse(event); + const logEvent = LogEventSchema.parse(parsed); + + if (format === 'pretty') { + consola.log(JSON.stringify(logEvent, null, 2)); + } else { + formatLogEvent(logEvent, format); + } + } catch { + consola.warn(`Failed to parse log event: ${event}`); + } +}; + +const openLogStream = async ( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +) => { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/infrastructure/logs/${projectUuid}/tail`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'text/event-stream', + Connection: 'keep-alive', + }, + }, + ); + + if (!response.ok) { + throw new StreamError( + `Failed to open log stream: ${response.status} ${response.statusText}`, + isFatalStatusCode(response.status), + ); + } + + const reader = response.body?.getReader(); + + if (!reader) { + throw new StreamError('Failed to read log stream.', true); + } + + return reader; +}; + +export const tailLogs = async ( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, + format: LogFormat, + connectionTtlMs = DEFAULT_CONNECTION_TTL_MS, +) => { + consola.info('Tailing logs...'); + + let retries = 0; + + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + const reader = await openLogStream(projectUuid, storeHash, accessToken, apiHost); + const decoder = new TextDecoder(); + const connectTime = Date.now(); + let buffer = ''; + + retries = 0; + + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { value, done: streamDone } = await reader.read(); + + if (value) { + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + + // Last element is either empty (complete event) or a partial chunk to carry over + buffer = parts.pop() ?? ''; + + parts + .map((raw) => parseSSEEvent(raw)) + .filter((event): event is string => event !== null) + .forEach((event) => processLogEvent(event, format)); + } + + if (streamDone || Date.now() - connectTime >= connectionTtlMs) { + void reader.cancel(); + break; + } + } + } catch (error) { + if (error instanceof StreamError && error.fatal) { + throw error; + } + + const isServerDisconnect = error instanceof TypeError && error.message === 'terminated'; + + if (isServerDisconnect) { + consola.warn('Log stream closed by server, reconnecting...'); + } else { + retries += 1; + + if (retries >= MAX_RETRIES) { + throw new Error(`Failed to connect to log stream after ${MAX_RETRIES} retries.`); + } + + consola.warn( + `Log stream disconnected, reconnecting (attempt ${retries}/${MAX_RETRIES})...`, + error, + ); + } + } + } +}; + +const tail = new Command('tail') + .description('Tail live logs from your deployed application.') + .addOption(storeHashOption().makeOptionMandatory()) + .addOption(accessTokenOption().makeOptionMandatory()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .addOption( + new Option('--format ', 'Output format for log events.') + .choices(['json', 'pretty', 'default', 'short', 'request']) + .default('default'), + ) + .action(async (options) => { + try { + await telemetry.identify(options.storeHash); + + const projectUuid = resolveProjectUuid(options); + + await tailLogs( + projectUuid, + options.storeHash, + options.accessToken, + options.apiHost, + options.format, + ); + } catch (error) { + consola.error(error); + process.exit(1); + } + }); + +const query = new Command('query') + .description('Query historical logs from your deployed application.') + .addOption(storeHashOption().makeOptionMandatory()) + .addOption(accessTokenOption().makeOptionMandatory()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .action((_options) => { + consola.error('The query command is not yet implemented.'); + process.exit(1); + }); + +export const logs = new Command('logs') + .description('View logs from your deployed application.') + .addCommand(tail, { isDefault: true }) + .addCommand(query); diff --git a/packages/catalyst/src/cli/index.spec.ts b/packages/catalyst/src/cli/index.spec.ts index d028081917..7eca4ec7da 100644 --- a/packages/catalyst/src/cli/index.spec.ts +++ b/packages/catalyst/src/cli/index.spec.ts @@ -29,6 +29,7 @@ describe('CLI program', () => { expect(commands).toContain('deploy'); expect(commands).toContain('project'); expect(commands).toContain('auth'); + expect(commands).toContain('logs'); const projectCmd = program.commands.find((cmd) => cmd.name() === 'project'); @@ -41,6 +42,12 @@ describe('CLI program', () => { expect(authCmd?.commands.map((c) => c.name())).toEqual( expect.arrayContaining(['whoami', 'login', 'logout']), ); + + const logsCmd = program.commands.find((cmd) => cmd.name() === 'logs'); + + expect(logsCmd?.commands.map((c) => c.name())).toEqual( + expect.arrayContaining(['tail', 'query']), + ); }); test('telemetry hooks are called when executing version command', async () => { diff --git a/packages/catalyst/src/cli/lib/shared-options.ts b/packages/catalyst/src/cli/lib/shared-options.ts new file mode 100644 index 0000000000..e0d29d802c --- /dev/null +++ b/packages/catalyst/src/cli/lib/shared-options.ts @@ -0,0 +1,39 @@ +import { Option } from 'commander'; + +import { getProjectConfig } from './project-config'; + +export const storeHashOption = () => + new Option( + '--store-hash ', + 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', + ).env('BIGCOMMERCE_STORE_HASH'); + +export const accessTokenOption = () => + new Option( + '--access-token ', + 'BigCommerce access token. Can be found after creating a store-level API account.', + ).env('BIGCOMMERCE_ACCESS_TOKEN'); + +export const apiHostOption = () => + new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') + .env('BIGCOMMERCE_API_HOST') + .default('api.bigcommerce.com'); + +export const projectUuidOption = () => + new Option( + '--project-uuid ', + 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', + ).env('BIGCOMMERCE_PROJECT_UUID'); + +export const resolveProjectUuid = (options: { projectUuid?: string }) => { + const config = getProjectConfig(); + const projectUuid = options.projectUuid ?? config.get('projectUuid'); + + 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 .', + ); + } + + return projectUuid; +}; diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index ed451a3c24..782be2d3d3 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -9,6 +9,7 @@ import PACKAGE_INFO from '../../package.json'; import { auth } from './commands/auth'; import { build } from './commands/build'; import { deploy } from './commands/deploy'; +import { logs } from './commands/logs'; import { project } from './commands/project'; import { start } from './commands/start'; import { telemetry } from './commands/telemetry'; @@ -60,6 +61,7 @@ program .addCommand(start) .addCommand(build) .addCommand(deploy) + .addCommand(logs) .addCommand(project) .addCommand(auth) .addCommand(telemetry) diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index a63e8113e6..c16ea054db 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -108,6 +108,25 @@ export const handlers = [ }); }), + // Handler for log tailing + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'data: {"uuid":"0f258256-0a83-4704-a456-03e99b4445c2","project_uuid":"6b202364-10f3-11f1-8bc7-fe9b9d8b14ab","request":{"method":"GET","url":"https://example.com/test","status_code":200},"logs":[{"timestamp":"2026-03-11T22:05:28.870Z","level":"info","messages":["hello world"]}],"exceptions":[],"timestamp":"2026-03-11T22:05:28.870Z"}\n\n', + ), + ); + setTimeout(() => controller.close(), 10); + }, + }); + + return new HttpResponse(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }), + // Handle for createProjects http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => HttpResponse.json({ From 18e44f57c945ce9183a8dcb11e99134ffeab1b1e Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Mon, 23 Mar 2026 19:35:09 -0500 Subject: [PATCH 42/90] fix(cli): symlink .env.local to .dev.vars before preview (#2944) Wrangler reads secrets from .dev.vars, not .env.local. Create a symlink from .bigcommerce/.dev.vars to .env.local before running opennextjs-cloudflare preview so the local server has access to environment variables. --- .../catalyst/src/cli/commands/start.spec.ts | 43 ++++++++++++++++++- packages/catalyst/src/cli/commands/start.ts | 27 +++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/catalyst/src/cli/commands/start.spec.ts b/packages/catalyst/src/cli/commands/start.spec.ts index d5f502c968..23329f1590 100644 --- a/packages/catalyst/src/cli/commands/start.spec.ts +++ b/packages/catalyst/src/cli/commands/start.spec.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { execa } from 'execa'; -import { join } from 'node:path'; +import { existsSync, lstatSync, symlinkSync } from 'node:fs'; import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; import { consola } from '../lib/logger'; @@ -8,6 +8,12 @@ import { program } from '../program'; import { start } from './start'; +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => false), + lstatSync: vi.fn(), + symlinkSync: vi.fn(), +})); + vi.mock('execa', () => ({ execa: vi.fn(() => Promise.resolve({})), __esModule: true, @@ -45,3 +51,38 @@ test('calls execa with OpenNext production optimized server', async () => { }), ); }); + +test('creates symlink when .env.local exists but .dev.vars does not', async () => { + vi.mocked(existsSync).mockImplementation((p) => { + if (String(p).endsWith('.env.local')) return true; + + return false; + }); + + await program.parseAsync(['node', 'catalyst', 'start']); + + expect(symlinkSync).toHaveBeenCalledWith( + expect.stringContaining('.env.local'), + expect.stringContaining('.dev.vars'), + ); +}); + +test('warns when .dev.vars exists and is not a symlink', async () => { + vi.mocked(existsSync).mockReturnValue(true); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- partial mock + vi.mocked(lstatSync).mockReturnValue({ + isSymbolicLink: () => false, + } as ReturnType); + + await program.parseAsync(['node', 'catalyst', 'start']); + + expect(symlinkSync).not.toHaveBeenCalled(); +}); + +test('warns when .env.local does not exist', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + await program.parseAsync(['node', 'catalyst', 'start']); + + expect(symlinkSync).not.toHaveBeenCalled(); +}); diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index 7a58922b3e..70a4ff108c 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -1,12 +1,37 @@ import { Command } from 'commander'; import { execa } from 'execa'; -import { join } from 'node:path'; +import { existsSync, lstatSync, symlinkSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { consola } from '../lib/logger'; export const start = new Command('start') .description( 'Start a local preview of your Catalyst storefront using the OpenNext Cloudflare adapter.', ) .action(async () => { + const envLocal = join(process.cwd(), '.env.local'); + const devVars = join(process.cwd(), '.bigcommerce', '.dev.vars'); + + if (existsSync(envLocal)) { + if (!existsSync(devVars)) { + const target = relative(join(process.cwd(), '.bigcommerce'), envLocal); + + symlinkSync(target, devVars); + consola.success('Symlinked .bigcommerce/.dev.vars → .env.local'); + } else if (!lstatSync(devVars).isSymbolicLink()) { + consola.warn( + '.bigcommerce/.dev.vars already exists and is not a symlink. ' + + 'Wrangler will use it instead of .env.local.', + ); + } + } else { + consola.warn( + 'No .env.local file found. The preview server may fail without environment variables.\n' + + 'Create a .env.local file in your project root with the required variables.', + ); + } + await execa( 'pnpm', [ From 0314b32728f17758fe1218d280c5dbad5b60af48 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Wed, 25 Mar 2026 14:24:21 -0500 Subject: [PATCH 43/90] fix(cli): pin @opennextjs/cloudflare peer dependency to 1.17.3 (#2949) --- .changeset/bump-opennext-cloudflare.md | 5 + packages/catalyst/package.json | 2 +- pnpm-lock.yaml | 4695 +++++++++--------------- 3 files changed, 1760 insertions(+), 2942 deletions(-) create mode 100644 .changeset/bump-opennext-cloudflare.md diff --git a/.changeset/bump-opennext-cloudflare.md b/.changeset/bump-opennext-cloudflare.md new file mode 100644 index 0000000000..1fa092334a --- /dev/null +++ b/.changeset/bump-opennext-cloudflare.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Bump `@opennextjs/cloudflare` peer dependency from `^1.8.0` to `^1.17.3`. diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index eda6b1d7b2..04e22e21a2 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -49,6 +49,6 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "@opennextjs/cloudflare": "^1.8.0" + "@opennextjs/cloudflare": "1.17.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e31f953226..6a57fcfd13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,16 +43,16 @@ importers: version: link:../packages/client '@c15t/nextjs': specifier: ^1.8.2 - version: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) + version: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@conform-to/react': specifier: ^1.6.1 - version: 1.6.1(react@19.1.7) + version: 1.6.1(react@19.1.5) '@conform-to/zod': specifier: ^1.6.1 version: 1.6.1(zod@3.25.51) '@icons-pack/react-simple-icons': specifier: ^11.2.0 - version: 11.2.0(react@19.1.7) + version: 11.2.0(react@19.1.5) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -67,46 +67,46 @@ importers: version: 0.208.0(@opentelemetry/api@1.9.0) '@radix-ui/react-accordion': specifier: ^1.2.11 - version: 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-checkbox': specifier: ^1.3.2 - version: 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-dialog': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 - version: 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-label': specifier: ^2.1.7 - version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-navigation-menu': specifier: ^1.2.13 - version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-popover': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-portal': specifier: ^1.1.9 - version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-radio-group': specifier: ^1.3.7 - version: 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-select': specifier: ^2.2.5 - version: 2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-switch': specifier: ^1.2.5 - version: 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-toggle': specifier: ^1.1.9 - version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-toggle-group': specifier: ^1.1.10 - version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@radix-ui/react-tooltip': specifier: ^1.2.7 - version: 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) '@t3-oss/env-core': specifier: ^0.13.6 version: 0.13.6(typescript@5.8.3)(zod@3.25.51) @@ -115,16 +115,16 @@ importers: version: 1.35.0 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + version: 1.5.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) '@vercel/functions': specifier: ^2.2.12 - version: 2.2.12(@aws-sdk/credential-provider-web-identity@3.864.0) + version: 2.2.12(@aws-sdk/credential-provider-web-identity@3.972.24) '@vercel/otel': specifier: ^2.1.0 version: 2.1.0(@opentelemetry/api-logs@0.208.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)) '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + version: 1.2.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -148,7 +148,7 @@ importers: version: 9.0.0-rc01(embla-carousel@9.0.0-rc01) embla-carousel-react: specifier: 9.0.0-rc01 - version: 9.0.0-rc01(react@19.1.7) + version: 9.0.0-rc01(react@19.1.5) gql.tada: specifier: ^1.8.10 version: 1.8.10(graphql@16.11.0)(typescript@5.8.3) @@ -166,37 +166,37 @@ importers: version: 11.1.0 lucide-react: specifier: ^0.474.0 - version: 0.474.0(react@19.1.7) + version: 0.474.0(react@19.1.5) next: - specifier: ~16.2.6 - version: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + specifier: ~16.1.6 + version: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) next-auth: specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7) + version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) next-intl: specifier: ^4.6.1 - version: 4.8.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(typescript@5.8.3) + version: 4.8.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7) + version: 2.4.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) p-lazy: specifier: ^5.0.0 version: 5.0.0 react: - specifier: 19.1.7 - version: 19.1.7 + specifier: 19.1.5 + version: 19.1.5 react-day-picker: specifier: ^9.7.0 - version: 9.7.0(react@19.1.7) + version: 9.7.0(react@19.1.5) react-dom: - specifier: 19.1.7 - version: 19.1.7(react@19.1.7) + specifier: 19.1.5 + version: 19.1.5(react@19.1.5) react-google-recaptcha: specifier: ^3.1.0 - version: 3.1.0(react@19.1.7) + version: 3.1.0(react@19.1.5) react-headroom: specifier: ^3.2.1 - version: 3.2.1(react@19.1.7) + version: 3.2.1(react@19.1.5) schema-dts: specifier: ^1.1.5 version: 1.1.5 @@ -205,7 +205,7 @@ importers: version: 0.0.1 sonner: specifier: ^1.7.4 - version: 1.7.4(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + version: 1.7.4(react-dom@19.1.5(react@19.1.5))(react@19.1.5) tailwindcss-radix: specifier: ^3.0.5 version: 3.0.5(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3))) @@ -307,8 +307,8 @@ importers: packages/catalyst: dependencies: '@opennextjs/cloudflare': - specifier: ^1.8.0 - version: 1.8.0(encoding@0.1.13)(wrangler@4.31.0) + specifier: 1.17.3 + version: 1.17.3(encoding@0.1.13)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(wrangler@4.31.0) '@segment/analytics-node': specifier: ^2.2.1 version: 2.2.1(encoding@0.1.13) @@ -590,62 +590,62 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@ast-grep/napi-darwin-arm64@0.35.0': - resolution: {integrity: sha512-T+MN4Oinc+sXjXCIHzfxDDWY7r2pKgPxM6zVeVlkMTrJV2mJtyKYBIS+CABhRM6kflps2T2I6l4DGaKV/8Ym9w==} + '@ast-grep/napi-darwin-arm64@0.40.5': + resolution: {integrity: sha512-2F072fGN0WTq7KI3okuEnkGJVEHLbi56Bw1H6NAMf7j2mJJeQWsRyGOMcyNnUXZDeNdvoMH0OB2a5wwUegY/nQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@ast-grep/napi-darwin-x64@0.35.0': - resolution: {integrity: sha512-pEYiN6JI1HY2uWhMYJ9+3yIMyVYKuYdFzeD+dL7odA3qzK0o9N9AM3/NOt4ynU2EhufaWCJr0P5NoQ636qN6MQ==} + '@ast-grep/napi-darwin-x64@0.40.5': + resolution: {integrity: sha512-dJMidHZhhxuLBYNi6/FKI812jQ7wcFPSKkVPwviez2D+KvYagapUMAV/4dJ7FCORfguVk8Y0jpPAlYmWRT5nvA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@ast-grep/napi-linux-arm64-gnu@0.35.0': - resolution: {integrity: sha512-NBuzQngABGKz7lhG08IQb+7nPqUx81Ol37xmS3ZhVSdSgM0mtp93rCbgFTkJcAFE8IMfCHQSg7G4g0Iotz4ABQ==} + '@ast-grep/napi-linux-arm64-gnu@0.40.5': + resolution: {integrity: sha512-nBRCbyoS87uqkaw4Oyfe5VO+SRm2B+0g0T8ME69Qry9ShMf41a2bTdpcQx9e8scZPogq+CTwDHo3THyBV71l9w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@ast-grep/napi-linux-arm64-musl@0.35.0': - resolution: {integrity: sha512-1EcvHPwyWpCL/96LuItBYGfeI5FaMTRvL+dHbO/hL5q1npqbb5qn+ppJwtNOjTPz8tayvgggxVk9T4C2O7taYA==} + '@ast-grep/napi-linux-arm64-musl@0.40.5': + resolution: {integrity: sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@ast-grep/napi-linux-x64-gnu@0.35.0': - resolution: {integrity: sha512-FDzNdlqmQnsiWXhnLxusw5AOfEcEM+5xtmrnAf3SBRFr86JyWD9qsynnFYC2pnP9hlMfifNH2TTmMpyGJW49Xw==} + '@ast-grep/napi-linux-x64-gnu@0.40.5': + resolution: {integrity: sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@ast-grep/napi-linux-x64-musl@0.35.0': - resolution: {integrity: sha512-wlmndjfBafT8u5p4DBnoRQyoCSGNuVSz7rT3TqhvlHcPzUouRWMn95epU9B1LNLyjXvr9xHeRjSktyCN28w57Q==} + '@ast-grep/napi-linux-x64-musl@0.40.5': + resolution: {integrity: sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@ast-grep/napi-win32-arm64-msvc@0.35.0': - resolution: {integrity: sha512-gkhJeYc4rrZLX2icLxalPikTLMR57DuIYLwLr9g+StHYXIsGHrbfrE6Nnbdd8Izfs34ArFCrcwdaMrGlvOPSeg==} + '@ast-grep/napi-win32-arm64-msvc@0.40.5': + resolution: {integrity: sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@ast-grep/napi-win32-ia32-msvc@0.35.0': - resolution: {integrity: sha512-OdUuRa3chHCZ65y+qALfkUjz0W0Eg21YZ9TyPquV5why07M6HAK38mmYGzLxFH6294SvRQhs+FA/rAfbKeH0jA==} + '@ast-grep/napi-win32-ia32-msvc@0.40.5': + resolution: {integrity: sha512-K/u8De62iUnFCzVUs7FBdTZ2Jrgc5/DLHqjpup66KxZ7GIM9/HGME/O8aSoPkpcAeCD4TiTZ11C1i5p5H98hTg==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@ast-grep/napi-win32-x64-msvc@0.35.0': - resolution: {integrity: sha512-pcQRUHqbroTN1oQ56V982a7IZTUUySQYWa2KEyksiifHGuBuitlzcyzFGjT96ThcqD9XW0UVJMvpoF2Qjh006Q==} + '@ast-grep/napi-win32-x64-msvc@0.40.5': + resolution: {integrity: sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@ast-grep/napi@0.35.0': - resolution: {integrity: sha512-3ucaaSxV6fxXoqHrE/rxAvP1THnDdY5jNzGlnvx+JvnY9C/dSRKc0jlRMRz59N3El572+/yNRUUpAV1T9aBJug==} + '@ast-grep/napi@0.40.5': + resolution: {integrity: sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A==} engines: {node: '>= 10'} '@auth/core@0.41.0': @@ -669,282 +669,192 @@ packages: '@aws-crypto/crc32c@5.2.0': resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - '@aws-crypto/ie11-detection@3.0.0': - resolution: {integrity: sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==} - '@aws-crypto/sha1-browser@5.2.0': resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - '@aws-crypto/sha256-browser@3.0.0': - resolution: {integrity: sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==} - '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - '@aws-crypto/sha256-js@3.0.0': - resolution: {integrity: sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==} - '@aws-crypto/sha256-js@5.2.0': resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} engines: {node: '>=16.0.0'} - '@aws-crypto/supports-web-crypto@3.0.0': - resolution: {integrity: sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==} - '@aws-crypto/supports-web-crypto@5.2.0': resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - '@aws-crypto/util@3.0.0': - resolution: {integrity: sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==} - '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-cloudfront@3.398.0': - resolution: {integrity: sha512-kISKhqN1k48TaMPbLgq9jj7mO2jvbJdhirvfu4JW3jhFhENnkY0oCwTPvR4Q6Ne2as6GFAMo2XZDZq4rxC7YDw==} - engines: {node: '>=14.0.0'} - - '@aws-sdk/client-dynamodb@3.868.0': - resolution: {integrity: sha512-a2uKLRplH3vTHgTHr+MaZ1YH0Sy0yVHK9rhM/drktxgXehMf4p7dZtt783c9SEupZvo46LxXryvwifoUdL4nRg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/client-lambda@3.865.0': - resolution: {integrity: sha512-ncCEW/kNRV8yJA/45z5HO6WEeihADzFY7RISfezDbvP3/X4dZb2gycRVPmJIE6CBqf01jwTkbG36qO+/iHIELg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/client-s3@3.864.0': - resolution: {integrity: sha512-QGYi9bWliewxumsvbJLLyx9WC0a4DP4F+utygBcq0zwPxaM0xDfBspQvP1dsepi7mW5aAjZmJ2+Xb7X0EhzJ/g==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/client-sqs@3.864.0': - resolution: {integrity: sha512-SxEdQW/g2hb7/O4juAQL0kOD86/QBUSNkdJ5rN3Nd04rJmYTCxe38iCJBT637n+hiedxThLuj8H9ZmY1/OSg7Q==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/client-sso@3.398.0': - resolution: {integrity: sha512-CygL0jhfibw4kmWXG/3sfZMFNjcXo66XUuPC4BqZBk8Rj5vFoxp1vZeMkDLzTIk97Nvo5J5Bh+QnXKhub6AckQ==} - engines: {node: '>=14.0.0'} - - '@aws-sdk/client-sso@3.864.0': - resolution: {integrity: sha512-THiOp0OpQROEKZ6IdDCDNNh3qnNn/kFFaTSOiugDpgcE5QdsOxh1/RXq7LmHpTJum3cmnFf8jG59PHcz9Tjnlw==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/client-sts@3.398.0': - resolution: {integrity: sha512-/3Pa9wLMvBZipKraq3AtbmTfXW6q9kyvhwOno64f1Fz7kFb8ijQFMGoATS70B2pGEZTlxkUqJFWDiisT6Q6dFg==} - engines: {node: '>=14.0.0'} - - '@aws-sdk/core@3.864.0': - resolution: {integrity: sha512-LFUREbobleHEln+Zf7IG83lAZwvHZG0stI7UU0CtwyuhQy5Yx0rKksHNOCmlM7MpTEbSCfntEhYi3jUaY5e5lg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-env@3.398.0': - resolution: {integrity: sha512-Z8Yj5z7FroAsR6UVML+XUdlpoqEe9Dnle8c2h8/xWwIC2feTfIBhjLhRVxfbpbM1pLgBSNEcZ7U8fwq5l7ESVQ==} - engines: {node: '>=14.0.0'} - - '@aws-sdk/credential-provider-env@3.864.0': - resolution: {integrity: sha512-StJPOI2Rt8UE6lYjXUpg6tqSZaM72xg46ljPg8kIevtBAAfdtq9K20qT/kSliWGIBocMFAv0g2mC0hAa+ECyvg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-http@3.864.0': - resolution: {integrity: sha512-E/RFVxGTuGnuD+9pFPH2j4l6HvrXzPhmpL8H8nOoJUosjx7d4v93GJMbbl1v/fkDLqW9qN4Jx2cI6PAjohA6OA==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-ini@3.398.0': - resolution: {integrity: sha512-AsK1lStK3nB9Cn6S6ODb1ktGh7SRejsNVQVKX3t5d3tgOaX+aX1Iwy8FzM/ZEN8uCloeRifUGIY9uQFygg5mSw==} - engines: {node: '>=14.0.0'} - - '@aws-sdk/credential-provider-ini@3.864.0': - resolution: {integrity: sha512-PlxrijguR1gxyPd5EYam6OfWLarj2MJGf07DvCx9MAuQkw77HBnsu6+XbV8fQriFuoJVTBLn9ROhMr/ROAYfUg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-node@3.398.0': - resolution: {integrity: sha512-odmI/DSKfuWUYeDnGTCEHBbC8/MwnF6yEq874zl6+owoVv0ZsYP8qBHfiJkYqrwg7wQ7Pi40sSAPC1rhesGwzg==} - engines: {node: '>=14.0.0'} - - '@aws-sdk/credential-provider-node@3.864.0': - resolution: {integrity: sha512-2BEymFeXURS+4jE9tP3vahPwbYRl0/1MVaFZcijj6pq+nf5EPGvkFillbdBRdc98ZI2NedZgSKu3gfZXgYdUhQ==} - engines: {node: '>=18.0.0'} + '@aws-sdk/client-cloudfront@3.984.0': + resolution: {integrity: sha512-couDuDLpJtoeWne/nYyJ+I+5ntBVdNgBVRTCoDaXuVV7OC3u/wz5Ps0+GogspEwMLEFoOJ8t691h3YXQtnpQTw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.398.0': - resolution: {integrity: sha512-WrkBL1W7TXN508PA9wRXPFtzmGpVSW98gDaHEaa8GolAPHMPa5t2QcC/z/cFpglzrcVv8SA277zu9Z8tELdZhg==} - engines: {node: '>=14.0.0'} + '@aws-sdk/client-dynamodb@3.984.0': + resolution: {integrity: sha512-8/Oft9MWQtbG6p9f8eY5fsKC2CcO5YVDlwive8eUYS9mEbgnyQxm68OyH26WvsSTykQ9QkIbR+fOG56RsIBODw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.864.0': - resolution: {integrity: sha512-Zxnn1hxhq7EOqXhVYgkF4rI9MnaO3+6bSg/tErnBQ3F8kDpA7CFU24G1YxwaJXp2X4aX3LwthefmSJHwcVP/2g==} - engines: {node: '>=18.0.0'} + '@aws-sdk/client-lambda@3.984.0': + resolution: {integrity: sha512-kqwNBIGNxGVhINwgN/UQfdsQkaMjbu9PFV2EhATWouV+RT60uMjK9JENgLDwbgJmEVbbnPsh9HaZ5KKwPSdiDg==} + engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.398.0': - resolution: {integrity: sha512-2Dl35587xbnzR/GGZqA2MnFs8+kS4wbHQO9BioU0okA+8NRueohNMdrdQmQDdSNK4BfIpFspiZmFkXFNyEAfgw==} - engines: {node: '>=14.0.0'} + '@aws-sdk/client-s3@3.984.0': + resolution: {integrity: sha512-7ny2Slr93Y+QniuluvcfWwyDi32zWQfznynL56Tk0vVh7bWrvS/odm8WP2nInKicRVNipcJHY2YInur6Q/9V0A==} + engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.864.0': - resolution: {integrity: sha512-UPyPNQbxDwHVGmgWdGg9/9yvzuedRQVF5jtMkmP565YX9pKZ8wYAcXhcYdNPWFvH0GYdB0crKOmvib+bmCuwkw==} - engines: {node: '>=18.0.0'} + '@aws-sdk/client-sqs@3.984.0': + resolution: {integrity: sha512-TDvHpOUWlpanc3xQ5Xw0y8L2hoojBFCCSmXQ/6rKqGOf1ScX3dMA+K9aF0Zp0iwjhSh4VvsHD42esl8XwQZDjA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.398.0': - resolution: {integrity: sha512-iG3905Alv9pINbQ8/MIsshgqYMbWx+NDQWpxbIW3W0MkSH3iAqdVpSCteYidYX9G/jv2Um1nW3y360ib20bvNg==} - engines: {node: '>=14.0.0'} + '@aws-sdk/core@3.973.24': + resolution: {integrity: sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.864.0': - resolution: {integrity: sha512-nNcjPN4SYg8drLwqK0vgVeSvxeGQiD0FxOaT38mV2H8cu0C5NzpvA+14Xy+W6vT84dxgmJYKk71Cr5QL2Oz+rA==} - engines: {node: '>=18.0.0'} + '@aws-sdk/crc64-nvme@3.972.5': + resolution: {integrity: sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==} + engines: {node: '>=20.0.0'} - '@aws-sdk/endpoint-cache@3.804.0': - resolution: {integrity: sha512-TQVDkA/lV6ua75ELZaichMzlp6x7tDa1bqdy/+0ZftmODPtKXuOOEcJxmdN7Ui/YRo1gkRz2D9txYy7IlNg1Og==} - engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.972.22': + resolution: {integrity: sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.862.0': - resolution: {integrity: sha512-Wcsc7VPLjImQw+CP1/YkwyofMs9Ab6dVq96iS8p0zv0C6YTaMjvillkau4zFfrrrTshdzFWKptIFhKK8Zsei1g==} - engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.972.24': + resolution: {integrity: sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-endpoint-discovery@3.862.0': - resolution: {integrity: sha512-43KnrSlzsa6/locegW9SLe/kMv51PPPAslDbBuLVtLcFUNWuCE7wgKTTzMPeA+NJQHKuJTFRR2TLKPYEs+4VJA==} - engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.972.24': + resolution: {integrity: sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.862.0': - resolution: {integrity: sha512-oG3AaVUJ+26p0ESU4INFn6MmqqiBFZGrebST66Or+YBhteed2rbbFl7mCfjtPWUFgquQlvT1UP19P3LjQKeKpw==} - engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-login@3.972.24': + resolution: {integrity: sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.864.0': - resolution: {integrity: sha512-MvakvzPZi9uyP3YADuIqtk/FAcPFkyYFWVVMf5iFs/rCdk0CUzn02Qf4CSuyhbkS6Y0KrAsMgKR4MgklPU79Wg==} - engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.972.25': + resolution: {integrity: sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.398.0': - resolution: {integrity: sha512-m+5laWdBaxIZK2ko0OwcCHJZJ5V1MgEIt8QVQ3k4/kOkN9ICjevOYmba751pHoTnbOYB7zQd6D2OT3EYEEsUcA==} - engines: {node: '>=14.0.0'} + '@aws-sdk/credential-provider-process@3.972.22': + resolution: {integrity: sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.862.0': - resolution: {integrity: sha512-jDje8dCFeFHfuCAxMDXBs8hy8q9NCTlyK4ThyyfAj3U4Pixly2mmzY2u7b7AyGhWsjJNx8uhTjlYq5zkQPQCYw==} - engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.972.24': + resolution: {integrity: sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-location-constraint@3.862.0': - resolution: {integrity: sha512-MnwLxCw7Cc9OngEH3SHFhrLlDI9WVxaBkp3oTsdY9JE7v8OE38wQ9vtjaRsynjwu0WRtrctSHbpd7h/QVvtjyA==} - engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.24': + resolution: {integrity: sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.398.0': - resolution: {integrity: sha512-CiJjW+FL12elS6Pn7/UVjVK8HWHhXMfvHZvOwx/Qkpy340sIhkuzOO6fZEruECDTZhl2Wqn81XdJ1ZQ4pRKpCg==} - engines: {node: '>=14.0.0'} + '@aws-sdk/dynamodb-codec@3.972.25': + resolution: {integrity: sha512-AfkNIk19MOeGXfRyznRMZNOTUt79HU2pl7GP606oUIGELmS495xfvGDGp9nGYbOPxWhXfM7/s51V8+lvmCEwVg==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.862.0': - resolution: {integrity: sha512-N/bXSJznNBR/i7Ofmf9+gM6dx/SPBK09ZWLKsW5iQjqKxAKn/2DozlnE54uiEs1saHZWoNDRg69Ww4XYYSlG1Q==} - engines: {node: '>=18.0.0'} + '@aws-sdk/endpoint-cache@3.972.4': + resolution: {integrity: sha512-GdASDnWanLnHxKK0hqV97xz23QmfA/C8yGe0PiuEmWiHSe+x+x+mFEj4sXqx9IbfyPncWz8f4EhNwBSG9cgYCg==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.398.0': - resolution: {integrity: sha512-7QpOqPQAZNXDXv6vsRex4R8dLniL0E/80OPK4PPFsrCh9btEyhN9Begh4i1T+5lL28hmYkztLOkTQ2N5J3hgRQ==} - engines: {node: '>=14.0.0'} + '@aws-sdk/middleware-bucket-endpoint@3.972.8': + resolution: {integrity: sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.862.0': - resolution: {integrity: sha512-KVoo3IOzEkTq97YKM4uxZcYFSNnMkhW/qj22csofLegZi5fk90ztUnnaeKfaEJHfHp/tm1Y3uSoOXH45s++kKQ==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-endpoint-discovery@3.972.8': + resolution: {integrity: sha512-S0oXx1QbSpMDBMJn4P0hOxW8ieGAdRT+G9NbL+ESWkkoCGf9D++fKYD2fyBGtIy88OrP7wgECpXgGLAcGpIj0A==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.864.0': - resolution: {integrity: sha512-GjYPZ6Xnqo17NnC8NIQyvvdzzO7dm+Ks7gpxD/HsbXPmV2aEfuFveJXneGW9e1BheSKFff6FPDWu8Gaj2Iu1yg==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-expect-continue@3.972.8': + resolution: {integrity: sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-sqs@3.862.0': - resolution: {integrity: sha512-DBX+xTAd3uhMYUFI3wIoSQYBmVFmq918Ah2t/NhTtkNmiuHAFxCy4fSzSklt9qS0i1WzccJEqOZNmqxGEFtolA==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.4': + resolution: {integrity: sha512-fhCbZXPAyy8btnNbnBlR7Cc1nD54cETSvGn2wey71ehsM89AKPO8Dpco9DBAAgvrUdLrdHQepBXcyX4vxC5OwA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-sts@3.398.0': - resolution: {integrity: sha512-+JH76XHEgfVihkY+GurohOQ5Z83zVN1nYcQzwCFnCDTh4dG4KwhnZKG+WPw6XJECocY0R+H0ivofeALHvVWJtQ==} - engines: {node: '>=14.0.0'} + '@aws-sdk/middleware-host-header@3.972.8': + resolution: {integrity: sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-signing@3.398.0': - resolution: {integrity: sha512-O0KqXAix1TcvZBFt1qoFkHMUNJOSgjJTYS7lFTRKSwgsD27bdW2TM2r9R8DAccWFt5Amjkdt+eOwQMIXPGTm8w==} - engines: {node: '>=14.0.0'} + '@aws-sdk/middleware-location-constraint@3.972.8': + resolution: {integrity: sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-ssec@3.862.0': - resolution: {integrity: sha512-72VtP7DZC8lYTE2L3Efx2BrD98oe9WTK8X6hmd3WTLkbIjvgWQWIdjgaFXBs8WevsXkewIctfyA3KEezvL5ggw==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-logger@3.972.8': + resolution: {integrity: sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.398.0': - resolution: {integrity: sha512-nF1jg0L+18b5HvTcYzwyFgfZQQMELJINFqI0mi4yRKaX7T5a3aGp5RVLGGju/6tAGTuFbfBoEhkhU3kkxexPYQ==} - engines: {node: '>=14.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.8': + resolution: {integrity: sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.864.0': - resolution: {integrity: sha512-wrddonw4EyLNSNBrApzEhpSrDwJiNfjxDm5E+bn8n32BbAojXASH8W8jNpxz/jMgNkkJNxCfyqybGKzBX0OhbQ==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.24': + resolution: {integrity: sha512-4sXxVC/enYgMkZefNMOzU6C6KtAXEvwVJLgNcUx1dvROH6GvKB5Sm2RGnGzTp0/PwkibIyMw4kOzF8tbLfaBAQ==} + engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.864.0': - resolution: {integrity: sha512-H1C+NjSmz2y8Tbgh7Yy89J20yD/hVyk15hNoZDbCYkXg0M358KS7KVIEYs8E2aPOCr1sK3HBE819D/yvdMgokA==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-sdk-sqs@3.972.17': + resolution: {integrity: sha512-LnzPRRoDXGtlFV2G1p2rsY6fRKrbf6Pvvc21KliSLw3+NmQca2+Aa1QIMRbpQvZYedsSqkGYwxe+qvXwQ2uxDw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.862.0': - resolution: {integrity: sha512-VisR+/HuVFICrBPY+q9novEiE4b3mvDofWqyvmxHcWM7HumTz9ZQSuEtnlB/92GVM3KDUrR9EmBHNRrfXYZkcQ==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-ssec@3.972.8': + resolution: {integrity: sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.864.0': - resolution: {integrity: sha512-w2HIn/WIcUyv1bmyCpRUKHXB5KdFGzyxPkp/YK5g+/FuGdnFFYWGfcO8O+How4jwrZTarBYsAHW9ggoKvwr37w==} - engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-user-agent@3.972.25': + resolution: {integrity: sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g==} + engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.398.0': - resolution: {integrity: sha512-nrYgjzavGCKJL/48Vt0EL+OlIc5UZLfNGpgyUW9cv3XZwl+kXV0QB+HH0rHZZLfpbBgZ2RBIJR9uD5ieu/6hpQ==} - engines: {node: '>=14.0.0'} + '@aws-sdk/nested-clients@3.996.14': + resolution: {integrity: sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q==} + engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.864.0': - resolution: {integrity: sha512-gTc2QHOBo05SCwVA65dUtnJC6QERvFaPiuppGDSxoF7O5AQNK0UR/kMSenwLqN8b5E1oLYvQTv3C1idJLRX0cg==} - engines: {node: '>=18.0.0'} + '@aws-sdk/region-config-resolver@3.972.9': + resolution: {integrity: sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng==} + engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.398.0': - resolution: {integrity: sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==} - engines: {node: '>=14.0.0'} + '@aws-sdk/signature-v4-multi-region@3.984.0': + resolution: {integrity: sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.862.0': - resolution: {integrity: sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==} - engines: {node: '>=18.0.0'} + '@aws-sdk/token-providers@3.1015.0': + resolution: {integrity: sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ==} + engines: {node: '>=20.0.0'} - '@aws-sdk/util-arn-parser@3.804.0': - resolution: {integrity: sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==} - engines: {node: '>=18.0.0'} + '@aws-sdk/types@3.973.6': + resolution: {integrity: sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==} + engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.398.0': - resolution: {integrity: sha512-Fy0gLYAei/Rd6BrXG4baspCnWTUSd0NdokU1pZh4KlfEAEN1i8SPPgfiO5hLk7+2inqtCmqxVJlfqbMVe9k4bw==} - engines: {node: '>=14.0.0'} + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.862.0': - resolution: {integrity: sha512-eCZuScdE9MWWkHGM2BJxm726MCmWk/dlHjOKvkM0sN1zxBellBMw5JohNss1Z8/TUmnW2gb9XHTOiHuGjOdksA==} - engines: {node: '>=18.0.0'} + '@aws-sdk/util-endpoints@3.984.0': + resolution: {integrity: sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==} + engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.804.0': - resolution: {integrity: sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==} - engines: {node: '>=18.0.0'} + '@aws-sdk/util-endpoints@3.996.5': + resolution: {integrity: sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==} + engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.893.0': resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==} engines: {node: '>=18.0.0'} - '@aws-sdk/util-user-agent-browser@3.398.0': - resolution: {integrity: sha512-A3Tzx1tkDHlBT+IgxmsMCHbV8LM7SwwCozq2ZjJRx0nqw3MCrrcxQFXldHeX/gdUMO+0Oocb7HGSnVODTq+0EA==} - - '@aws-sdk/util-user-agent-browser@3.862.0': - resolution: {integrity: sha512-BmPTlm0r9/10MMr5ND9E92r8KMZbq5ltYXYpVcUbAsnB1RJ8ASJuRoLne5F7mB3YMx0FJoOTuSq7LdQM3LgW3Q==} - - '@aws-sdk/util-user-agent-node@3.398.0': - resolution: {integrity: sha512-RTVQofdj961ej4//fEkppFf4KXqKGMTCqJYghx3G0C/MYXbg7MGl7LjfNGtJcboRE8pfHHQ/TUWBDA7RIAPPlQ==} - engines: {node: '>=14.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true + '@aws-sdk/util-user-agent-browser@3.972.8': + resolution: {integrity: sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==} - '@aws-sdk/util-user-agent-node@3.864.0': - resolution: {integrity: sha512-d+FjUm2eJEpP+FRpVR3z6KzMdx1qwxEYDz8jzNKwxYLBBquaBaP/wfoMtMQKAcbrR7aT9FZVZF7zDgzNxUvQlQ==} - engines: {node: '>=18.0.0'} + '@aws-sdk/util-user-agent-node@3.973.11': + resolution: {integrity: sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA==} + engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' peerDependenciesMeta: aws-crt: optional: true - '@aws-sdk/util-utf8-browser@3.259.0': - resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} - - '@aws-sdk/xml-builder@3.310.0': - resolution: {integrity: sha512-TqELu4mOuSIKQCqj63fGVs86Yh+vBx5nHRpWKNUNhB2nPTpfbziTs5c1X358be3peVWA4wPxW7Nt53KIg1tnNw==} - engines: {node: '>=14.0.0'} + '@aws-sdk/xml-builder@3.972.15': + resolution: {integrity: sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==} + engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.862.0': - resolution: {integrity: sha512-6Ed0kmC1NMbuFTEgNmamAUU1h5gShgxL1hBVLbEzUa3trX5aJBz1vU4bXaBTvOYUAnOHtiy1Ml4AMStd6hJnFA==} + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.27.1': @@ -2364,14 +2274,6 @@ packages: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2512,8 +2414,8 @@ packages: '@next/bundle-analyzer@16.1.6': resolution: {integrity: sha512-ee2kagdTaeEWPlotgdTOqFHYcD3e2m2bbE3I9Rq2i6ABYi5OgopmtEUe8NM23viaYxLV2tDH/2nd5+qKoEr6cw==} - '@next/env@16.2.6': - resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} '@next/eslint-plugin-next@15.3.3': resolution: {integrity: sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q==} @@ -2521,50 +2423,50 @@ packages: '@next/eslint-plugin-next@15.5.10': resolution: {integrity: sha512-fDpxcy6G7Il4lQVVsaJD0fdC2/+SmuBGTF+edRLlsR4ZFOE3W2VyzrrGYdg/pHW8TydeAdSVM+mIzITGtZ3yWA==} - '@next/swc-darwin-arm64@16.2.6': - resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.6': - resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.6': - resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.6': - resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.6': - resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.6': - resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.6': - resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.6': - resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2618,15 +2520,18 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@opennextjs/aws@3.7.6': - resolution: {integrity: sha512-l4UGkmaZaAjWER+PuZ/zNziMnrbf6oA9B1RUDKcyKsX+hEES1b7h6kLgpo4a4maf01M+k0yJUZQyF4sf+vI+Iw==} + '@opennextjs/aws@3.9.16': + resolution: {integrity: sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q==} hasBin: true + peerDependencies: + next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5 - '@opennextjs/cloudflare@1.8.0': - resolution: {integrity: sha512-1hcX7wHPNsF6jsMetANIECFnZ7lxsYdOzQAuR7S+2pi7QjLS487fQZ/WZ1pxoDYx4vpziw6yTFnaR+FyIqdsMg==} + '@opennextjs/cloudflare@1.17.3': + resolution: {integrity: sha512-BQT8R/CEqrZS2sYydJ6uqv3U+yZAxbTeDUfgqQIlOnhu3wDPsB/QuuUBBseWOZiOdZ8gLegK6F2SLKia74Nv6g==} hasBin: true peerDependencies: - wrangler: ^4.24.4 + next: ~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5 + wrangler: ^4.65.0 '@opentelemetry/api-logs@0.203.0': resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} @@ -3830,598 +3735,302 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@smithy/abort-controller@2.2.0': - resolution: {integrity: sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==} - engines: {node: '>=14.0.0'} - - '@smithy/abort-controller@4.0.5': - resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==} - engines: {node: '>=18.0.0'} - - '@smithy/abort-controller@4.2.3': - resolution: {integrity: sha512-xWL9Mf8b7tIFuAlpjKtRPnHrR8XVrwTj5NPYO/QwZPtc0SDLsPxb56V5tzi5yspSMytISHybifez+4jlrx0vkQ==} - engines: {node: '>=18.0.0'} - - '@smithy/chunked-blob-reader-native@4.0.0': - resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==} + '@smithy/abort-controller@4.2.12': + resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader@5.0.0': - resolution: {integrity: sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==} + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@2.2.0': - resolution: {integrity: sha512-fsiMgd8toyUba6n1WRmr+qACzXltpdDkPTAaDqc8QqPBUzO+/JKwL6bUBseHVi8tu9l+3JOK+tSf7cay+4B3LA==} - engines: {node: '>=14.0.0'} - - '@smithy/config-resolver@4.1.5': - resolution: {integrity: sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==} + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.0': - resolution: {integrity: sha512-Kkmz3Mup2PGp/HNJxhCWkLNdlajJORLSjwkcfrj0E7nu6STAEdcMR1ir5P9/xOmncx8xXfru0fbUYLlZog/cFg==} + '@smithy/config-resolver@4.4.13': + resolution: {integrity: sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==} engines: {node: '>=18.0.0'} - '@smithy/core@3.17.1': - resolution: {integrity: sha512-V4Qc2CIb5McABYfaGiIYLTmo/vwNIK7WXI5aGveBd9UcdhbOMwcvIMxIw/DJj1S9QgOMa/7FBkarMdIC0EOTEQ==} + '@smithy/core@3.23.12': + resolution: {integrity: sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==} engines: {node: '>=18.0.0'} - '@smithy/core@3.8.0': - resolution: {integrity: sha512-EYqsIYJmkR1VhVE9pccnk353xhs+lB6btdutJEtsp7R055haMJp2yE16eSxw8fv+G0WUY6vqxyYOP8kOqawxYQ==} + '@smithy/credential-provider-imds@4.2.12': + resolution: {integrity: sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@2.3.0': - resolution: {integrity: sha512-BWB9mIukO1wjEOo1Ojgl6LrG4avcaC7T/ZP6ptmAaW4xluhSIPZhY+/PI5YKzlk+jsm+4sQZB45Bt1OfMeQa3w==} - engines: {node: '>=14.0.0'} - - '@smithy/credential-provider-imds@4.0.7': - resolution: {integrity: sha512-dDzrMXA8d8riFNiPvytxn0mNwR4B3h8lgrQ5UjAGu6T9z/kRg/Xncf4tEQHE/+t25sY8IH3CowcmWi+1U5B1Gw==} + '@smithy/eventstream-codec@4.2.12': + resolution: {integrity: sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.3': - resolution: {integrity: sha512-hA1MQ/WAHly4SYltJKitEsIDVsNmXcQfYBRv2e+q04fnqtAX5qXaybxy/fhUeAMCnQIdAjaGDb04fMHQefWRhw==} + '@smithy/eventstream-serde-browser@4.2.12': + resolution: {integrity: sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.0.5': - resolution: {integrity: sha512-miEUN+nz2UTNoRYRhRqVTJCx7jMeILdAurStT2XoS+mhokkmz1xAPp95DFW9Gxt4iF2VBqpeF9HbTQ3kY1viOA==} + '@smithy/eventstream-serde-config-resolver@4.3.12': + resolution: {integrity: sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.0.5': - resolution: {integrity: sha512-LCUQUVTbM6HFKzImYlSB9w4xafZmpdmZsOh9rIl7riPC3osCgGFVP+wwvYVw6pXda9PPT9TcEZxaq3XE81EdJQ==} + '@smithy/eventstream-serde-node@4.2.12': + resolution: {integrity: sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.1.3': - resolution: {integrity: sha512-yTTzw2jZjn/MbHu1pURbHdpjGbCuMHWncNBpJnQAPxOVnFUAbSIUSwafiphVDjNV93TdBJWmeVAds7yl5QCkcA==} + '@smithy/eventstream-serde-universal@4.2.12': + resolution: {integrity: sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.0.5': - resolution: {integrity: sha512-lGS10urI4CNzz6YlTe5EYG0YOpsSp3ra8MXyco4aqSkQDuyZPIw2hcaxDU82OUVtK7UY9hrSvgWtpsW5D4rb4g==} + '@smithy/fetch-http-handler@5.3.15': + resolution: {integrity: sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.0.5': - resolution: {integrity: sha512-JFnmu4SU36YYw3DIBVao3FsJh4Uw65vVDIqlWT4LzR6gXA0F3KP0IXFKKJrhaVzCBhAuMsrUUaT5I+/4ZhF7aw==} + '@smithy/hash-blob-browser@4.2.13': + resolution: {integrity: sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@2.5.0': - resolution: {integrity: sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==} - - '@smithy/fetch-http-handler@5.1.1': - resolution: {integrity: sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==} + '@smithy/hash-node@4.2.12': + resolution: {integrity: sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.4': - resolution: {integrity: sha512-bwigPylvivpRLCm+YK9I5wRIYjFESSVwl8JQ1vVx/XhCw0PtCi558NwTnT2DaVCl5pYlImGuQTSwMsZ+pIavRw==} + '@smithy/hash-stream-node@4.2.12': + resolution: {integrity: sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==} engines: {node: '>=18.0.0'} - '@smithy/hash-blob-browser@4.0.5': - resolution: {integrity: sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==} + '@smithy/invalid-dependency@4.2.12': + resolution: {integrity: sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@2.2.0': - resolution: {integrity: sha512-zLWaC/5aWpMrHKpoDF6nqpNtBhlAYKF/7+9yMN7GpdR8CzohnWfGtMznPybnwSS8saaXBMxIGwJqR4HmRp6b3g==} + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/hash-node@4.0.5': - resolution: {integrity: sha512-cv1HHkKhpyRb6ahD8Vcfb2Hgz67vNIXEp2vnhzfxLFGRukLCNEA5QdsorbUEzXma1Rco0u3rx5VTqbM06GcZqQ==} + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.3': - resolution: {integrity: sha512-6+NOdZDbfuU6s1ISp3UOk5Rg953RJ2aBLNLLBEcamLjHAg1Po9Ha7QIB5ZWhdRUVuOUrT8BVFR+O2KIPmw027g==} + '@smithy/md5-js@4.2.12': + resolution: {integrity: sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==} engines: {node: '>=18.0.0'} - '@smithy/hash-stream-node@4.0.5': - resolution: {integrity: sha512-IJuDS3+VfWB67UC0GU0uYBG/TA30w+PlOaSo0GPm9UHS88A6rCP6uZxNjNYiyRtOcjv7TXn/60cW8ox1yuZsLg==} + '@smithy/middleware-content-length@4.2.12': + resolution: {integrity: sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@2.2.0': - resolution: {integrity: sha512-nEDASdbKFKPXN2O6lOlTgrEEOO9NHIeO+HVvZnkqc8h5U9g3BIhWsvzFo+UcUbliMHvKNPD/zVxDrkP1Sbgp8Q==} - - '@smithy/invalid-dependency@4.0.5': - resolution: {integrity: sha512-IVnb78Qtf7EJpoEVo7qJ8BEXQwgC4n3igeJNNKEj/MLYtapnx8A67Zt/J3RXAj2xSO1910zk0LdFiygSemuLow==} + '@smithy/middleware-endpoint@4.4.27': + resolution: {integrity: sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.3': - resolution: {integrity: sha512-Cc9W5DwDuebXEDMpOpl4iERo8I0KFjTnomK2RMdhhR87GwrSmUmwMxS4P5JdRf+LsjOdIqumcerwRgYMr/tZ9Q==} + '@smithy/middleware-retry@4.4.44': + resolution: {integrity: sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/is-array-buffer@4.0.0': - resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==} + '@smithy/middleware-serde@4.2.15': + resolution: {integrity: sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + '@smithy/middleware-stack@4.2.12': + resolution: {integrity: sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==} engines: {node: '>=18.0.0'} - '@smithy/md5-js@4.0.5': - resolution: {integrity: sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==} + '@smithy/node-config-provider@4.3.12': + resolution: {integrity: sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@2.2.0': - resolution: {integrity: sha512-5bl2LG1Ah/7E5cMSC+q+h3IpVHMeOkG0yLRyQT1p2aMJkSrZG7RlXHPuAgb7EyaFeidKEnnd/fNaLLaKlHGzDQ==} - engines: {node: '>=14.0.0'} - - '@smithy/middleware-content-length@4.0.5': - resolution: {integrity: sha512-l1jlNZoYzoCC7p0zCtBDE5OBXZ95yMKlRlftooE5jPWQn4YBPLgsp+oeHp7iMHaTGoUdFqmHOPa8c9G3gBsRpQ==} + '@smithy/node-http-handler@4.5.0': + resolution: {integrity: sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.3': - resolution: {integrity: sha512-/atXLsT88GwKtfp5Jr0Ks1CSa4+lB+IgRnkNrrYP0h1wL4swHNb0YONEvTceNKNdZGJsye+W2HH8W7olbcPUeA==} + '@smithy/property-provider@4.2.12': + resolution: {integrity: sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@2.5.1': - resolution: {integrity: sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==} - engines: {node: '>=14.0.0'} - - '@smithy/middleware-endpoint@4.1.18': - resolution: {integrity: sha512-ZhvqcVRPZxnZlokcPaTwb+r+h4yOIOCJmx0v2d1bpVlmP465g3qpVSf7wxcq5zZdu4jb0H4yIMxuPwDJSQc3MQ==} + '@smithy/protocol-http@5.3.12': + resolution: {integrity: sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.3.5': - resolution: {integrity: sha512-SIzKVTvEudFWJbxAaq7f2GvP3jh2FHDpIFI6/VAf4FOWGFZy0vnYMPSRj8PGYI8Hjt29mvmwSRgKuO3bK4ixDw==} + '@smithy/querystring-builder@4.2.12': + resolution: {integrity: sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@2.3.1': - resolution: {integrity: sha512-P2bGufFpFdYcWvqpyqqmalRtwFUNUA8vHjJR5iGqbfR6mp65qKOLcUd6lTr4S9Gn/enynSrSf3p3FVgVAf6bXA==} - engines: {node: '>=14.0.0'} - - '@smithy/middleware-retry@4.1.19': - resolution: {integrity: sha512-X58zx/NVECjeuUB6A8HBu4bhx72EoUz+T5jTMIyeNKx2lf+Gs9TmWPNNkH+5QF0COjpInP/xSpJGJ7xEnAklQQ==} + '@smithy/querystring-parser@4.2.12': + resolution: {integrity: sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.5': - resolution: {integrity: sha512-DCaXbQqcZ4tONMvvdz+zccDE21sLcbwWoNqzPLFlZaxt1lDtOE2tlVpRSwcTOJrjJSUThdgEYn7HrX5oLGlK9A==} + '@smithy/service-error-classification@4.2.12': + resolution: {integrity: sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@2.3.0': - resolution: {integrity: sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==} - engines: {node: '>=14.0.0'} - - '@smithy/middleware-serde@4.0.9': - resolution: {integrity: sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==} + '@smithy/shared-ini-file-loader@4.4.7': + resolution: {integrity: sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.3': - resolution: {integrity: sha512-8g4NuUINpYccxiCXM5s1/V+uLtts8NcX4+sPEbvYQDZk4XoJfDpq5y2FQxfmUL89syoldpzNzA0R9nhzdtdKnQ==} + '@smithy/signature-v4@5.3.12': + resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@2.2.0': - resolution: {integrity: sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==} - engines: {node: '>=14.0.0'} - - '@smithy/middleware-stack@4.0.5': - resolution: {integrity: sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==} + '@smithy/smithy-client@4.12.7': + resolution: {integrity: sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.3': - resolution: {integrity: sha512-iGuOJkH71faPNgOj/gWuEGS6xvQashpLwWB1HjHq1lNNiVfbiJLpZVbhddPuDbx9l4Cgl0vPLq5ltRfSaHfspA==} + '@smithy/types@4.13.1': + resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@2.3.0': - resolution: {integrity: sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==} - engines: {node: '>=14.0.0'} - - '@smithy/node-config-provider@4.1.4': - resolution: {integrity: sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==} + '@smithy/url-parser@4.2.12': + resolution: {integrity: sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.3': - resolution: {integrity: sha512-NzI1eBpBSViOav8NVy1fqOlSfkLgkUjUTlohUSgAEhHaFWA3XJiLditvavIP7OpvTjDp5u2LhtlBhkBlEisMwA==} + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@2.5.0': - resolution: {integrity: sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==} - engines: {node: '>=14.0.0'} - - '@smithy/node-http-handler@4.1.1': - resolution: {integrity: sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==} + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.3': - resolution: {integrity: sha512-MAwltrDB0lZB/H6/2M5PIsISSwdI5yIh6DaBB9r0Flo9nx3y0dzl/qTMJPd7tJvPdsx6Ks/cwVzheGNYzXyNbQ==} + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@2.2.0': - resolution: {integrity: sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==} + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/property-provider@4.0.5': - resolution: {integrity: sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==} + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.3': - resolution: {integrity: sha512-+1EZ+Y+njiefCohjlhyOcy1UNYjT+1PwGFHCxA/gYctjg3DQWAU19WigOXAco/Ql8hZokNehpzLd0/+3uCreqQ==} + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@2.0.5': - resolution: {integrity: sha512-d2hhHj34mA2V86doiDfrsy2fNTnUOowGaf9hKb0hIPHqvcnShU4/OSc4Uf1FwHkAdYF3cFXTrj5VGUYbEuvMdw==} - engines: {node: '>=14.0.0'} - - '@smithy/protocol-http@3.3.0': - resolution: {integrity: sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==} - engines: {node: '>=14.0.0'} - - '@smithy/protocol-http@5.1.3': - resolution: {integrity: sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==} + '@smithy/util-defaults-mode-browser@4.3.43': + resolution: {integrity: sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.3': - resolution: {integrity: sha512-Mn7f/1aN2/jecywDcRDvWWWJF4uwg/A0XjFMJtj72DsgHTByfjRltSqcT9NyE9RTdBSN6X1RSXrhn/YWQl8xlw==} + '@smithy/util-defaults-mode-node@4.2.47': + resolution: {integrity: sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@2.2.0': - resolution: {integrity: sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==} - engines: {node: '>=14.0.0'} - - '@smithy/querystring-builder@4.0.5': - resolution: {integrity: sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==} + '@smithy/util-endpoints@3.3.3': + resolution: {integrity: sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.3': - resolution: {integrity: sha512-LOVCGCmwMahYUM/P0YnU/AlDQFjcu+gWbFJooC417QRB/lDJlWSn8qmPSDp+s4YVAHOgtgbNG4sR+SxF/VOcJQ==} + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@2.2.0': - resolution: {integrity: sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==} - engines: {node: '>=14.0.0'} - - '@smithy/querystring-parser@4.0.5': - resolution: {integrity: sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==} + '@smithy/util-middleware@4.2.12': + resolution: {integrity: sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.3': - resolution: {integrity: sha512-cYlSNHcTAX/wc1rpblli3aUlLMGgKZ/Oqn8hhjFASXMCXjIqeuQBei0cnq2JR8t4RtU9FpG6uyl6PxyArTiwKA==} + '@smithy/util-retry@4.2.12': + resolution: {integrity: sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@2.1.5': - resolution: {integrity: sha512-uBDTIBBEdAQryvHdc5W8sS5YX7RQzF683XrHePVdFmAgKiMofU15FLSM0/HU03hKTnazdNRFa0YHS7+ArwoUSQ==} - engines: {node: '>=14.0.0'} - - '@smithy/service-error-classification@4.0.7': - resolution: {integrity: sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==} + '@smithy/util-stream@4.5.20': + resolution: {integrity: sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.3': - resolution: {integrity: sha512-NkxsAxFWwsPsQiwFG2MzJ/T7uIR6AQNh1SzcxSUnmmIqIQMlLRQDKhc17M7IYjiuBXhrQRjQTo3CxX+DobS93g==} + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@2.4.0': - resolution: {integrity: sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==} + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/shared-ini-file-loader@4.0.5': - resolution: {integrity: sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.3.3': - resolution: {integrity: sha512-9f9Ixej0hFhroOK2TxZfUUDR13WVa8tQzhSzPDgXe5jGL3KmaM9s8XN7RQwqtEypI82q9KHnKS71CJ+q/1xLtQ==} + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@2.3.0': - resolution: {integrity: sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==} - engines: {node: '>=14.0.0'} - - '@smithy/signature-v4@5.1.3': - resolution: {integrity: sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==} + '@smithy/util-waiter@4.2.13': + resolution: {integrity: sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.3': - resolution: {integrity: sha512-CmSlUy+eEYbIEYN5N3vvQTRfqt0lJlQkaQUIf+oizu7BbDut0pozfDjBGecfcfWf7c62Yis4JIEgqQ/TCfodaA==} + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@2.5.1': - resolution: {integrity: sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==} - engines: {node: '>=14.0.0'} + '@speed-highlight/core@1.2.7': + resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} - '@smithy/smithy-client@4.4.10': - resolution: {integrity: sha512-iW6HjXqN0oPtRS0NK/zzZ4zZeGESIFcxj2FkWed3mcK8jdSdHzvnCKXSjvewESKAgGKAbJRA+OsaqKhkdYRbQQ==} - engines: {node: '>=18.0.0'} + '@sqltools/formatter@1.2.5': + resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} - '@smithy/smithy-client@4.9.1': - resolution: {integrity: sha512-Ngb95ryR5A9xqvQFT5mAmYkCwbXvoLavLFwmi7zVg/IowFPCfiqRfkOKnbc/ZRL8ZKJ4f+Tp6kSu6wjDQb8L/g==} - engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@smithy/types@2.12.0': - resolution: {integrity: sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==} - engines: {node: '>=14.0.0'} + '@stylistic/eslint-plugin@2.7.2': + resolution: {integrity: sha512-3DVLU5HEuk2pQoBmXJlzvrxbKNpu2mJ0SRqz5O/CJjyNCr12ZiPcYMEtuArTyPOk5i7bsAU44nywh1rGfe3gKQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.40.0' - '@smithy/types@4.3.2': - resolution: {integrity: sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==} - engines: {node: '>=18.0.0'} + '@swc/core-darwin-arm64@1.11.31': + resolution: {integrity: sha512-NTEaYOts0OGSbJZc0O74xsji+64JrF1stmBii6D5EevWEtrY4wlZhm8SiP/qPrOB+HqtAihxWIukWkP2aSdGSQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] - '@smithy/types@4.8.0': - resolution: {integrity: sha512-QpELEHLO8SsQVtqP+MkEgCYTFW0pleGozfs3cZ183ZBj9z3VC1CX1/wtFMK64p+5bhtZo41SeLK1rBRtd25nHQ==} - engines: {node: '>=18.0.0'} + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] - '@smithy/url-parser@2.2.0': - resolution: {integrity: sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==} + '@swc/core-darwin-x64@1.11.31': + resolution: {integrity: sha512-THSGaSwT96JwXDwuXQ6yFBbn+xDMdyw7OmBpnweAWsh5DhZmQkALEm1DgdQO3+rrE99MkmzwAfclc0UmYro/OA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] - '@smithy/url-parser@4.0.5': - resolution: {integrity: sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==} - engines: {node: '>=18.0.0'} + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] - '@smithy/url-parser@4.2.3': - resolution: {integrity: sha512-I066AigYvY3d9VlU3zG9XzZg1yT10aNqvCaBTw9EPgu5GrsEl1aUkcMvhkIXascYH1A8W0LQo3B1Kr1cJNcQEw==} - engines: {node: '>=18.0.0'} + '@swc/core-linux-arm-gnueabihf@1.11.31': + resolution: {integrity: sha512-laKtQFnW7KHgE57Hx32os2SNAogcuIDxYE+3DYIOmDMqD7/1DCfJe6Rln2N9WcOw6HuDbDpyQavIwZNfSAa8vQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] - '@smithy/util-base64@2.3.0': - resolution: {integrity: sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==} - engines: {node: '>=14.0.0'} + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] - '@smithy/util-base64@4.0.0': - resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==} - engines: {node: '>=18.0.0'} + '@swc/core-linux-arm64-gnu@1.11.31': + resolution: {integrity: sha512-T+vGw9aPE1YVyRxRr1n7NAdkbgzBzrXCCJ95xAZc/0+WUwmL77Z+js0J5v1KKTRxw4FvrslNCOXzMWrSLdwPSA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} - engines: {node: '>=18.0.0'} + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] - '@smithy/util-body-length-browser@2.2.0': - resolution: {integrity: sha512-dtpw9uQP7W+n3vOtx0CfBD5EWd7EPdIdsQnWTDoFf77e3VUf05uA7R7TGipIo8e4WL2kuPdnsr3hMQn9ziYj5w==} + '@swc/core-linux-arm64-musl@1.11.31': + resolution: {integrity: sha512-Mztp5NZkyd5MrOAG+kl+QSn0lL4Uawd4CK4J7wm97Hs44N9DHGIG5nOz7Qve1KZo407Y25lTxi/PqzPKHo61zQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] - '@smithy/util-body-length-browser@4.0.0': - resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==} - engines: {node: '>=18.0.0'} + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@2.3.0': - resolution: {integrity: sha512-ITWT1Wqjubf2CJthb0BuT9+bpzBfXeMokH/AAa5EJQgbv9aPMVfnM76iFIZVFf50hYXGbtiV71BHAthNWd6+dw==} - engines: {node: '>=14.0.0'} - - '@smithy/util-body-length-node@4.0.0': - resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-buffer-from@4.0.0': - resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==} - engines: {node: '>=18.0.0'} - - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} - engines: {node: '>=18.0.0'} - - '@smithy/util-config-provider@2.3.0': - resolution: {integrity: sha512-HZkzrRcuFN1k70RLqlNK4FnPXKOpkik1+4JaBoHNJn+RnJGYqaa3c5/+XtLOXhlKzlRgNvyaLieHTW2VwGN0VQ==} - engines: {node: '>=14.0.0'} - - '@smithy/util-config-provider@4.0.0': - resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==} - engines: {node: '>=18.0.0'} - - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-browser@2.2.1': - resolution: {integrity: sha512-RtKW+8j8skk17SYowucwRUjeh4mCtnm5odCL0Lm2NtHQBsYKrNW0od9Rhopu9wF1gHMfHeWF7i90NwBz/U22Kw==} - engines: {node: '>= 10.0.0'} - - '@smithy/util-defaults-mode-browser@4.0.26': - resolution: {integrity: sha512-xgl75aHIS/3rrGp7iTxQAOELYeyiwBu+eEgAk4xfKwJJ0L8VUjhO2shsDpeil54BOFsqmk5xfdesiewbUY5tKQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-browser@4.3.4': - resolution: {integrity: sha512-qI5PJSW52rnutos8Bln8nwQZRpyoSRN6k2ajyoUHNMUzmWqHnOJCnDELJuV6m5PML0VkHI+XcXzdB+6awiqYUw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-node@2.3.1': - resolution: {integrity: sha512-vkMXHQ0BcLFysBMWgSBLSk3+leMpFSyyFj8zQtv5ZyUBx8/owVh1/pPEkzmW/DR/Gy/5c8vjLDD9gZjXNKbrpA==} - engines: {node: '>= 10.0.0'} - - '@smithy/util-defaults-mode-node@4.0.26': - resolution: {integrity: sha512-z81yyIkGiLLYVDetKTUeCZQ8x20EEzvQjrqJtb/mXnevLq2+w3XCEWTJ2pMp401b6BkEkHVfXb/cROBpVauLMQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-node@4.2.6': - resolution: {integrity: sha512-c6M/ceBTm31YdcFpgfgQAJaw3KbaLuRKnAz91iMWFLSrgxRpYm03c3bu5cpYojNMfkV9arCUelelKA7XQT36SQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-endpoints@3.0.7': - resolution: {integrity: sha512-klGBP+RpBp6V5JbrY2C/VKnHXn3d5V2YrifZbmMY8os7M6m8wdYFoO6w/fe5VkP+YVwrEktW3IWYaSQVNZJ8oQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-endpoints@3.2.3': - resolution: {integrity: sha512-aCfxUOVv0CzBIkU10TubdgKSx5uRvzH064kaiPEWfNIvKOtNpu642P4FP1hgOFkjQIkDObrfIDnKMKkeyrejvQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@2.2.0': - resolution: {integrity: sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==} - engines: {node: '>=14.0.0'} - - '@smithy/util-hex-encoding@4.0.0': - resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@2.2.0': - resolution: {integrity: sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==} - engines: {node: '>=14.0.0'} - - '@smithy/util-middleware@4.0.5': - resolution: {integrity: sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.2.3': - resolution: {integrity: sha512-v5ObKlSe8PWUHCqEiX2fy1gNv6goiw6E5I/PN2aXg3Fb/hse0xeaAnSpXDiWl7x6LamVKq7senB+m5LOYHUAHw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@2.2.0': - resolution: {integrity: sha512-q9+pAFPTfftHXRytmZ7GzLFFrEGavqapFc06XxzZFcSIGERXMerXxCitjOG1prVDR9QdjqotF40SWvbqcCpf8g==} - engines: {node: '>= 14.0.0'} - - '@smithy/util-retry@4.0.7': - resolution: {integrity: sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.2.3': - resolution: {integrity: sha512-lLPWnakjC0q9z+OtiXk+9RPQiYPNAovt2IXD3CP4LkOnd9NpUsxOjMx1SnoUVB7Orb7fZp67cQMtTBKMFDvOGg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@2.2.0': - resolution: {integrity: sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-stream@4.2.4': - resolution: {integrity: sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.4': - resolution: {integrity: sha512-+qDxSkiErejw1BAIXUFBSfM5xh3arbz1MmxlbMCKanDDZtVEQ7PSKW9FQS0Vud1eI/kYn0oCTVKyNzRlq+9MUw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@2.2.0': - resolution: {integrity: sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-uri-escape@4.0.0': - resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@4.0.0': - resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==} - engines: {node: '>=18.0.0'} - - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-waiter@2.2.0': - resolution: {integrity: sha512-IHk53BVw6MPMi2Gsn+hCng8rFA3ZmR3Rk7GllxDUW9qFJl/hiSvskn7XldkECapQVkIg/1dHpMAxI9xSTaLLSA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-waiter@4.0.7': - resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==} - engines: {node: '>=18.0.0'} - - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} - engines: {node: '>=18.0.0'} - - '@speed-highlight/core@1.2.7': - resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} - - '@sqltools/formatter@1.2.5': - resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} - - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - - '@stylistic/eslint-plugin@2.7.2': - resolution: {integrity: sha512-3DVLU5HEuk2pQoBmXJlzvrxbKNpu2mJ0SRqz5O/CJjyNCr12ZiPcYMEtuArTyPOk5i7bsAU44nywh1rGfe3gKQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: '>=8.40.0' - - '@swc/core-darwin-arm64@1.11.31': - resolution: {integrity: sha512-NTEaYOts0OGSbJZc0O74xsji+64JrF1stmBii6D5EevWEtrY4wlZhm8SiP/qPrOB+HqtAihxWIukWkP2aSdGSQ==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] - - '@swc/core-darwin-arm64@1.15.18': - resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} - engines: {node: '>=10'} - cpu: [arm64] - os: [darwin] - - '@swc/core-darwin-x64@1.11.31': - resolution: {integrity: sha512-THSGaSwT96JwXDwuXQ6yFBbn+xDMdyw7OmBpnweAWsh5DhZmQkALEm1DgdQO3+rrE99MkmzwAfclc0UmYro/OA==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] - - '@swc/core-darwin-x64@1.15.18': - resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} - engines: {node: '>=10'} - cpu: [x64] - os: [darwin] - - '@swc/core-linux-arm-gnueabihf@1.11.31': - resolution: {integrity: sha512-laKtQFnW7KHgE57Hx32os2SNAogcuIDxYE+3DYIOmDMqD7/1DCfJe6Rln2N9WcOw6HuDbDpyQavIwZNfSAa8vQ==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] - - '@swc/core-linux-arm-gnueabihf@1.15.18': - resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} - engines: {node: '>=10'} - cpu: [arm] - os: [linux] - - '@swc/core-linux-arm64-gnu@1.11.31': - resolution: {integrity: sha512-T+vGw9aPE1YVyRxRr1n7NAdkbgzBzrXCCJ95xAZc/0+WUwmL77Z+js0J5v1KKTRxw4FvrslNCOXzMWrSLdwPSA==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-arm64-gnu@1.15.18': - resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-arm64-musl@1.11.31': - resolution: {integrity: sha512-Mztp5NZkyd5MrOAG+kl+QSn0lL4Uawd4CK4J7wm97Hs44N9DHGIG5nOz7Qve1KZo407Y25lTxi/PqzPKHo61zQ==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-arm64-musl@1.15.18': - resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} - engines: {node: '>=10'} - cpu: [arm64] - os: [linux] - - '@swc/core-linux-x64-gnu@1.11.31': - resolution: {integrity: sha512-DDVE0LZcXOWwOqFU1Xi7gdtiUg3FHA0vbGb3trjWCuI1ZtDZHEQYL4M3/2FjqKZtIwASrDvO96w91okZbXhvMg==} - engines: {node: '>=10'} - cpu: [x64] - os: [linux] + '@swc/core-linux-x64-gnu@1.11.31': + resolution: {integrity: sha512-DDVE0LZcXOWwOqFU1Xi7gdtiUg3FHA0vbGb3trjWCuI1ZtDZHEQYL4M3/2FjqKZtIwASrDvO96w91okZbXhvMg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] '@swc/core-linux-x64-gnu@1.15.18': resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} @@ -4707,9 +4316,6 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@types/uuid@9.0.8': - resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -5156,6 +4762,9 @@ packages: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -5264,6 +4873,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-events@2.5.4: resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} @@ -5324,16 +4937,13 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - body-parser@2.2.0: - resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - bowser@2.12.0: - resolution: {integrity: sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg==} - bowser@2.12.1: resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} @@ -5343,6 +4953,10 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -5447,6 +5061,10 @@ packages: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -5583,6 +5201,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} + engines: {node: '>= 6'} + comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} @@ -5642,10 +5264,6 @@ packages: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -5797,15 +5415,6 @@ packages: supports-color: optional: true - debug@4.3.6: - resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -6520,8 +6129,8 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - express@5.0.1: - resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} exsolve@1.0.5: @@ -6571,16 +6180,15 @@ packages: fast-uri@3.0.3: resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} - fast-xml-parser@4.2.5: - resolution: {integrity: sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==} - hasBin: true + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} fast-xml-parser@4.5.3: resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} hasBin: true - fast-xml-parser@5.2.5: - resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} hasBin: true fastq@1.17.1: @@ -6832,10 +6440,9 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@11.0.3: - resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + glob@12.0.0: + resolution: {integrity: sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==} engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: @@ -6947,8 +6554,8 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} http-link-header@1.1.3: @@ -7002,6 +6609,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + icu-minify@4.8.3: resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==} @@ -7863,10 +7474,6 @@ packages: metaviewport-parser@0.3.0: resolution: {integrity: sha512-EoYJ8xfjQ6kpe9VbVHvZTZHiOl4HL1Z18CrZ+qahvLXT7ZO4YTC2JMyt5FaUp9JJp6J4Ybb/z7IsCXZt86/QkQ==} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -7917,9 +7524,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - minimatch@10.0.3: - resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} - engines: {node: 20 || >=22} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -7983,9 +7590,6 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -8059,8 +7663,8 @@ packages: typescript: optional: true - next@16.2.6: - resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -8372,6 +7976,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.2.0: + resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -8850,12 +8458,8 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} quansync@0.2.10: @@ -8885,9 +8489,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@3.0.0: - resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} - engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -8903,10 +8507,10 @@ packages: peerDependencies: react: '>=16.8.0' - react-dom@19.1.7: - resolution: {integrity: sha512-hE2iTlqZDmmCBRpmXbzHJLngNeHfLtdMA1qp4eDreMgdtbCgFAWGJhvD2u1KF0UQe5W5y2cQkpjAYTGhXbil4A==} + react-dom@19.1.5: + resolution: {integrity: sha512-tvMijysf97vcHla1PNI/aU2apv7f4r0ct0OBk3i3QOBfsVhZzHEuPBLemClkfuw8LroE4FH6kXcQOftf2ntPHQ==} peerDependencies: - react: ^19.1.7 + react: ^19.1.5 react-google-recaptcha@3.1.0: resolution: {integrity: sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==} @@ -8954,8 +8558,8 @@ packages: '@types/react': optional: true - react@19.1.7: - resolution: {integrity: sha512-sExZFfembjCLTr9ran4JS8W2Z9m3d0lbrOAuFreAR8krpw76YnK+lnzlkO4OvFjEuHzKc8rw94h0EAVSh/Gn+w==} + react@19.1.5: + resolution: {integrity: sha512-lCX00zqONdNfcnJYEL91LuNYzyWFU70vKhApUR08Y1Fi/Y5FGw6l6hAWtlkq+k/vnx463XLm/5dyQp5HAJCw6Q==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -9337,6 +8941,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -9437,8 +9045,8 @@ packages: strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - strnum@2.1.1: - resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + strnum@2.2.2: + resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} stubborn-fs@1.2.5: resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} @@ -9976,6 +9584,9 @@ packages: urlpattern-polyfill@10.0.0: resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -10007,18 +9618,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -10426,44 +10029,44 @@ snapshots: lru-cache: 10.4.3 optional: true - '@ast-grep/napi-darwin-arm64@0.35.0': + '@ast-grep/napi-darwin-arm64@0.40.5': optional: true - '@ast-grep/napi-darwin-x64@0.35.0': + '@ast-grep/napi-darwin-x64@0.40.5': optional: true - '@ast-grep/napi-linux-arm64-gnu@0.35.0': + '@ast-grep/napi-linux-arm64-gnu@0.40.5': optional: true - '@ast-grep/napi-linux-arm64-musl@0.35.0': + '@ast-grep/napi-linux-arm64-musl@0.40.5': optional: true - '@ast-grep/napi-linux-x64-gnu@0.35.0': + '@ast-grep/napi-linux-x64-gnu@0.40.5': optional: true - '@ast-grep/napi-linux-x64-musl@0.35.0': + '@ast-grep/napi-linux-x64-musl@0.40.5': optional: true - '@ast-grep/napi-win32-arm64-msvc@0.35.0': + '@ast-grep/napi-win32-arm64-msvc@0.40.5': optional: true - '@ast-grep/napi-win32-ia32-msvc@0.35.0': + '@ast-grep/napi-win32-ia32-msvc@0.40.5': optional: true - '@ast-grep/napi-win32-x64-msvc@0.35.0': + '@ast-grep/napi-win32-x64-msvc@0.40.5': optional: true - '@ast-grep/napi@0.35.0': + '@ast-grep/napi@0.40.5': optionalDependencies: - '@ast-grep/napi-darwin-arm64': 0.35.0 - '@ast-grep/napi-darwin-x64': 0.35.0 - '@ast-grep/napi-linux-arm64-gnu': 0.35.0 - '@ast-grep/napi-linux-arm64-musl': 0.35.0 - '@ast-grep/napi-linux-x64-gnu': 0.35.0 - '@ast-grep/napi-linux-x64-musl': 0.35.0 - '@ast-grep/napi-win32-arm64-msvc': 0.35.0 - '@ast-grep/napi-win32-ia32-msvc': 0.35.0 - '@ast-grep/napi-win32-x64-msvc': 0.35.0 + '@ast-grep/napi-darwin-arm64': 0.40.5 + '@ast-grep/napi-darwin-x64': 0.40.5 + '@ast-grep/napi-linux-arm64-gnu': 0.40.5 + '@ast-grep/napi-linux-arm64-musl': 0.40.5 + '@ast-grep/napi-linux-x64-gnu': 0.40.5 + '@ast-grep/napi-linux-x64-musl': 0.40.5 + '@ast-grep/napi-win32-arm64-msvc': 0.40.5 + '@ast-grep/napi-win32-ia32-msvc': 0.40.5 + '@ast-grep/napi-win32-x64-msvc': 0.40.5 '@auth/core@0.41.0': dependencies: @@ -10476,976 +10079,674 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 + '@aws-sdk/types': 3.973.6 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 + '@aws-sdk/types': 3.973.6 tslib: 2.8.1 - '@aws-crypto/ie11-detection@3.0.0': - dependencies: - tslib: 1.14.1 - '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-locate-window': 3.804.0 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-locate-window': 3.893.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-crypto/sha256-browser@3.0.0': - dependencies: - '@aws-crypto/ie11-detection': 3.0.0 - '@aws-crypto/sha256-js': 3.0.0 - '@aws-crypto/supports-web-crypto': 3.0.0 - '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-locate-window': 3.804.0 - '@aws-sdk/util-utf8-browser': 3.259.0 - tslib: 1.14.1 - '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 + '@aws-sdk/types': 3.973.6 '@aws-sdk/util-locate-window': 3.893.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-crypto/sha256-js@3.0.0': - dependencies: - '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.862.0 - tslib: 1.14.1 - '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 + '@aws-sdk/types': 3.973.6 tslib: 2.8.1 - '@aws-crypto/supports-web-crypto@3.0.0': - dependencies: - tslib: 1.14.1 - '@aws-crypto/supports-web-crypto@5.2.0': dependencies: tslib: 2.8.1 - '@aws-crypto/util@3.0.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-utf8-browser': 3.259.0 - tslib: 1.14.1 - '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/client-cloudfront@3.398.0': - dependencies: - '@aws-crypto/sha256-browser': 3.0.0 - '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.398.0 - '@aws-sdk/credential-provider-node': 3.398.0 - '@aws-sdk/middleware-host-header': 3.398.0 - '@aws-sdk/middleware-logger': 3.398.0 - '@aws-sdk/middleware-recursion-detection': 3.398.0 - '@aws-sdk/middleware-signing': 3.398.0 - '@aws-sdk/middleware-user-agent': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@aws-sdk/util-endpoints': 3.398.0 - '@aws-sdk/util-user-agent-browser': 3.398.0 - '@aws-sdk/util-user-agent-node': 3.398.0 - '@aws-sdk/xml-builder': 3.310.0 - '@smithy/config-resolver': 2.2.0 - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/hash-node': 2.2.0 - '@smithy/invalid-dependency': 2.2.0 - '@smithy/middleware-content-length': 2.2.0 - '@smithy/middleware-endpoint': 2.5.1 - '@smithy/middleware-retry': 2.3.1 - '@smithy/middleware-serde': 2.3.0 - '@smithy/middleware-stack': 2.2.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/node-http-handler': 2.5.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - '@smithy/util-base64': 2.3.0 - '@smithy/util-body-length-browser': 2.2.0 - '@smithy/util-body-length-node': 2.3.0 - '@smithy/util-defaults-mode-browser': 2.2.1 - '@smithy/util-defaults-mode-node': 2.3.1 - '@smithy/util-retry': 2.2.0 - '@smithy/util-stream': 2.2.0 + '@aws-sdk/types': 3.973.6 '@smithy/util-utf8': 2.3.0 - '@smithy/util-waiter': 2.2.0 - fast-xml-parser: 4.2.5 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-dynamodb@3.868.0': + '@aws-sdk/client-cloudfront@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/credential-provider-node': 3.864.0 - '@aws-sdk/middleware-endpoint-discovery': 3.862.0 - '@aws-sdk/middleware-host-header': 3.862.0 - '@aws-sdk/middleware-logger': 3.862.0 - '@aws-sdk/middleware-recursion-detection': 3.862.0 - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/region-config-resolver': 3.862.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.862.0 - '@aws-sdk/util-user-agent-browser': 3.862.0 - '@aws-sdk/util-user-agent-node': 3.864.0 - '@smithy/config-resolver': 4.1.5 - '@smithy/core': 3.8.0 - '@smithy/fetch-http-handler': 5.1.1 - '@smithy/hash-node': 4.0.5 - '@smithy/invalid-dependency': 4.0.5 - '@smithy/middleware-content-length': 4.0.5 - '@smithy/middleware-endpoint': 4.1.18 - '@smithy/middleware-retry': 4.1.19 - '@smithy/middleware-serde': 4.0.9 - '@smithy/middleware-stack': 4.0.5 - '@smithy/node-config-provider': 4.1.4 - '@smithy/node-http-handler': 4.1.1 - '@smithy/protocol-http': 5.1.3 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - '@smithy/util-base64': 4.0.0 - '@smithy/util-body-length-browser': 4.0.0 - '@smithy/util-body-length-node': 4.0.0 - '@smithy/util-defaults-mode-browser': 4.0.26 - '@smithy/util-defaults-mode-node': 4.0.26 - '@smithy/util-endpoints': 3.0.7 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-retry': 4.0.7 - '@smithy/util-utf8': 4.0.0 - '@smithy/util-waiter': 4.0.7 - '@types/uuid': 9.0.8 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/credential-provider-node': 3.972.25 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.984.0 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.11 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 tslib: 2.8.1 - uuid: 9.0.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-lambda@3.865.0': + '@aws-sdk/client-dynamodb@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/credential-provider-node': 3.864.0 - '@aws-sdk/middleware-host-header': 3.862.0 - '@aws-sdk/middleware-logger': 3.862.0 - '@aws-sdk/middleware-recursion-detection': 3.862.0 - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/region-config-resolver': 3.862.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.862.0 - '@aws-sdk/util-user-agent-browser': 3.862.0 - '@aws-sdk/util-user-agent-node': 3.864.0 - '@smithy/config-resolver': 4.1.5 - '@smithy/core': 3.8.0 - '@smithy/eventstream-serde-browser': 4.0.5 - '@smithy/eventstream-serde-config-resolver': 4.1.3 - '@smithy/eventstream-serde-node': 4.0.5 - '@smithy/fetch-http-handler': 5.1.1 - '@smithy/hash-node': 4.0.5 - '@smithy/invalid-dependency': 4.0.5 - '@smithy/middleware-content-length': 4.0.5 - '@smithy/middleware-endpoint': 4.1.18 - '@smithy/middleware-retry': 4.1.19 - '@smithy/middleware-serde': 4.0.9 - '@smithy/middleware-stack': 4.0.5 - '@smithy/node-config-provider': 4.1.4 - '@smithy/node-http-handler': 4.1.1 - '@smithy/protocol-http': 5.1.3 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - '@smithy/util-base64': 4.0.0 - '@smithy/util-body-length-browser': 4.0.0 - '@smithy/util-body-length-node': 4.0.0 - '@smithy/util-defaults-mode-browser': 4.0.26 - '@smithy/util-defaults-mode-node': 4.0.26 - '@smithy/util-endpoints': 3.0.7 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-retry': 4.0.7 - '@smithy/util-stream': 4.2.4 - '@smithy/util-utf8': 4.0.0 - '@smithy/util-waiter': 4.0.7 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/credential-provider-node': 3.972.25 + '@aws-sdk/dynamodb-codec': 3.972.25 + '@aws-sdk/middleware-endpoint-discovery': 3.972.8 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.984.0 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.11 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-s3@3.864.0': + '@aws-sdk/client-lambda@3.984.0': dependencies: - '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/credential-provider-node': 3.864.0 - '@aws-sdk/middleware-bucket-endpoint': 3.862.0 - '@aws-sdk/middleware-expect-continue': 3.862.0 - '@aws-sdk/middleware-flexible-checksums': 3.864.0 - '@aws-sdk/middleware-host-header': 3.862.0 - '@aws-sdk/middleware-location-constraint': 3.862.0 - '@aws-sdk/middleware-logger': 3.862.0 - '@aws-sdk/middleware-recursion-detection': 3.862.0 - '@aws-sdk/middleware-sdk-s3': 3.864.0 - '@aws-sdk/middleware-ssec': 3.862.0 - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/region-config-resolver': 3.862.0 - '@aws-sdk/signature-v4-multi-region': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.862.0 - '@aws-sdk/util-user-agent-browser': 3.862.0 - '@aws-sdk/util-user-agent-node': 3.864.0 - '@aws-sdk/xml-builder': 3.862.0 - '@smithy/config-resolver': 4.1.5 - '@smithy/core': 3.8.0 - '@smithy/eventstream-serde-browser': 4.0.5 - '@smithy/eventstream-serde-config-resolver': 4.1.3 - '@smithy/eventstream-serde-node': 4.0.5 - '@smithy/fetch-http-handler': 5.1.1 - '@smithy/hash-blob-browser': 4.0.5 - '@smithy/hash-node': 4.0.5 - '@smithy/hash-stream-node': 4.0.5 - '@smithy/invalid-dependency': 4.0.5 - '@smithy/md5-js': 4.0.5 - '@smithy/middleware-content-length': 4.0.5 - '@smithy/middleware-endpoint': 4.1.18 - '@smithy/middleware-retry': 4.1.19 - '@smithy/middleware-serde': 4.0.9 - '@smithy/middleware-stack': 4.0.5 - '@smithy/node-config-provider': 4.1.4 - '@smithy/node-http-handler': 4.1.1 - '@smithy/protocol-http': 5.1.3 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - '@smithy/util-base64': 4.0.0 - '@smithy/util-body-length-browser': 4.0.0 - '@smithy/util-body-length-node': 4.0.0 - '@smithy/util-defaults-mode-browser': 4.0.26 - '@smithy/util-defaults-mode-node': 4.0.26 - '@smithy/util-endpoints': 3.0.7 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-retry': 4.0.7 - '@smithy/util-stream': 4.2.4 - '@smithy/util-utf8': 4.0.0 - '@smithy/util-waiter': 4.0.7 - '@types/uuid': 9.0.8 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/credential-provider-node': 3.972.25 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.984.0 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.11 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/eventstream-serde-browser': 4.2.12 + '@smithy/eventstream-serde-config-resolver': 4.3.12 + '@smithy/eventstream-serde-node': 4.2.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 tslib: 2.8.1 - uuid: 9.0.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sqs@3.864.0': + '@aws-sdk/client-s3@3.984.0': dependencies: + '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/credential-provider-node': 3.864.0 - '@aws-sdk/middleware-host-header': 3.862.0 - '@aws-sdk/middleware-logger': 3.862.0 - '@aws-sdk/middleware-recursion-detection': 3.862.0 - '@aws-sdk/middleware-sdk-sqs': 3.862.0 - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/region-config-resolver': 3.862.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.862.0 - '@aws-sdk/util-user-agent-browser': 3.862.0 - '@aws-sdk/util-user-agent-node': 3.864.0 - '@smithy/config-resolver': 4.1.5 - '@smithy/core': 3.8.0 - '@smithy/fetch-http-handler': 5.1.1 - '@smithy/hash-node': 4.0.5 - '@smithy/invalid-dependency': 4.0.5 - '@smithy/md5-js': 4.0.5 - '@smithy/middleware-content-length': 4.0.5 - '@smithy/middleware-endpoint': 4.1.18 - '@smithy/middleware-retry': 4.1.19 - '@smithy/middleware-serde': 4.0.9 - '@smithy/middleware-stack': 4.0.5 - '@smithy/node-config-provider': 4.1.4 - '@smithy/node-http-handler': 4.1.1 - '@smithy/protocol-http': 5.1.3 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - '@smithy/util-base64': 4.0.0 - '@smithy/util-body-length-browser': 4.0.0 - '@smithy/util-body-length-node': 4.0.0 - '@smithy/util-defaults-mode-browser': 4.0.26 - '@smithy/util-defaults-mode-node': 4.0.26 - '@smithy/util-endpoints': 3.0.7 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-retry': 4.0.7 - '@smithy/util-utf8': 4.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sso@3.398.0': - dependencies: - '@aws-crypto/sha256-browser': 3.0.0 - '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/middleware-host-header': 3.398.0 - '@aws-sdk/middleware-logger': 3.398.0 - '@aws-sdk/middleware-recursion-detection': 3.398.0 - '@aws-sdk/middleware-user-agent': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@aws-sdk/util-endpoints': 3.398.0 - '@aws-sdk/util-user-agent-browser': 3.398.0 - '@aws-sdk/util-user-agent-node': 3.398.0 - '@smithy/config-resolver': 2.2.0 - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/hash-node': 2.2.0 - '@smithy/invalid-dependency': 2.2.0 - '@smithy/middleware-content-length': 2.2.0 - '@smithy/middleware-endpoint': 2.5.1 - '@smithy/middleware-retry': 2.3.1 - '@smithy/middleware-serde': 2.3.0 - '@smithy/middleware-stack': 2.2.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/node-http-handler': 2.5.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - '@smithy/util-base64': 2.3.0 - '@smithy/util-body-length-browser': 2.2.0 - '@smithy/util-body-length-node': 2.3.0 - '@smithy/util-defaults-mode-browser': 2.2.1 - '@smithy/util-defaults-mode-node': 2.3.1 - '@smithy/util-retry': 2.2.0 - '@smithy/util-utf8': 2.3.0 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/credential-provider-node': 3.972.25 + '@aws-sdk/middleware-bucket-endpoint': 3.972.8 + '@aws-sdk/middleware-expect-continue': 3.972.8 + '@aws-sdk/middleware-flexible-checksums': 3.974.4 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-location-constraint': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-sdk-s3': 3.972.24 + '@aws-sdk/middleware-ssec': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/signature-v4-multi-region': 3.984.0 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.984.0 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.11 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/eventstream-serde-browser': 4.2.12 + '@smithy/eventstream-serde-config-resolver': 4.3.12 + '@smithy/eventstream-serde-node': 4.2.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-blob-browser': 4.2.13 + '@smithy/hash-node': 4.2.12 + '@smithy/hash-stream-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/md5-js': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.864.0': + '@aws-sdk/client-sqs@3.984.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/middleware-host-header': 3.862.0 - '@aws-sdk/middleware-logger': 3.862.0 - '@aws-sdk/middleware-recursion-detection': 3.862.0 - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/region-config-resolver': 3.862.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.862.0 - '@aws-sdk/util-user-agent-browser': 3.862.0 - '@aws-sdk/util-user-agent-node': 3.864.0 - '@smithy/config-resolver': 4.1.5 - '@smithy/core': 3.8.0 - '@smithy/fetch-http-handler': 5.1.1 - '@smithy/hash-node': 4.0.5 - '@smithy/invalid-dependency': 4.0.5 - '@smithy/middleware-content-length': 4.0.5 - '@smithy/middleware-endpoint': 4.1.18 - '@smithy/middleware-retry': 4.1.19 - '@smithy/middleware-serde': 4.0.9 - '@smithy/middleware-stack': 4.0.5 - '@smithy/node-config-provider': 4.1.4 - '@smithy/node-http-handler': 4.1.1 - '@smithy/protocol-http': 5.1.3 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - '@smithy/util-base64': 4.0.0 - '@smithy/util-body-length-browser': 4.0.0 - '@smithy/util-body-length-node': 4.0.0 - '@smithy/util-defaults-mode-browser': 4.0.26 - '@smithy/util-defaults-mode-node': 4.0.26 - '@smithy/util-endpoints': 3.0.7 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-retry': 4.0.7 - '@smithy/util-utf8': 4.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sts@3.398.0': - dependencies: - '@aws-crypto/sha256-browser': 3.0.0 - '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/credential-provider-node': 3.398.0 - '@aws-sdk/middleware-host-header': 3.398.0 - '@aws-sdk/middleware-logger': 3.398.0 - '@aws-sdk/middleware-recursion-detection': 3.398.0 - '@aws-sdk/middleware-sdk-sts': 3.398.0 - '@aws-sdk/middleware-signing': 3.398.0 - '@aws-sdk/middleware-user-agent': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@aws-sdk/util-endpoints': 3.398.0 - '@aws-sdk/util-user-agent-browser': 3.398.0 - '@aws-sdk/util-user-agent-node': 3.398.0 - '@smithy/config-resolver': 2.2.0 - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/hash-node': 2.2.0 - '@smithy/invalid-dependency': 2.2.0 - '@smithy/middleware-content-length': 2.2.0 - '@smithy/middleware-endpoint': 2.5.1 - '@smithy/middleware-retry': 2.3.1 - '@smithy/middleware-serde': 2.3.0 - '@smithy/middleware-stack': 2.2.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/node-http-handler': 2.5.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - '@smithy/util-base64': 2.3.0 - '@smithy/util-body-length-browser': 2.2.0 - '@smithy/util-body-length-node': 2.3.0 - '@smithy/util-defaults-mode-browser': 2.2.1 - '@smithy/util-defaults-mode-node': 2.3.1 - '@smithy/util-retry': 2.2.0 - '@smithy/util-utf8': 2.3.0 - fast-xml-parser: 4.2.5 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/credential-provider-node': 3.972.25 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-sdk-sqs': 3.972.17 + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.984.0 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.11 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/md5-js': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.864.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@aws-sdk/xml-builder': 3.862.0 - '@smithy/core': 3.17.1 - '@smithy/node-config-provider': 4.3.3 - '@smithy/property-provider': 4.2.3 - '@smithy/protocol-http': 5.3.3 - '@smithy/signature-v4': 5.3.3 - '@smithy/smithy-client': 4.9.1 - '@smithy/types': 4.8.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.3 - '@smithy/util-utf8': 4.2.0 - fast-xml-parser: 5.2.5 + '@aws-sdk/core@3.973.24': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws-sdk/xml-builder': 3.972.15 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.398.0': + '@aws-sdk/crc64-nvme@3.972.5': dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/property-provider': 2.2.0 - '@smithy/types': 2.12.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.864.0': + '@aws-sdk/credential-provider-env@3.972.22': dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.0.5 - '@smithy/types': 4.3.2 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.864.0': - dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/fetch-http-handler': 5.1.1 - '@smithy/node-http-handler': 4.1.1 - '@smithy/property-provider': 4.0.5 - '@smithy/protocol-http': 5.1.3 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/util-stream': 4.2.4 + '@aws-sdk/credential-provider-http@3.972.24': + dependencies: + '@aws-sdk/core': 3.973.24 + '@aws-sdk/types': 3.973.6 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.398.0': - dependencies: - '@aws-sdk/credential-provider-env': 3.398.0 - '@aws-sdk/credential-provider-process': 3.398.0 - '@aws-sdk/credential-provider-sso': 3.398.0 - '@aws-sdk/credential-provider-web-identity': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@smithy/credential-provider-imds': 2.3.0 - '@smithy/property-provider': 2.2.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 + '@aws-sdk/credential-provider-ini@3.972.24': + dependencies: + '@aws-sdk/core': 3.973.24 + '@aws-sdk/credential-provider-env': 3.972.22 + '@aws-sdk/credential-provider-http': 3.972.24 + '@aws-sdk/credential-provider-login': 3.972.24 + '@aws-sdk/credential-provider-process': 3.972.22 + '@aws-sdk/credential-provider-sso': 3.972.24 + '@aws-sdk/credential-provider-web-identity': 3.972.24 + '@aws-sdk/nested-clients': 3.996.14 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-ini@3.864.0': - dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/credential-provider-env': 3.864.0 - '@aws-sdk/credential-provider-http': 3.864.0 - '@aws-sdk/credential-provider-process': 3.864.0 - '@aws-sdk/credential-provider-sso': 3.864.0 - '@aws-sdk/credential-provider-web-identity': 3.864.0 - '@aws-sdk/nested-clients': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/credential-provider-imds': 4.0.7 - '@smithy/property-provider': 4.0.5 - '@smithy/shared-ini-file-loader': 4.0.5 - '@smithy/types': 4.3.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.398.0': - dependencies: - '@aws-sdk/credential-provider-env': 3.398.0 - '@aws-sdk/credential-provider-ini': 3.398.0 - '@aws-sdk/credential-provider-process': 3.398.0 - '@aws-sdk/credential-provider-sso': 3.398.0 - '@aws-sdk/credential-provider-web-identity': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@smithy/credential-provider-imds': 2.3.0 - '@smithy/property-provider': 2.2.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 + '@aws-sdk/credential-provider-login@3.972.24': + dependencies: + '@aws-sdk/core': 3.973.24 + '@aws-sdk/nested-clients': 3.996.14 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.864.0': - dependencies: - '@aws-sdk/credential-provider-env': 3.864.0 - '@aws-sdk/credential-provider-http': 3.864.0 - '@aws-sdk/credential-provider-ini': 3.864.0 - '@aws-sdk/credential-provider-process': 3.864.0 - '@aws-sdk/credential-provider-sso': 3.864.0 - '@aws-sdk/credential-provider-web-identity': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/credential-provider-imds': 4.0.7 - '@smithy/property-provider': 4.0.5 - '@smithy/shared-ini-file-loader': 4.0.5 - '@smithy/types': 4.3.2 + '@aws-sdk/credential-provider-node@3.972.25': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.22 + '@aws-sdk/credential-provider-http': 3.972.24 + '@aws-sdk/credential-provider-ini': 3.972.24 + '@aws-sdk/credential-provider-process': 3.972.22 + '@aws-sdk/credential-provider-sso': 3.972.24 + '@aws-sdk/credential-provider-web-identity': 3.972.24 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.398.0': - dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/property-provider': 2.2.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-process@3.864.0': + '@aws-sdk/credential-provider-process@3.972.22': dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.0.5 - '@smithy/shared-ini-file-loader': 4.0.5 - '@smithy/types': 4.3.2 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.398.0': + '@aws-sdk/credential-provider-sso@3.972.24': dependencies: - '@aws-sdk/client-sso': 3.398.0 - '@aws-sdk/token-providers': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@smithy/property-provider': 2.2.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/nested-clients': 3.996.14 + '@aws-sdk/token-providers': 3.1015.0 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-sso@3.864.0': + '@aws-sdk/credential-provider-web-identity@3.972.24': dependencies: - '@aws-sdk/client-sso': 3.864.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/token-providers': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.0.5 - '@smithy/shared-ini-file-loader': 4.0.5 - '@smithy/types': 4.3.2 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/nested-clients': 3.996.14 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.398.0': + '@aws-sdk/dynamodb-codec@3.972.25': dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/property-provider': 2.2.0 - '@smithy/types': 2.12.0 + '@aws-sdk/core': 3.973.24 + '@smithy/core': 3.23.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.864.0': - dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/nested-clients': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.2.3 - '@smithy/types': 4.8.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/endpoint-cache@3.804.0': + '@aws-sdk/endpoint-cache@3.972.4': dependencies: mnemonist: 0.38.3 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.862.0': + '@aws-sdk/middleware-bucket-endpoint@3.972.8': dependencies: - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-arn-parser': 3.804.0 - '@smithy/node-config-provider': 4.1.4 - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 - '@smithy/util-config-provider': 4.0.0 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-endpoint-discovery@3.862.0': + '@aws-sdk/middleware-endpoint-discovery@3.972.8': dependencies: - '@aws-sdk/endpoint-cache': 3.804.0 - '@aws-sdk/types': 3.862.0 - '@smithy/node-config-provider': 4.1.4 - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 + '@aws-sdk/endpoint-cache': 3.972.4 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-expect-continue@3.862.0': + '@aws-sdk/middleware-expect-continue@3.972.8': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.864.0': + '@aws-sdk/middleware-flexible-checksums@3.974.4': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/is-array-buffer': 4.0.0 - '@smithy/node-config-provider': 4.1.4 - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-stream': 4.2.4 - '@smithy/util-utf8': 4.0.0 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/crc64-nvme': 3.972.5 + '@aws-sdk/types': 3.973.6 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.398.0': + '@aws-sdk/middleware-host-header@3.972.8': dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/types': 2.12.0 + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.862.0': + '@aws-sdk/middleware-location-constraint@3.972.8': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-location-constraint@3.862.0': + '@aws-sdk/middleware-logger@3.972.8': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.3.2 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.398.0': + '@aws-sdk/middleware-recursion-detection@3.972.8': dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/types': 2.12.0 + '@aws-sdk/types': 3.973.6 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.24': + dependencies: + '@aws-sdk/core': 3.973.24 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.862.0': + '@aws-sdk/middleware-sdk-sqs@3.972.17': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.8.0 + '@aws-sdk/types': 3.973.6 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.398.0': + '@aws-sdk/middleware-ssec@3.972.8': dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/types': 2.12.0 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.862.0': + '@aws-sdk/middleware-user-agent@3.972.25': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-retry': 4.2.12 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.864.0': - dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-arn-parser': 3.804.0 - '@smithy/core': 3.8.0 - '@smithy/node-config-provider': 4.1.4 - '@smithy/protocol-http': 5.1.3 - '@smithy/signature-v4': 5.1.3 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/util-config-provider': 4.0.0 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-stream': 4.2.4 - '@smithy/util-utf8': 4.0.0 + '@aws-sdk/nested-clients@3.996.14': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/region-config-resolver': 3.972.9 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.11 + '@smithy/config-resolver': 4.4.13 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-retry': 4.4.44 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.43 + '@smithy/util-defaults-mode-node': 4.2.47 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/middleware-sdk-sqs@3.862.0': + '@aws-sdk/region-config-resolver@3.972.9': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/util-hex-encoding': 4.0.0 - '@smithy/util-utf8': 4.0.0 + '@aws-sdk/types': 3.973.6 + '@smithy/config-resolver': 4.4.13 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-sts@3.398.0': + '@aws-sdk/signature-v4-multi-region@3.984.0': dependencies: - '@aws-sdk/middleware-signing': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@smithy/types': 2.12.0 + '@aws-sdk/middleware-sdk-s3': 3.972.24 + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-signing@3.398.0': + '@aws-sdk/token-providers@3.1015.0': dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/property-provider': 2.2.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/signature-v4': 2.3.0 - '@smithy/types': 2.12.0 - '@smithy/util-middleware': 2.2.0 + '@aws-sdk/core': 3.973.24 + '@aws-sdk/nested-clients': 3.996.14 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - '@aws-sdk/middleware-ssec@3.862.0': + '@aws-sdk/types@3.973.6': dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.3.2 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.398.0': + '@aws-sdk/util-arn-parser@3.972.3': dependencies: - '@aws-sdk/types': 3.398.0 - '@aws-sdk/util-endpoints': 3.398.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/types': 2.12.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.864.0': + '@aws-sdk/util-endpoints@3.984.0': dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.862.0 - '@smithy/core': 3.17.1 - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-endpoints': 3.3.3 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.864.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.864.0 - '@aws-sdk/middleware-host-header': 3.862.0 - '@aws-sdk/middleware-logger': 3.862.0 - '@aws-sdk/middleware-recursion-detection': 3.862.0 - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/region-config-resolver': 3.862.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.862.0 - '@aws-sdk/util-user-agent-browser': 3.862.0 - '@aws-sdk/util-user-agent-node': 3.864.0 - '@smithy/config-resolver': 4.4.0 - '@smithy/core': 3.17.1 - '@smithy/fetch-http-handler': 5.3.4 - '@smithy/hash-node': 4.2.3 - '@smithy/invalid-dependency': 4.2.3 - '@smithy/middleware-content-length': 4.2.3 - '@smithy/middleware-endpoint': 4.3.5 - '@smithy/middleware-retry': 4.4.5 - '@smithy/middleware-serde': 4.2.3 - '@smithy/middleware-stack': 4.2.3 - '@smithy/node-config-provider': 4.3.3 - '@smithy/node-http-handler': 4.4.3 - '@smithy/protocol-http': 5.3.3 - '@smithy/smithy-client': 4.9.1 - '@smithy/types': 4.8.0 - '@smithy/url-parser': 4.2.3 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.4 - '@smithy/util-defaults-mode-node': 4.2.6 - '@smithy/util-endpoints': 3.2.3 - '@smithy/util-middleware': 4.2.3 - '@smithy/util-retry': 4.2.3 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/region-config-resolver@3.862.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/node-config-provider': 4.3.3 - '@smithy/types': 4.8.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-middleware': 4.2.3 - tslib: 2.8.1 - - '@aws-sdk/signature-v4-multi-region@3.864.0': - dependencies: - '@aws-sdk/middleware-sdk-s3': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.1.3 - '@smithy/signature-v4': 5.1.3 - '@smithy/types': 4.3.2 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.398.0': - dependencies: - '@aws-crypto/sha256-browser': 3.0.0 - '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/middleware-host-header': 3.398.0 - '@aws-sdk/middleware-logger': 3.398.0 - '@aws-sdk/middleware-recursion-detection': 3.398.0 - '@aws-sdk/middleware-user-agent': 3.398.0 - '@aws-sdk/types': 3.398.0 - '@aws-sdk/util-endpoints': 3.398.0 - '@aws-sdk/util-user-agent-browser': 3.398.0 - '@aws-sdk/util-user-agent-node': 3.398.0 - '@smithy/config-resolver': 2.2.0 - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/hash-node': 2.2.0 - '@smithy/invalid-dependency': 2.2.0 - '@smithy/middleware-content-length': 2.2.0 - '@smithy/middleware-endpoint': 2.5.1 - '@smithy/middleware-retry': 2.3.1 - '@smithy/middleware-serde': 2.3.0 - '@smithy/middleware-stack': 2.2.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/node-http-handler': 2.5.0 - '@smithy/property-provider': 2.2.0 - '@smithy/protocol-http': 2.0.5 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - '@smithy/util-base64': 2.3.0 - '@smithy/util-body-length-browser': 2.2.0 - '@smithy/util-body-length-node': 2.3.0 - '@smithy/util-defaults-mode-browser': 2.2.1 - '@smithy/util-defaults-mode-node': 2.3.1 - '@smithy/util-retry': 2.2.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.864.0': - dependencies: - '@aws-sdk/core': 3.864.0 - '@aws-sdk/nested-clients': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.0.5 - '@smithy/shared-ini-file-loader': 4.0.5 - '@smithy/types': 4.3.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/types@3.398.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - - '@aws-sdk/types@3.862.0': - dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 - - '@aws-sdk/util-arn-parser@3.804.0': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.398.0': - dependencies: - '@aws-sdk/types': 3.398.0 - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.862.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.8.0 - '@smithy/url-parser': 4.2.3 - '@smithy/util-endpoints': 3.2.3 - tslib: 2.8.1 - - '@aws-sdk/util-locate-window@3.804.0': + '@aws-sdk/util-endpoints@3.996.5': dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-endpoints': 3.3.3 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.893.0': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.398.0': + '@aws-sdk/util-user-agent-browser@3.972.8': dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/types': 2.12.0 - bowser: 2.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-browser@3.862.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.8.0 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 bowser: 2.12.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.398.0': - dependencies: - '@aws-sdk/types': 3.398.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.864.0': - dependencies: - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/node-config-provider': 4.3.3 - '@smithy/types': 4.8.0 - tslib: 2.8.1 - - '@aws-sdk/util-utf8-browser@3.259.0': + '@aws-sdk/util-user-agent-node@3.973.11': dependencies: + '@aws-sdk/middleware-user-agent': 3.972.25 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.310.0': + '@aws-sdk/xml-builder@3.972.15': dependencies: + '@smithy/types': 4.13.1 + fast-xml-parser: 5.5.8 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.862.0': - dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@babel/code-frame@7.27.1': dependencies: @@ -11772,17 +11073,17 @@ snapshots: '@c15t/logger@1.0.1': dependencies: - chalk: 5.4.1 + chalk: 5.6.2 neverthrow: 8.2.0 picocolors: 1.1.1 - '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': + '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': dependencies: - '@c15t/react': 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) + '@c15t/react': 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@c15t/translations': 1.8.0 - next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -11824,16 +11125,16 @@ snapshots: - use-sync-external-store - ws - '@c15t/react@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': + '@c15t/react@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': dependencies: - '@radix-ui/react-accordion': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-switch': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - c15t: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) + '@radix-ui/react-accordion': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-switch': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + c15t: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) clsx: 2.1.1 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) - zustand: 5.0.8(@types/react@19.2.7)(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) + zustand: 5.0.8(@types/react@19.2.7)(react@19.1.5) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -12076,10 +11377,10 @@ snapshots: '@conform-to/dom@1.6.1': {} - '@conform-to/react@1.6.1(react@19.1.7)': + '@conform-to/react@1.6.1(react@19.1.5)': dependencies: '@conform-to/dom': 1.6.1 - react: 19.1.7 + react: 19.1.5 '@conform-to/zod@1.6.1(zod@3.25.51)': dependencies: @@ -12358,7 +11659,7 @@ snapshots: '@dotenvx/dotenvx@1.31.0': dependencies: commander: 11.1.0 - dotenv: 16.5.0 + dotenv: 16.6.1 eciesjs: 0.4.15 execa: 5.1.1 fdir: 6.4.6(picomatch@4.0.2) @@ -12585,11 +11886,11 @@ snapshots: '@floating-ui/core': 1.7.1 '@floating-ui/utils': 0.2.9 - '@floating-ui/react-dom@2.1.3(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@floating-ui/react-dom@2.1.3(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@floating-ui/dom': 1.7.1 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) '@floating-ui/utils@0.2.9': {} @@ -12690,9 +11991,9 @@ snapshots: '@iarna/toml@2.2.5': {} - '@icons-pack/react-simple-icons@11.2.0(react@19.1.7)': + '@icons-pack/react-simple-icons@11.2.0(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 '@img/colour@1.1.0': optional: true @@ -12982,12 +12283,6 @@ snapshots: optionalDependencies: '@types/node': 22.15.30 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -13306,7 +12601,7 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@16.2.6': {} + '@next/env@16.1.6': {} '@next/eslint-plugin-next@15.3.3': dependencies: @@ -13316,28 +12611,28 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.6': + '@next/swc-darwin-arm64@16.1.6': optional: true - '@next/swc-darwin-x64@16.2.6': + '@next/swc-darwin-x64@16.1.6': optional: true - '@next/swc-linux-arm64-gnu@16.2.6': + '@next/swc-linux-arm64-gnu@16.1.6': optional: true - '@next/swc-linux-arm64-musl@16.2.6': + '@next/swc-linux-arm64-musl@16.1.6': optional: true - '@next/swc-linux-x64-gnu@16.2.6': + '@next/swc-linux-x64-gnu@16.1.6': optional: true - '@next/swc-linux-x64-musl@16.2.6': + '@next/swc-linux-x64-musl@16.1.6': optional: true - '@next/swc-win32-arm64-msvc@16.2.6': + '@next/swc-win32-arm64-msvc@16.1.6': optional: true - '@next/swc-win32-x64-msvc@16.2.6': + '@next/swc-win32-x64-msvc@16.1.6': optional: true '@noble/ciphers@1.3.0': {} @@ -13386,36 +12681,40 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opennextjs/aws@3.7.6': + '@opennextjs/aws@3.9.16(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))': dependencies: - '@ast-grep/napi': 0.35.0 - '@aws-sdk/client-cloudfront': 3.398.0 - '@aws-sdk/client-dynamodb': 3.868.0 - '@aws-sdk/client-lambda': 3.865.0 - '@aws-sdk/client-s3': 3.864.0 - '@aws-sdk/client-sqs': 3.864.0 + '@ast-grep/napi': 0.40.5 + '@aws-sdk/client-cloudfront': 3.984.0 + '@aws-sdk/client-dynamodb': 3.984.0 + '@aws-sdk/client-lambda': 3.984.0 + '@aws-sdk/client-s3': 3.984.0 + '@aws-sdk/client-sqs': 3.984.0 '@node-minify/core': 8.0.6 '@node-minify/terser': 8.0.6 '@tsconfig/node18': 1.0.3 aws4fetch: 1.0.20 - chalk: 5.4.1 + chalk: 5.6.2 cookie: 1.0.2 esbuild: 0.25.4 - express: 5.0.1 + express: 5.2.1 + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) path-to-regexp: 6.3.0 - urlpattern-polyfill: 10.0.0 + urlpattern-polyfill: 10.1.0 yaml: 2.8.1 transitivePeerDependencies: - aws-crt - supports-color - '@opennextjs/cloudflare@1.8.0(encoding@0.1.13)(wrangler@4.31.0)': + '@opennextjs/cloudflare@1.17.3(encoding@0.1.13)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(wrangler@4.31.0)': dependencies: + '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 3.7.6 + '@opennextjs/aws': 3.9.16(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)) cloudflare: 4.5.0(encoding@0.1.13) + comment-json: 4.6.2 enquirer: 2.4.1 - glob: 11.0.3 + glob: 12.0.0 + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) ts-tqdm: 0.8.6 wrangler: 4.31.0 yargs: 18.0.0 @@ -13996,1566 +13295,1090 @@ snapshots: '@radix-ui/primitive@1.1.2': {} - '@radix-ui/react-accordion@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-accordion@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) aria-hidden: 1.2.6 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.7)(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) aria-hidden: 1.2.6 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-navigation-menu@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-navigation-menu@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-popover@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-popover@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) aria-hidden: 1.2.6 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-popper@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': - dependencies: - '@floating-ui/react-dom': 2.1.3(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-popper@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + dependencies: + '@floating-ui/react-dom': 2.1.3(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) '@radix-ui/rect': 1.1.1 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-select@2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-select@2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) aria-hidden: 1.2.6 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-slot@1.2.0(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-slot@1.2.0(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-switch@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-switch@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-toggle-group@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-toggle-group@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-toggle': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-toggle': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-toggle@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-toggle@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.1.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.1.7)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.1.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) - react: 19.1.7 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.5 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) '@radix-ui/rect@1.1.1': {} - '@rollup/pluginutils@5.1.4(rollup@4.44.2)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.2 - optionalDependencies: - rollup: 4.44.2 - - '@rollup/rollup-android-arm-eabi@4.44.2': - optional: true - - '@rollup/rollup-android-arm64@4.44.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.44.2': - optional: true - - '@rollup/rollup-darwin-x64@4.44.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.44.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.44.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.44.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.44.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.44.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.44.2': - optional: true - - '@rollup/rollup-linux-loongarch64-gnu@4.44.2': - optional: true - - '@rollup/rollup-linux-powerpc64le-gnu@4.44.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.44.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.44.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.44.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.44.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.44.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.44.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.44.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.44.2': - optional: true - - '@rtsao/scc@1.1.0': {} - - '@rushstack/eslint-patch@1.11.0': {} - - '@schummar/icu-type-parser@1.21.5': {} - - '@sec-ant/readable-stream@0.4.1': {} - - '@segment/analytics-core@1.8.1': - dependencies: - '@lukeed/uuid': 2.0.1 - '@segment/analytics-generic-utils': 1.2.0 - dset: 3.1.4 - tslib: 2.8.1 - - '@segment/analytics-generic-utils@1.2.0': - dependencies: - tslib: 2.8.1 - - '@segment/analytics-node@2.2.1(encoding@0.1.13)': - dependencies: - '@lukeed/uuid': 2.0.1 - '@segment/analytics-core': 1.8.1 - '@segment/analytics-generic-utils': 1.2.0 - buffer: 6.0.3 - jose: 5.10.0 - node-fetch: 2.7.0(encoding@0.1.13) - tslib: 2.8.1 - transitivePeerDependencies: - - encoding - - '@sentry-internal/tracing@7.120.3': - dependencies: - '@sentry/core': 7.120.3 - '@sentry/types': 7.120.3 - '@sentry/utils': 7.120.3 - - '@sentry/core@6.19.7': - dependencies: - '@sentry/hub': 6.19.7 - '@sentry/minimal': 6.19.7 - '@sentry/types': 6.19.7 - '@sentry/utils': 6.19.7 - tslib: 1.14.1 - - '@sentry/core@7.120.3': - dependencies: - '@sentry/types': 7.120.3 - '@sentry/utils': 7.120.3 - - '@sentry/hub@6.19.7': - dependencies: - '@sentry/types': 6.19.7 - '@sentry/utils': 6.19.7 - tslib: 1.14.1 - - '@sentry/integrations@7.120.3': - dependencies: - '@sentry/core': 7.120.3 - '@sentry/types': 7.120.3 - '@sentry/utils': 7.120.3 - localforage: 1.10.0 - - '@sentry/minimal@6.19.7': - dependencies: - '@sentry/hub': 6.19.7 - '@sentry/types': 6.19.7 - tslib: 1.14.1 - - '@sentry/node@6.19.7': - dependencies: - '@sentry/core': 6.19.7 - '@sentry/hub': 6.19.7 - '@sentry/types': 6.19.7 - '@sentry/utils': 6.19.7 - cookie: 0.4.2 - https-proxy-agent: 5.0.1 - lru_map: 0.3.3 - tslib: 1.14.1 - transitivePeerDependencies: - - supports-color - - '@sentry/node@7.120.3': - dependencies: - '@sentry-internal/tracing': 7.120.3 - '@sentry/core': 7.120.3 - '@sentry/integrations': 7.120.3 - '@sentry/types': 7.120.3 - '@sentry/utils': 7.120.3 - - '@sentry/types@6.19.7': {} - - '@sentry/types@7.120.3': {} - - '@sentry/utils@6.19.7': - dependencies: - '@sentry/types': 6.19.7 - tslib: 1.14.1 - - '@sentry/utils@7.120.3': - dependencies: - '@sentry/types': 7.120.3 - - '@sinclair/typebox@0.27.8': {} - - '@sindresorhus/is@4.6.0': {} - - '@sindresorhus/is@7.0.2': {} - - '@sindresorhus/merge-streams@2.3.0': {} - - '@sindresorhus/merge-streams@4.0.0': {} - - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 - - '@smithy/abort-controller@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - - '@smithy/abort-controller@4.0.5': - dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 - - '@smithy/abort-controller@4.2.3': - dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader-native@4.0.0': - dependencies: - '@smithy/util-base64': 4.0.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader@5.0.0': - dependencies: - tslib: 2.8.1 - - '@smithy/config-resolver@2.2.0': - dependencies: - '@smithy/node-config-provider': 2.3.0 - '@smithy/types': 2.12.0 - '@smithy/util-config-provider': 2.3.0 - '@smithy/util-middleware': 2.2.0 - tslib: 2.8.1 - - '@smithy/config-resolver@4.1.5': - dependencies: - '@smithy/node-config-provider': 4.1.4 - '@smithy/types': 4.3.2 - '@smithy/util-config-provider': 4.0.0 - '@smithy/util-middleware': 4.0.5 - tslib: 2.8.1 - - '@smithy/config-resolver@4.4.0': - dependencies: - '@smithy/node-config-provider': 4.3.3 - '@smithy/types': 4.8.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.3 - '@smithy/util-middleware': 4.2.3 - tslib: 2.8.1 - - '@smithy/core@3.17.1': - dependencies: - '@smithy/middleware-serde': 4.2.3 - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.3 - '@smithy/util-stream': 4.5.4 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - - '@smithy/core@3.8.0': - dependencies: - '@smithy/middleware-serde': 4.0.9 - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 - '@smithy/util-base64': 4.0.0 - '@smithy/util-body-length-browser': 4.0.0 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-stream': 4.2.4 - '@smithy/util-utf8': 4.0.0 - '@types/uuid': 9.0.8 - tslib: 2.8.1 - uuid: 9.0.1 - - '@smithy/credential-provider-imds@2.3.0': - dependencies: - '@smithy/node-config-provider': 2.3.0 - '@smithy/property-provider': 2.2.0 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.0.7': - dependencies: - '@smithy/node-config-provider': 4.1.4 - '@smithy/property-provider': 4.0.5 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.3': - dependencies: - '@smithy/node-config-provider': 4.3.3 - '@smithy/property-provider': 4.2.3 - '@smithy/types': 4.8.0 - '@smithy/url-parser': 4.2.3 - tslib: 2.8.1 - - '@smithy/eventstream-codec@4.0.5': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.3.2 - '@smithy/util-hex-encoding': 4.0.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-browser@4.0.5': - dependencies: - '@smithy/eventstream-serde-universal': 4.0.5 - '@smithy/types': 4.3.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-config-resolver@4.1.3': - dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-node@4.0.5': - dependencies: - '@smithy/eventstream-serde-universal': 4.0.5 - '@smithy/types': 4.3.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-universal@4.0.5': - dependencies: - '@smithy/eventstream-codec': 4.0.5 - '@smithy/types': 4.3.2 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@2.5.0': - dependencies: - '@smithy/protocol-http': 3.3.0 - '@smithy/querystring-builder': 2.2.0 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.1.1': - dependencies: - '@smithy/protocol-http': 5.1.3 - '@smithy/querystring-builder': 4.0.5 - '@smithy/types': 4.3.2 - '@smithy/util-base64': 4.0.0 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.3.4': - dependencies: - '@smithy/protocol-http': 5.3.3 - '@smithy/querystring-builder': 4.2.3 - '@smithy/types': 4.8.0 - '@smithy/util-base64': 4.3.0 - tslib: 2.8.1 - - '@smithy/hash-blob-browser@4.0.5': - dependencies: - '@smithy/chunked-blob-reader': 5.0.0 - '@smithy/chunked-blob-reader-native': 4.0.0 - '@smithy/types': 4.3.2 - tslib: 2.8.1 - - '@smithy/hash-node@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - '@smithy/util-buffer-from': 2.2.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@smithy/hash-node@4.0.5': - dependencies: - '@smithy/types': 4.3.2 - '@smithy/util-buffer-from': 4.0.0 - '@smithy/util-utf8': 4.0.0 - tslib: 2.8.1 - - '@smithy/hash-node@4.2.3': - dependencies: - '@smithy/types': 4.8.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/hash-stream-node@4.0.5': + '@rollup/pluginutils@5.1.4(rollup@4.44.2)': dependencies: - '@smithy/types': 4.3.2 - '@smithy/util-utf8': 4.0.0 - tslib: 2.8.1 + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.2 + optionalDependencies: + rollup: 4.44.2 - '@smithy/invalid-dependency@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@rollup/rollup-android-arm-eabi@4.44.2': + optional: true - '@smithy/invalid-dependency@4.0.5': - dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@rollup/rollup-android-arm64@4.44.2': + optional: true - '@smithy/invalid-dependency@4.2.3': - dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@rollup/rollup-darwin-arm64@4.44.2': + optional: true - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 + '@rollup/rollup-darwin-x64@4.44.2': + optional: true - '@smithy/is-array-buffer@4.0.0': - dependencies: - tslib: 2.8.1 + '@rollup/rollup-freebsd-arm64@4.44.2': + optional: true - '@smithy/is-array-buffer@4.2.0': - dependencies: - tslib: 2.8.1 + '@rollup/rollup-freebsd-x64@4.44.2': + optional: true - '@smithy/md5-js@4.0.5': - dependencies: - '@smithy/types': 4.3.2 - '@smithy/util-utf8': 4.0.0 - tslib: 2.8.1 + '@rollup/rollup-linux-arm-gnueabihf@4.44.2': + optional: true - '@smithy/middleware-content-length@2.2.0': - dependencies: - '@smithy/protocol-http': 3.3.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@rollup/rollup-linux-arm-musleabihf@4.44.2': + optional: true - '@smithy/middleware-content-length@4.0.5': - dependencies: - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@rollup/rollup-linux-arm64-gnu@4.44.2': + optional: true - '@smithy/middleware-content-length@4.2.3': - dependencies: - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@rollup/rollup-linux-arm64-musl@4.44.2': + optional: true - '@smithy/middleware-endpoint@2.5.1': - dependencies: - '@smithy/middleware-serde': 2.3.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - '@smithy/util-middleware': 2.2.0 - tslib: 2.8.1 + '@rollup/rollup-linux-loongarch64-gnu@4.44.2': + optional: true - '@smithy/middleware-endpoint@4.1.18': - dependencies: - '@smithy/core': 3.8.0 - '@smithy/middleware-serde': 4.0.9 - '@smithy/node-config-provider': 4.1.4 - '@smithy/shared-ini-file-loader': 4.0.5 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - '@smithy/util-middleware': 4.0.5 - tslib: 2.8.1 + '@rollup/rollup-linux-powerpc64le-gnu@4.44.2': + optional: true - '@smithy/middleware-endpoint@4.3.5': - dependencies: - '@smithy/core': 3.17.1 - '@smithy/middleware-serde': 4.2.3 - '@smithy/node-config-provider': 4.3.3 - '@smithy/shared-ini-file-loader': 4.3.3 - '@smithy/types': 4.8.0 - '@smithy/url-parser': 4.2.3 - '@smithy/util-middleware': 4.2.3 - tslib: 2.8.1 + '@rollup/rollup-linux-riscv64-gnu@4.44.2': + optional: true - '@smithy/middleware-retry@2.3.1': - dependencies: - '@smithy/node-config-provider': 2.3.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/service-error-classification': 2.1.5 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - '@smithy/util-middleware': 2.2.0 - '@smithy/util-retry': 2.2.0 - tslib: 2.8.1 - uuid: 9.0.1 - - '@smithy/middleware-retry@4.1.19': - dependencies: - '@smithy/node-config-provider': 4.1.4 - '@smithy/protocol-http': 5.1.3 - '@smithy/service-error-classification': 4.0.7 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-retry': 4.0.7 - '@types/uuid': 9.0.8 - tslib: 2.8.1 - uuid: 9.0.1 - - '@smithy/middleware-retry@4.4.5': - dependencies: - '@smithy/node-config-provider': 4.3.3 - '@smithy/protocol-http': 5.3.3 - '@smithy/service-error-classification': 4.2.3 - '@smithy/smithy-client': 4.9.1 - '@smithy/types': 4.8.0 - '@smithy/util-middleware': 4.2.3 - '@smithy/util-retry': 4.2.3 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 + '@rollup/rollup-linux-riscv64-musl@4.44.2': + optional: true - '@smithy/middleware-serde@2.3.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@rollup/rollup-linux-s390x-gnu@4.44.2': + optional: true - '@smithy/middleware-serde@4.0.9': - dependencies: - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@rollup/rollup-linux-x64-gnu@4.44.2': + optional: true - '@smithy/middleware-serde@4.2.3': - dependencies: - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@rollup/rollup-linux-x64-musl@4.44.2': + optional: true - '@smithy/middleware-stack@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@rollup/rollup-win32-arm64-msvc@4.44.2': + optional: true - '@smithy/middleware-stack@4.0.5': - dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@rollup/rollup-win32-ia32-msvc@4.44.2': + optional: true - '@smithy/middleware-stack@4.2.3': - dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@rollup/rollup-win32-x64-msvc@4.44.2': + optional: true - '@smithy/node-config-provider@2.3.0': - dependencies: - '@smithy/property-provider': 2.2.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@rtsao/scc@1.1.0': {} - '@smithy/node-config-provider@4.1.4': - dependencies: - '@smithy/property-provider': 4.0.5 - '@smithy/shared-ini-file-loader': 4.0.5 - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@rushstack/eslint-patch@1.11.0': {} - '@smithy/node-config-provider@4.3.3': - dependencies: - '@smithy/property-provider': 4.2.3 - '@smithy/shared-ini-file-loader': 4.3.3 - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@schummar/icu-type-parser@1.21.5': {} - '@smithy/node-http-handler@2.5.0': - dependencies: - '@smithy/abort-controller': 2.2.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/querystring-builder': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@sec-ant/readable-stream@0.4.1': {} - '@smithy/node-http-handler@4.1.1': + '@segment/analytics-core@1.8.1': dependencies: - '@smithy/abort-controller': 4.0.5 - '@smithy/protocol-http': 5.1.3 - '@smithy/querystring-builder': 4.0.5 - '@smithy/types': 4.3.2 + '@lukeed/uuid': 2.0.1 + '@segment/analytics-generic-utils': 1.2.0 + dset: 3.1.4 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.3': + '@segment/analytics-generic-utils@1.2.0': dependencies: - '@smithy/abort-controller': 4.2.3 - '@smithy/protocol-http': 5.3.3 - '@smithy/querystring-builder': 4.2.3 - '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/property-provider@2.2.0': + '@segment/analytics-node@2.2.1(encoding@0.1.13)': dependencies: - '@smithy/types': 2.12.0 + '@lukeed/uuid': 2.0.1 + '@segment/analytics-core': 1.8.1 + '@segment/analytics-generic-utils': 1.2.0 + buffer: 6.0.3 + jose: 5.10.0 + node-fetch: 2.7.0(encoding@0.1.13) tslib: 2.8.1 + transitivePeerDependencies: + - encoding - '@smithy/property-provider@4.0.5': + '@sentry-internal/tracing@7.120.3': dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@sentry/core': 7.120.3 + '@sentry/types': 7.120.3 + '@sentry/utils': 7.120.3 - '@smithy/property-provider@4.2.3': + '@sentry/core@6.19.7': dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@sentry/hub': 6.19.7 + '@sentry/minimal': 6.19.7 + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + tslib: 1.14.1 - '@smithy/protocol-http@2.0.5': + '@sentry/core@7.120.3': dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@sentry/types': 7.120.3 + '@sentry/utils': 7.120.3 - '@smithy/protocol-http@3.3.0': + '@sentry/hub@6.19.7': dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + tslib: 1.14.1 - '@smithy/protocol-http@5.1.3': + '@sentry/integrations@7.120.3': dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@sentry/core': 7.120.3 + '@sentry/types': 7.120.3 + '@sentry/utils': 7.120.3 + localforage: 1.10.0 - '@smithy/protocol-http@5.3.3': + '@sentry/minimal@6.19.7': dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@sentry/hub': 6.19.7 + '@sentry/types': 6.19.7 + tslib: 1.14.1 - '@smithy/querystring-builder@2.2.0': + '@sentry/node@6.19.7': dependencies: - '@smithy/types': 2.12.0 - '@smithy/util-uri-escape': 2.2.0 - tslib: 2.8.1 + '@sentry/core': 6.19.7 + '@sentry/hub': 6.19.7 + '@sentry/types': 6.19.7 + '@sentry/utils': 6.19.7 + cookie: 0.4.2 + https-proxy-agent: 5.0.1 + lru_map: 0.3.3 + tslib: 1.14.1 + transitivePeerDependencies: + - supports-color - '@smithy/querystring-builder@4.0.5': + '@sentry/node@7.120.3': dependencies: - '@smithy/types': 4.3.2 - '@smithy/util-uri-escape': 4.0.0 - tslib: 2.8.1 + '@sentry-internal/tracing': 7.120.3 + '@sentry/core': 7.120.3 + '@sentry/integrations': 7.120.3 + '@sentry/types': 7.120.3 + '@sentry/utils': 7.120.3 - '@smithy/querystring-builder@4.2.3': - dependencies: - '@smithy/types': 4.8.0 - '@smithy/util-uri-escape': 4.2.0 - tslib: 2.8.1 + '@sentry/types@6.19.7': {} - '@smithy/querystring-parser@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@sentry/types@7.120.3': {} - '@smithy/querystring-parser@4.0.5': + '@sentry/utils@6.19.7': dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@sentry/types': 6.19.7 + tslib: 1.14.1 - '@smithy/querystring-parser@4.2.3': + '@sentry/utils@7.120.3': dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@sentry/types': 7.120.3 - '@smithy/service-error-classification@2.1.5': - dependencies: - '@smithy/types': 2.12.0 + '@sinclair/typebox@0.27.8': {} - '@smithy/service-error-classification@4.0.7': - dependencies: - '@smithy/types': 4.3.2 + '@sindresorhus/is@4.6.0': {} - '@smithy/service-error-classification@4.2.3': - dependencies: - '@smithy/types': 4.8.0 + '@sindresorhus/is@7.0.2': {} - '@smithy/shared-ini-file-loader@2.4.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@sindresorhus/merge-streams@2.3.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/shared-ini-file-loader@4.0.5': + '@sinonjs/commons@3.0.1': dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 + type-detect: 4.0.8 - '@smithy/shared-ini-file-loader@4.3.3': + '@sinonjs/fake-timers@10.3.0': dependencies: - '@smithy/types': 4.8.0 - tslib: 2.8.1 + '@sinonjs/commons': 3.0.1 - '@smithy/signature-v4@2.3.0': + '@smithy/abort-controller@4.2.12': dependencies: - '@smithy/is-array-buffer': 2.2.0 - '@smithy/types': 2.12.0 - '@smithy/util-hex-encoding': 2.2.0 - '@smithy/util-middleware': 2.2.0 - '@smithy/util-uri-escape': 2.2.0 - '@smithy/util-utf8': 2.3.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/signature-v4@5.1.3': + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: - '@smithy/is-array-buffer': 4.0.0 - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 - '@smithy/util-hex-encoding': 4.0.0 - '@smithy/util-middleware': 4.0.5 - '@smithy/util-uri-escape': 4.0.0 - '@smithy/util-utf8': 4.0.0 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 - '@smithy/signature-v4@5.3.3': + '@smithy/chunked-blob-reader@5.2.2': dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.3 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@2.5.1': + '@smithy/config-resolver@4.4.13': dependencies: - '@smithy/middleware-endpoint': 2.5.1 - '@smithy/middleware-stack': 2.2.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/types': 2.12.0 - '@smithy/util-stream': 2.2.0 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/smithy-client@4.4.10': - dependencies: - '@smithy/core': 3.8.0 - '@smithy/middleware-endpoint': 4.1.18 - '@smithy/middleware-stack': 4.0.5 - '@smithy/protocol-http': 5.1.3 - '@smithy/types': 4.3.2 - '@smithy/util-stream': 4.2.4 + '@smithy/core@3.23.12': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/smithy-client@4.9.1': + '@smithy/credential-provider-imds@4.2.12': dependencies: - '@smithy/core': 3.17.1 - '@smithy/middleware-endpoint': 4.3.5 - '@smithy/middleware-stack': 4.2.3 - '@smithy/protocol-http': 5.3.3 - '@smithy/types': 4.8.0 - '@smithy/util-stream': 4.5.4 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 tslib: 2.8.1 - '@smithy/types@2.12.0': + '@smithy/eventstream-codec@4.2.12': dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 tslib: 2.8.1 - '@smithy/types@4.3.2': + '@smithy/eventstream-serde-browser@4.2.12': dependencies: + '@smithy/eventstream-serde-universal': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/types@4.8.0': + '@smithy/eventstream-serde-config-resolver@4.3.12': dependencies: + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/url-parser@2.2.0': + '@smithy/eventstream-serde-node@4.2.12': dependencies: - '@smithy/querystring-parser': 2.2.0 - '@smithy/types': 2.12.0 + '@smithy/eventstream-serde-universal': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/url-parser@4.0.5': + '@smithy/eventstream-serde-universal@4.2.12': dependencies: - '@smithy/querystring-parser': 4.0.5 - '@smithy/types': 4.3.2 + '@smithy/eventstream-codec': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/url-parser@4.2.3': + '@smithy/fetch-http-handler@5.3.15': dependencies: - '@smithy/querystring-parser': 4.2.3 - '@smithy/types': 4.8.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 - '@smithy/util-base64@2.3.0': + '@smithy/hash-blob-browser@4.2.13': dependencies: - '@smithy/util-buffer-from': 2.2.0 - '@smithy/util-utf8': 2.3.0 + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-base64@4.0.0': + '@smithy/hash-node@4.2.12': dependencies: - '@smithy/util-buffer-from': 4.0.0 - '@smithy/util-utf8': 4.0.0 + '@smithy/types': 4.13.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-base64@4.3.0': + '@smithy/hash-stream-node@4.2.12': dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-body-length-browser@2.2.0': + '@smithy/invalid-dependency@4.2.12': dependencies: + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.0.0': + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.0': + '@smithy/is-array-buffer@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-node@2.3.0': + '@smithy/md5-js@4.2.12': dependencies: + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-body-length-node@4.0.0': + '@smithy/middleware-content-length@4.2.12': dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.1': + '@smithy/middleware-endpoint@4.4.27': dependencies: + '@smithy/core': 3.23.12 + '@smithy/middleware-serde': 4.2.15 + '@smithy/node-config-provider': 4.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/util-buffer-from@2.2.0': + '@smithy/middleware-retry@4.4.44': dependencies: - '@smithy/is-array-buffer': 2.2.0 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/service-error-classification': 4.2.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/util-buffer-from@4.0.0': + '@smithy/middleware-serde@4.2.15': dependencies: - '@smithy/is-array-buffer': 4.0.0 + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': + '@smithy/middleware-stack@4.2.12': dependencies: - '@smithy/is-array-buffer': 4.2.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-config-provider@2.3.0': + '@smithy/node-config-provider@4.3.12': dependencies: + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-config-provider@4.0.0': + '@smithy/node-http-handler@4.5.0': dependencies: + '@smithy/abort-controller': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.0': + '@smithy/property-provider@4.2.12': dependencies: + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@2.2.1': + '@smithy/protocol-http@5.3.12': dependencies: - '@smithy/property-provider': 2.2.0 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - bowser: 2.12.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.0.26': + '@smithy/querystring-builder@4.2.12': dependencies: - '@smithy/property-provider': 4.0.5 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 - bowser: 2.12.0 + '@smithy/types': 4.13.1 + '@smithy/util-uri-escape': 4.2.2 tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.4': + '@smithy/querystring-parser@4.2.12': dependencies: - '@smithy/property-provider': 4.2.3 - '@smithy/smithy-client': 4.9.1 - '@smithy/types': 4.8.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@2.3.1': + '@smithy/service-error-classification@4.2.12': dependencies: - '@smithy/config-resolver': 2.2.0 - '@smithy/credential-provider-imds': 2.3.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/property-provider': 2.2.0 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - tslib: 2.8.1 + '@smithy/types': 4.13.1 - '@smithy/util-defaults-mode-node@4.0.26': + '@smithy/shared-ini-file-loader@4.4.7': dependencies: - '@smithy/config-resolver': 4.1.5 - '@smithy/credential-provider-imds': 4.0.7 - '@smithy/node-config-provider': 4.1.4 - '@smithy/property-provider': 4.0.5 - '@smithy/smithy-client': 4.4.10 - '@smithy/types': 4.3.2 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.6': + '@smithy/signature-v4@5.3.12': dependencies: - '@smithy/config-resolver': 4.4.0 - '@smithy/credential-provider-imds': 4.2.3 - '@smithy/node-config-provider': 4.3.3 - '@smithy/property-provider': 4.2.3 - '@smithy/smithy-client': 4.9.1 - '@smithy/types': 4.8.0 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-endpoints@3.0.7': + '@smithy/smithy-client@4.12.7': dependencies: - '@smithy/node-config-provider': 4.1.4 - '@smithy/types': 4.3.2 + '@smithy/core': 3.23.12 + '@smithy/middleware-endpoint': 4.4.27 + '@smithy/middleware-stack': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.3': + '@smithy/types@4.13.1': dependencies: - '@smithy/node-config-provider': 4.3.3 - '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/util-hex-encoding@2.2.0': + '@smithy/url-parser@4.2.12': dependencies: + '@smithy/querystring-parser': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.0.0': + '@smithy/util-base64@4.3.2': dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': + '@smithy/util-body-length-browser@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@2.2.0': + '@smithy/util-body-length-node@4.2.3': dependencies: - '@smithy/types': 2.12.0 tslib: 2.8.1 - '@smithy/util-middleware@4.0.5': + '@smithy/util-buffer-from@2.2.0': dependencies: - '@smithy/types': 4.3.2 + '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-middleware@4.2.3': + '@smithy/util-buffer-from@4.2.2': dependencies: - '@smithy/types': 4.8.0 + '@smithy/is-array-buffer': 4.2.2 tslib: 2.8.1 - '@smithy/util-retry@2.2.0': + '@smithy/util-config-provider@4.2.2': dependencies: - '@smithy/service-error-classification': 2.1.5 - '@smithy/types': 2.12.0 tslib: 2.8.1 - '@smithy/util-retry@4.0.7': + '@smithy/util-defaults-mode-browser@4.3.43': dependencies: - '@smithy/service-error-classification': 4.0.7 - '@smithy/types': 4.3.2 + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-retry@4.2.3': + '@smithy/util-defaults-mode-node@4.2.47': dependencies: - '@smithy/service-error-classification': 4.2.3 - '@smithy/types': 4.8.0 + '@smithy/config-resolver': 4.4.13 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-stream@2.2.0': + '@smithy/util-endpoints@3.3.3': dependencies: - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/node-http-handler': 2.5.0 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - '@smithy/util-buffer-from': 2.2.0 - '@smithy/util-hex-encoding': 2.2.0 - '@smithy/util-utf8': 2.3.0 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-stream@4.2.4': + '@smithy/util-hex-encoding@4.2.2': dependencies: - '@smithy/fetch-http-handler': 5.1.1 - '@smithy/node-http-handler': 4.1.1 - '@smithy/types': 4.3.2 - '@smithy/util-base64': 4.0.0 - '@smithy/util-buffer-from': 4.0.0 - '@smithy/util-hex-encoding': 4.0.0 - '@smithy/util-utf8': 4.0.0 tslib: 2.8.1 - '@smithy/util-stream@4.5.4': + '@smithy/util-middleware@4.2.12': dependencies: - '@smithy/fetch-http-handler': 5.3.4 - '@smithy/node-http-handler': 4.4.3 - '@smithy/types': 4.8.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-uri-escape@2.2.0': + '@smithy/util-retry@4.2.12': dependencies: + '@smithy/service-error-classification': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-uri-escape@4.0.0': + '@smithy/util-stream@4.5.20': dependencies: + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.0': + '@smithy/util-uri-escape@4.2.2': dependencies: tslib: 2.8.1 @@ -15564,29 +14387,18 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.0.0': - dependencies: - '@smithy/util-buffer-from': 4.0.0 - tslib: 2.8.1 - - '@smithy/util-utf8@4.2.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-waiter@2.2.0': + '@smithy/util-utf8@4.2.2': dependencies: - '@smithy/abort-controller': 2.2.0 - '@smithy/types': 2.12.0 + '@smithy/util-buffer-from': 4.2.2 tslib: 2.8.1 - '@smithy/util-waiter@4.0.7': + '@smithy/util-waiter@4.2.13': dependencies: - '@smithy/abort-controller': 4.0.5 - '@smithy/types': 4.3.2 + '@smithy/abort-controller': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/uuid@1.1.0': + '@smithy/uuid@1.1.2': dependencies: tslib: 2.8.1 @@ -15927,8 +14739,6 @@ snapshots: '@types/uuid@10.0.0': {} - '@types/uuid@9.0.8': {} - '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.32': @@ -16213,18 +15023,18 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@1.5.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/analytics@1.5.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) - '@vercel/functions@2.2.12(@aws-sdk/credential-provider-web-identity@3.864.0)': + '@vercel/functions@2.2.12(@aws-sdk/credential-provider-web-identity@3.972.24)': dependencies: '@vercel/oidc': 2.0.1 optionalDependencies: - '@aws-sdk/credential-provider-web-identity': 3.864.0 + '@aws-sdk/credential-provider-web-identity': 3.972.24 '@vercel/oidc@2.0.1': dependencies: @@ -16241,10 +15051,10 @@ snapshots: '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/speed-insights@1.2.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -16505,6 +15315,8 @@ snapshots: get-intrinsic: 1.3.0 is-string: 1.1.1 + array-timsort@1.0.3: {} + array-union@2.1.0: {} array.prototype.findlast@1.2.5: @@ -16665,6 +15477,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.5.4: optional: true @@ -16710,24 +15524,22 @@ snapshots: blake3-wasm@2.1.5: {} - body-parser@2.2.0: + body-parser@2.2.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3 - http-errors: 2.0.0 - iconv-lite: 0.6.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.14.0 - raw-body: 3.0.0 + qs: 6.15.0 + raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: - supports-color boolbase@1.0.0: {} - bowser@2.12.0: {} - bowser@2.12.1: {} brace-expansion@1.1.11: @@ -16739,6 +15551,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -16796,13 +15612,13 @@ snapshots: optionalDependencies: magicast: 0.3.5 - c15t@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2): + c15t@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2): dependencies: '@c15t/backend': 1.8.0(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@upstash/redis@1.35.0)(crossws@0.3.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@c15t/translations': 1.8.0 '@orpc/client': 1.8.1(@opentelemetry/api@1.9.0) '@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(crossws@0.3.5)(ws@8.18.2) - zustand: 5.0.8(@types/react@19.2.7)(react@19.1.7) + zustand: 5.0.8(@types/react@19.2.7)(react@19.1.5) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -16900,6 +15716,8 @@ snapshots: chalk@5.4.1: {} + chalk@5.6.2: {} + char-regex@1.0.2: {} chardet@0.7.0: {} @@ -17057,6 +15875,11 @@ snapshots: commander@7.2.0: {} + comment-json@4.6.2: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + comment-parser@1.4.1: {} compressible@2.0.18: @@ -17120,8 +15943,6 @@ snapshots: cookie@0.4.2: {} - cookie@0.7.1: {} - cookie@0.7.2: {} cookie@1.0.2: {} @@ -17284,10 +16105,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.6: - dependencies: - ms: 2.1.2 - debug@4.4.1: dependencies: ms: 2.1.3 @@ -17472,11 +16289,11 @@ snapshots: dependencies: embla-carousel: 9.0.0-rc01 - embla-carousel-react@9.0.0-rc01(react@19.1.7): + embla-carousel-react@9.0.0-rc01(react@19.1.5): dependencies: embla-carousel: 9.0.0-rc01 embla-carousel-reactive-utils: 9.0.0-rc01(embla-carousel@9.0.0-rc01) - react: 19.1.7 + react: 19.1.5 embla-carousel-reactive-utils@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: @@ -18136,39 +16953,35 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 - express@5.0.1: + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.0 + body-parser: 2.2.2 content-disposition: 1.0.0 content-type: 1.0.5 - cookie: 0.7.1 + cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.3.6 + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 finalhandler: 2.1.0 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 2.0.0 - methods: 1.1.2 mime-types: 3.0.1 on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.15.0 range-parser: 1.2.1 router: 2.2.0 - safe-buffer: 5.2.1 send: 1.2.0 serve-static: 2.2.0 - setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 2.0.1 - utils-merge: 1.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -18225,17 +17038,19 @@ snapshots: fast-uri@3.0.3: {} - fast-xml-parser@4.2.5: + fast-xml-builder@1.1.4: dependencies: - strnum: 1.1.2 + path-expression-matcher: 1.2.0 fast-xml-parser@4.5.3: dependencies: strnum: 1.1.2 - fast-xml-parser@5.2.5: + fast-xml-parser@5.5.8: dependencies: - strnum: 2.1.1 + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.2.0 + strnum: 2.2.2 fastq@1.17.1: dependencies: @@ -18274,7 +17089,7 @@ snapshots: escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -18510,11 +17325,11 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@11.0.3: + glob@12.0.0: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.0.3 + minimatch: 10.2.4 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.0 @@ -18660,12 +17475,12 @@ snapshots: http-cache-semantics@4.2.0: {} - http-errors@2.0.0: + http-errors@2.0.1: dependencies: depd: 2.0.0 inherits: 2.0.4 setprototypeof: 1.2.0 - statuses: 2.0.1 + statuses: 2.0.2 toidentifier: 1.0.1 http-link-header@1.1.3: {} @@ -18718,6 +17533,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + icu-minify@4.8.3: dependencies: '@formatjs/icu-messageformat-parser': 3.5.1 @@ -19809,9 +18628,9 @@ snapshots: lru_map@0.3.3: {} - lucide-react@0.474.0(react@19.1.7): + lucide-react@0.474.0(react@19.1.5): dependencies: - react: 19.1.7 + react: 19.1.5 magic-string@0.30.17: dependencies: @@ -19834,7 +18653,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.7.2 make-error@1.3.6: optional: true @@ -19857,8 +18676,6 @@ snapshots: metaviewport-parser@0.3.0: {} - methods@1.1.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -19906,9 +18723,9 @@ snapshots: - bufferutil - utf-8-validate - minimatch@10.0.3: + minimatch@10.2.4: dependencies: - '@isaacs/brace-expansion': 5.0.0 + brace-expansion: 5.0.4 minimatch@3.1.2: dependencies: @@ -19962,8 +18779,6 @@ snapshots: ms@2.0.0: {} - ms@2.1.2: {} - ms@2.1.3: {} msw@2.9.0(@types/node@22.15.30)(typescript@5.8.3): @@ -20013,50 +18828,50 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.44.2 - next-auth@5.0.0-beta.30(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7): + next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): dependencies: '@auth/core': 0.41.0 - next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) - react: 19.1.7 + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + react: 19.1.5 next-intl-swc-plugin-extractor@4.8.3: {} - next-intl@4.8.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(typescript@5.8.3): + next-intl@4.8.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@parcel/watcher': 2.5.1 '@swc/core': 1.15.18 icu-minify: 4.8.3 negotiator: 1.0.0 - next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) next-intl-swc-plugin-extractor: 4.8.3 po-parser: 2.1.1 - react: 19.1.7 - use-intl: 4.8.3(react@19.1.7) + react: 19.1.5 + use-intl: 4.8.3(react@19.1.5) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: - '@swc/helpers' - next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7): + next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5): dependencies: - '@next/env': 16.2.6 + '@next/env': 16.1.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.7 caniuse-lite: 1.0.30001721 postcss: 8.4.31 - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) - styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) + styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.6 - '@next/swc-darwin-x64': 16.2.6 - '@next/swc-linux-arm64-gnu': 16.2.6 - '@next/swc-linux-arm64-musl': 16.2.6 - '@next/swc-linux-x64-gnu': 16.2.6 - '@next/swc-linux-x64-musl': 16.2.6 - '@next/swc-win32-arm64-msvc': 16.2.6 - '@next/swc-win32-x64-msvc': 16.2.6 + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.52.0 sharp: 0.34.5 @@ -20107,12 +18922,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.4.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7): + nuqs@2.4.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): dependencies: mitt: 3.0.1 - react: 19.1.7 + react: 19.1.5 optionalDependencies: - next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) nwsapi@2.2.20: optional: true @@ -20353,6 +19168,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.2.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -20846,11 +19663,7 @@ snapshots: pure-rand@6.1.0: {} - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - - qs@6.14.0: + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -20872,11 +19685,11 @@ snapshots: range-parser@1.2.1: {} - raw-body@3.0.0: + raw-body@3.0.2: dependencies: bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.6.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 unpipe: 1.0.0 rc9@2.1.2: @@ -20884,69 +19697,69 @@ snapshots: defu: 6.1.4 destr: 2.0.5 - react-async-script@1.2.0(react@19.1.7): + react-async-script@1.2.0(react@19.1.5): dependencies: hoist-non-react-statics: 3.3.2 prop-types: 15.8.1 - react: 19.1.7 + react: 19.1.5 - react-day-picker@9.7.0(react@19.1.7): + react-day-picker@9.7.0(react@19.1.5): dependencies: '@date-fns/tz': 1.2.0 date-fns: 4.1.0 date-fns-jalali: 4.1.0-0 - react: 19.1.7 + react: 19.1.5 - react-dom@19.1.7(react@19.1.7): + react-dom@19.1.5(react@19.1.5): dependencies: - react: 19.1.7 + react: 19.1.5 scheduler: 0.26.0 - react-google-recaptcha@3.1.0(react@19.1.7): + react-google-recaptcha@3.1.0(react@19.1.5): dependencies: prop-types: 15.8.1 - react: 19.1.7 - react-async-script: 1.2.0(react@19.1.7) + react: 19.1.5 + react-async-script: 1.2.0(react@19.1.5) - react-headroom@3.2.1(react@19.1.7): + react-headroom@3.2.1(react@19.1.5): dependencies: prop-types: 15.8.1 raf: 3.4.1 - react: 19.1.7 + react: 19.1.5 shallowequal: 1.1.0 react-is@16.13.1: {} react-is@18.3.1: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.1.7): + react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.1.5): dependencies: - react: 19.1.7 - react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.7) + react: 19.1.5 + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.5) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.1.7): + react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.1.5): dependencies: - react: 19.1.7 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.1.7) - react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.7) + react: 19.1.5 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.1.5) + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.5) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.1.7) - use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.1.7) + use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.1.5) + use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.1.5) optionalDependencies: '@types/react': 19.2.7 - react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.7): + react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.5): dependencies: get-nonce: 1.0.1 - react: 19.1.7 + react: 19.1.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react@19.1.7: {} + react@19.1.5: {} read-cache@1.0.0: dependencies: @@ -21177,12 +19990,12 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 mime-types: 3.0.1 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -21377,10 +20190,10 @@ snapshots: ip-address: 9.0.5 smart-buffer: 4.2.0 - sonner@1.7.4(react-dom@19.1.7(react@19.1.7))(react@19.1.7): + sonner@1.7.4(react-dom@19.1.5(react@19.1.5))(react@19.1.5): dependencies: - react: 19.1.7 - react-dom: 19.1.7(react@19.1.7) + react: 19.1.5 + react-dom: 19.1.5(react@19.1.5) source-map-js@1.2.1: {} @@ -21436,6 +20249,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.9.0: {} stdin-discarder@0.2.2: {} @@ -21558,14 +20373,14 @@ snapshots: strnum@1.1.2: {} - strnum@2.1.1: {} + strnum@2.2.2: {} stubborn-fs@1.2.5: {} - styled-jsx@5.1.6(@babel/core@7.27.4)(react@19.1.7): + styled-jsx@5.1.6(@babel/core@7.27.4)(react@19.1.5): dependencies: client-only: 0.0.1 - react: 19.1.7 + react: 19.1.5 optionalDependencies: '@babel/core': 7.27.4 @@ -22194,25 +21009,27 @@ snapshots: urlpattern-polyfill@10.0.0: {} - use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.1.7): + urlpattern-polyfill@10.1.0: {} + + use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.1.5): dependencies: - react: 19.1.7 + react: 19.1.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - use-intl@4.8.3(react@19.1.7): + use-intl@4.8.3(react@19.1.5): dependencies: '@formatjs/fast-memoize': 3.1.0 '@schummar/icu-type-parser': 1.21.5 icu-minify: 4.8.3 intl-messageformat: 11.1.2 - react: 19.1.7 + react: 19.1.5 - use-sidecar@1.1.3(@types/react@19.2.7)(react@19.1.7): + use-sidecar@1.1.3(@types/react@19.2.7)(react@19.1.5): dependencies: detect-node-es: 1.1.0 - react: 19.1.7 + react: 19.1.5 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 @@ -22221,12 +21038,8 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - uuid@11.1.0: {} - uuid@9.0.1: {} - v8-compile-cache-lib@3.0.1: optional: true @@ -22608,7 +21421,7 @@ snapshots: zod@4.1.12: {} - zustand@5.0.8(@types/react@19.2.7)(react@19.1.7): + zustand@5.0.8(@types/react@19.2.7)(react@19.1.5): optionalDependencies: '@types/react': 19.2.7 - react: 19.1.7 + react: 19.1.5 From 60a2abd43a854fcdf953405062d7c964a9a10448 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Thu, 26 Mar 2026 09:34:43 -0600 Subject: [PATCH 44/90] chore: consolidate changesets for alpha release Remove individual changesets and update the umbrella major changeset with full CLI release notes covering all commands and features. Co-Authored-By: Claude --- .changeset/brave-maps-arrive.md | 5 ---- .changeset/bump-opennext-cloudflare.md | 5 ---- .changeset/curvy-pandas-stop.md | 9 ------ .changeset/honest-nights-knock.md | 5 ---- .changeset/itchy-lions-work.md | 41 +++++++++++++++++++++++++- .changeset/remove-dev-command.md | 5 ---- .changeset/remove-framework-config.md | 5 ---- .changeset/remove-root-dir-opt.md | 5 ---- .changeset/ripe-beers-switch.md | 5 ---- .changeset/simplify-start-command.md | 5 ---- .changeset/warm-planes-flash.md | 5 ---- 11 files changed, 40 insertions(+), 55 deletions(-) delete mode 100644 .changeset/brave-maps-arrive.md delete mode 100644 .changeset/bump-opennext-cloudflare.md delete mode 100644 .changeset/curvy-pandas-stop.md delete mode 100644 .changeset/honest-nights-knock.md delete mode 100644 .changeset/remove-dev-command.md delete mode 100644 .changeset/remove-framework-config.md delete mode 100644 .changeset/remove-root-dir-opt.md delete mode 100644 .changeset/ripe-beers-switch.md delete mode 100644 .changeset/simplify-start-command.md delete mode 100644 .changeset/warm-planes-flash.md diff --git a/.changeset/brave-maps-arrive.md b/.changeset/brave-maps-arrive.md deleted file mode 100644 index 135910ebc6..0000000000 --- a/.changeset/brave-maps-arrive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": minor ---- - -Add `catalyst whoami` command to verify stored credentials and display store/project info. diff --git a/.changeset/bump-opennext-cloudflare.md b/.changeset/bump-opennext-cloudflare.md deleted file mode 100644 index 1fa092334a..0000000000 --- a/.changeset/bump-opennext-cloudflare.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": patch ---- - -Bump `@opennextjs/cloudflare` peer dependency from `^1.8.0` to `^1.17.3`. diff --git a/.changeset/curvy-pandas-stop.md b/.changeset/curvy-pandas-stop.md deleted file mode 100644 index 430aa994bf..0000000000 --- a/.changeset/curvy-pandas-stop.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@bigcommerce/catalyst": patch ---- - -Remove the reading of default environment variable files. - -## Migration - -Ensure that environment variables are passed explicitly using flags if they are not already included in the `project.json` config file. diff --git a/.changeset/honest-nights-knock.md b/.changeset/honest-nights-knock.md deleted file mode 100644 index 189282d523..0000000000 --- a/.changeset/honest-nights-knock.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": patch ---- - -Write additional config variables to the project.json file so that we have a centralized place for all required variables. diff --git a/.changeset/itchy-lions-work.md b/.changeset/itchy-lions-work.md index db39b9b042..8f4b5d487b 100644 --- a/.changeset/itchy-lions-work.md +++ b/.changeset/itchy-lions-work.md @@ -2,4 +2,43 @@ "@bigcommerce/catalyst": major --- -Alpha version of the CLI +Introducing the Catalyst CLI (`@bigcommerce/catalyst`) — a command-line tool for building and deploying your Catalyst storefront to BigCommerce's Native Hosting infrastructure. + +### Highlights + +- **Browser-based authentication** — Run `catalyst auth login` to authenticate via an OAuth device code flow. Credentials are stored locally in `.bigcommerce/project.json` for use by all subsequent commands. CI/CD environments can use `--store-hash` and `--access-token` flags or environment variables instead. +- **Project management** — Create, link, and list BigCommerce infrastructure projects with `catalyst project create`, `catalyst project link`, and `catalyst project list`. +- **Build & deploy** — `catalyst build` runs the OpenNext Cloudflare build pipeline and generates deployment artifacts. `catalyst deploy` bundles, uploads, and deploys your storefront with real-time progress streaming. Pass runtime secrets with `--secret KEY=VALUE`. +- **Local preview** — `catalyst start` launches a local Cloudflare Workers preview of your built storefront via the OpenNext adapter. +- **Live log tailing** — `catalyst logs tail` streams real-time application logs from your deployed storefront with color-coded log levels and multiple output formats. +- **Smart credential resolution** — Configuration is resolved in priority order: CLI flags → `--env-file` → process environment variables → `.bigcommerce/project.json`. +- **Telemetry** — Anonymous usage telemetry with session and trace IDs for support. Opt out anytime with `catalyst telemetry disable`. + +### Commands + +| Command | Description | +|---------|-------------| +| `catalyst auth login` | Authenticate via browser OAuth flow | +| `catalyst auth logout` | Remove stored credentials | +| `catalyst auth whoami` | Verify credentials and display store/project info | +| `catalyst project create` | Create a new infrastructure project | +| `catalyst project link` | Link to an existing infrastructure project | +| `catalyst project list` | List infrastructure projects for your store | +| `catalyst build` | Build your Catalyst project for deployment | +| `catalyst deploy` | Build and deploy to BigCommerce Native Hosting | +| `catalyst start` | Start a local Cloudflare Workers preview | +| `catalyst logs tail` | Stream live logs from your deployment | +| `catalyst version` | Display CLI, Node.js, and platform info | +| `catalyst telemetry` | View or change telemetry collection status | + +### Getting started + +```bash +cd core +pnpm add @bigcommerce/catalyst@alpha @opennextjs/cloudflare@1.17.3 +pnpm catalyst auth login +pnpm catalyst project create +pnpm catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN= +``` + +For full documentation, see the [Native Hosting Overview](https://developer.bigcommerce.com/docs/storefront/catalyst/deployment/native-hosting/overview) and [CLI Reference](https://developer.bigcommerce.com/docs/storefront/catalyst/reference/cli). diff --git a/.changeset/remove-dev-command.md b/.changeset/remove-dev-command.md deleted file mode 100644 index 50777d29ef..0000000000 --- a/.changeset/remove-dev-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": minor ---- - -Remove `catalyst dev` command. Use `next dev` directly instead. diff --git a/.changeset/remove-framework-config.md b/.changeset/remove-framework-config.md deleted file mode 100644 index 603e4ff710..0000000000 --- a/.changeset/remove-framework-config.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": minor ---- - -Remove `framework` configuration and simplify `catalyst build` to only run the Catalyst build path. diff --git a/.changeset/remove-root-dir-opt.md b/.changeset/remove-root-dir-opt.md deleted file mode 100644 index 4cd1e2252f..0000000000 --- a/.changeset/remove-root-dir-opt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": minor ---- - -Remove `--root-dir` flag from `project create` and `project link` commands. Use `process.cwd()` as a hardcoded default for usage simplicity. diff --git a/.changeset/ripe-beers-switch.md b/.changeset/ripe-beers-switch.md deleted file mode 100644 index ea90bd6138..0000000000 --- a/.changeset/ripe-beers-switch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": minor ---- - -Build now runs automatically when deploying. You can pass `--prebuilt` if you don't need to run build before deploying. diff --git a/.changeset/simplify-start-command.md b/.changeset/simplify-start-command.md deleted file mode 100644 index f719e14c7e..0000000000 --- a/.changeset/simplify-start-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": minor ---- - -Remove `next start` proxy from `catalyst start` command. The command now only runs the OpenNext Cloudflare local preview. diff --git a/.changeset/warm-planes-flash.md b/.changeset/warm-planes-flash.md deleted file mode 100644 index 5f37a7510f..0000000000 --- a/.changeset/warm-planes-flash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": patch ---- - -Map deployment error codes (10–60) to human-readable, actionable messages in the CLI deploy command. Previously, deployment failures showed generic messages like `Deployment failed with error code: 30` with no explanation. From 3ee7a8b1e0eefb46f4c75d4606885fcbea489264 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Thu, 26 Mar 2026 09:42:44 -0600 Subject: [PATCH 45/90] chore: version @bigcommerce/catalyst@1.0.0-alpha.1 Co-Authored-By: Claude --- ...y-lions-work.md => alpha-release-notes.md} | 0 .changeset/pre.json | 8 +++- packages/catalyst/CHANGELOG.md | 44 +++++++++++++++++++ packages/catalyst/package.json | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) rename .changeset/{itchy-lions-work.md => alpha-release-notes.md} (100%) diff --git a/.changeset/itchy-lions-work.md b/.changeset/alpha-release-notes.md similarity index 100% rename from .changeset/itchy-lions-work.md rename to .changeset/alpha-release-notes.md diff --git a/.changeset/pre.json b/.changeset/pre.json index eb0c70f1ff..c8c900a031 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -2,9 +2,13 @@ "mode": "pre", "tag": "alpha", "initialVersions": { - "@bigcommerce/catalyst": "1.0.0" + "@bigcommerce/catalyst": "1.0.0", + "@bigcommerce/catalyst-core": "1.6.2", + "@bigcommerce/catalyst-client": "1.0.1", + "@bigcommerce/create-catalyst": "1.0.2", + "@bigcommerce/eslint-config-catalyst": "1.0.0" }, "changesets": [ - "itchy-lions-work" + "alpha-release-notes" ] } diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md index f12e93f45a..501ad2366f 100644 --- a/packages/catalyst/CHANGELOG.md +++ b/packages/catalyst/CHANGELOG.md @@ -1,5 +1,49 @@ # @bigcommerce/catalyst +## 1.0.0-alpha.1 + +### Major Changes + +- Introducing the Catalyst CLI (`@bigcommerce/catalyst`) — a command-line tool for building and deploying your Catalyst storefront to BigCommerce's Native Hosting infrastructure. + + ### Highlights + - **Browser-based authentication** — Run `catalyst auth login` to authenticate via an OAuth device code flow. Credentials are stored locally in `.bigcommerce/project.json` for use by all subsequent commands. CI/CD environments can use `--store-hash` and `--access-token` flags or environment variables instead. + - **Project management** — Create, link, and list BigCommerce infrastructure projects with `catalyst project create`, `catalyst project link`, and `catalyst project list`. + - **Build & deploy** — `catalyst build` runs the OpenNext Cloudflare build pipeline and generates deployment artifacts. `catalyst deploy` bundles, uploads, and deploys your storefront with real-time progress streaming. Pass runtime secrets with `--secret KEY=VALUE`. + - **Local preview** — `catalyst start` launches a local Cloudflare Workers preview of your built storefront via the OpenNext adapter. + - **Live log tailing** — `catalyst logs tail` streams real-time application logs from your deployed storefront with color-coded log levels and multiple output formats. + - **Smart credential resolution** — Configuration is resolved in priority order: CLI flags → `--env-file` → process environment variables → `.bigcommerce/project.json`. + - **Telemetry** — Anonymous usage telemetry with session and trace IDs for support. Opt out anytime with `catalyst telemetry disable`. + + ### Commands + + | Command | Description | + | ------------------------- | ------------------------------------------------- | + | `catalyst auth login` | Authenticate via browser OAuth flow | + | `catalyst auth logout` | Remove stored credentials | + | `catalyst auth whoami` | Verify credentials and display store/project info | + | `catalyst project create` | Create a new infrastructure project | + | `catalyst project link` | Link to an existing infrastructure project | + | `catalyst project list` | List infrastructure projects for your store | + | `catalyst build` | Build your Catalyst project for deployment | + | `catalyst deploy` | Build and deploy to BigCommerce Native Hosting | + | `catalyst start` | Start a local Cloudflare Workers preview | + | `catalyst logs tail` | Stream live logs from your deployment | + | `catalyst version` | Display CLI, Node.js, and platform info | + | `catalyst telemetry` | View or change telemetry collection status | + + ### Getting started + + ```bash + cd core + pnpm add @bigcommerce/catalyst@alpha @opennextjs/cloudflare@1.17.3 + pnpm catalyst auth login + pnpm catalyst project create + pnpm catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN= + ``` + + For full documentation, see the [Native Hosting Overview](https://developer.bigcommerce.com/docs/storefront/catalyst/deployment/native-hosting/overview) and [CLI Reference](https://developer.bigcommerce.com/docs/storefront/catalyst/reference/cli). + ## 1.0.0-alpha.0 ### Major Changes diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index 04e22e21a2..eed2d3b498 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/catalyst", - "version": "1.0.0-alpha.0", + "version": "1.0.0-alpha.1", "type": "module", "bin": { "catalyst": "dist/cli.js" From e0d0785b5a3b357a805946dd1e6164d2330c7a75 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Thu, 26 Mar 2026 10:43:58 -0600 Subject: [PATCH 46/90] chore: version @bigcommerce/catalyst@1.0.0-alpha.2 Rebuild dist to fix env var resolution (CATALYST_* instead of BIGCOMMERCE_*) and remove stale makeOptionMandatory on deploy. Co-Authored-By: Claude --- .changeset/fix-env-var-names.md | 5 +++++ .changeset/pre.json | 3 ++- packages/catalyst/CHANGELOG.md | 6 ++++++ packages/catalyst/package.json | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-env-var-names.md diff --git a/.changeset/fix-env-var-names.md b/.changeset/fix-env-var-names.md new file mode 100644 index 0000000000..9a2f9b44fd --- /dev/null +++ b/.changeset/fix-env-var-names.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Fix CLI environment variable resolution for `deploy`, `build`, and `project` commands. The published dist was using stale `BIGCOMMERCE_*` env var names instead of the correct `CATALYST_*` names (`CATALYST_STORE_HASH`, `CATALYST_ACCESS_TOKEN`, `CATALYST_PROJECT_UUID`). diff --git a/.changeset/pre.json b/.changeset/pre.json index c8c900a031..b5f6afda95 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -9,6 +9,7 @@ "@bigcommerce/eslint-config-catalyst": "1.0.0" }, "changesets": [ - "alpha-release-notes" + "alpha-release-notes", + "fix-env-var-names" ] } diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md index 501ad2366f..25a6f3ee22 100644 --- a/packages/catalyst/CHANGELOG.md +++ b/packages/catalyst/CHANGELOG.md @@ -1,5 +1,11 @@ # @bigcommerce/catalyst +## 1.0.0-alpha.2 + +### Patch Changes + +- Fix CLI environment variable resolution for `deploy`, `build`, and `project` commands. The published dist was using stale `BIGCOMMERCE_*` env var names instead of the correct `CATALYST_*` names (`CATALYST_STORE_HASH`, `CATALYST_ACCESS_TOKEN`, `CATALYST_PROJECT_UUID`). + ## 1.0.0-alpha.1 ### Major Changes diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index eed2d3b498..d3e9a8d9a1 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/catalyst", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.2", "type": "module", "bin": { "catalyst": "dist/cli.js" From 80cadf66e5a2cc26508829a83bb2ad9e41cb8009 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Mon, 30 Mar 2026 14:57:33 -0500 Subject: [PATCH 47/90] fix(cli): clean up deployment url output (#2960) * fix(cli): clean up deployment url output Remove redundant console.log, trailing \n on spinner message, and prepend https:// scheme so the url is clickable in terminal emulators. * fix(cli): fix lint warnings and update test expectations --- packages/catalyst/src/cli/commands/deploy.spec.ts | 4 ++-- packages/catalyst/src/cli/commands/deploy.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 36f4de2bbd..38ed52065c 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -185,7 +185,7 @@ describe('deployment and event streaming', () => { 'Fetching...', 'Processing...', 'Finalizing...', - 'Deployment completed successfully.\n', + 'Deployment completed successfully.', ]); }); @@ -234,7 +234,7 @@ describe('deployment and event streaming', () => { 'Fetching...', 'Processing...', 'Finalizing...', - 'Deployment completed successfully.\n', + 'Deployment completed successfully.', ]); expect(consola.warn).toHaveBeenCalledWith( diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 8341e04ecd..943768082d 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -1,5 +1,6 @@ import AdmZip from 'adm-zip'; import { Command, Option } from 'commander'; +import { colorize } from 'consola/utils'; import { access, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; @@ -289,7 +290,6 @@ export const getDeploymentStatus = async ( } if (data.deployment_url) { - console.log(data.deployment_url); deploymentUrl = data.deployment_url; } }); @@ -298,10 +298,12 @@ export const getDeploymentStatus = async ( done = streamDone; } - spinner.success('Deployment completed successfully.\n'); + spinner.success('Deployment completed successfully.'); if (deploymentUrl) { - consola.success(`View your deployment at: ${deploymentUrl}`); + const url = deploymentUrl.startsWith('https://') ? deploymentUrl : `https://${deploymentUrl}`; + + consola.success(`View your deployment at: ${colorize('blue', url)}`); } }; From a7c97ad7223381a1a5c8ee5be89cfc56ead0542a Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Mon, 30 Mar 2026 14:58:03 -0500 Subject: [PATCH 48/90] refactor(cli): rename "trace id" to "correlation id" (#2962) --- .changeset/alpha-release-notes.md | 2 +- packages/catalyst/CHANGELOG.md | 2 +- packages/catalyst/src/cli/commands/deploy.ts | 8 ++++---- packages/catalyst/src/cli/commands/project.spec.ts | 2 +- packages/catalyst/src/cli/index.ts | 4 ++-- packages/catalyst/src/cli/lib/project.ts | 4 ++-- packages/catalyst/src/cli/lib/telemetry.spec.ts | 6 +++--- packages/catalyst/src/cli/lib/telemetry.ts | 8 ++++---- packages/catalyst/vitest.setup.ts | 2 +- packages/create-catalyst/src/utils/telemetry/telemetry.ts | 6 +++--- 10 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.changeset/alpha-release-notes.md b/.changeset/alpha-release-notes.md index 8f4b5d487b..1fa02e3e8d 100644 --- a/.changeset/alpha-release-notes.md +++ b/.changeset/alpha-release-notes.md @@ -12,7 +12,7 @@ Introducing the Catalyst CLI (`@bigcommerce/catalyst`) — a command-line tool f - **Local preview** — `catalyst start` launches a local Cloudflare Workers preview of your built storefront via the OpenNext adapter. - **Live log tailing** — `catalyst logs tail` streams real-time application logs from your deployed storefront with color-coded log levels and multiple output formats. - **Smart credential resolution** — Configuration is resolved in priority order: CLI flags → `--env-file` → process environment variables → `.bigcommerce/project.json`. -- **Telemetry** — Anonymous usage telemetry with session and trace IDs for support. Opt out anytime with `catalyst telemetry disable`. +- **Telemetry** — Anonymous usage telemetry with session and correlation IDs for support. Opt out anytime with `catalyst telemetry disable`. ### Commands diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md index 25a6f3ee22..17e367a057 100644 --- a/packages/catalyst/CHANGELOG.md +++ b/packages/catalyst/CHANGELOG.md @@ -19,7 +19,7 @@ - **Local preview** — `catalyst start` launches a local Cloudflare Workers preview of your built storefront via the OpenNext adapter. - **Live log tailing** — `catalyst logs tail` streams real-time application logs from your deployed storefront with color-coded log levels and multiple output formats. - **Smart credential resolution** — Configuration is resolved in priority order: CLI flags → `--env-file` → process environment variables → `.bigcommerce/project.json`. - - **Telemetry** — Anonymous usage telemetry with session and trace IDs for support. Opt out anytime with `catalyst telemetry disable`. + - **Telemetry** — Anonymous usage telemetry with session and correlation IDs for support. Opt out anytime with `catalyst telemetry disable`. ### Commands diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 943768082d..dc8e76e7cf 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -119,7 +119,7 @@ export const generateUploadSignature = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', - 'X-Correlation-Id': getTelemetry().sessionId, + 'X-Correlation-Id': getTelemetry().correlationId, }, body: JSON.stringify({}), }, @@ -196,7 +196,7 @@ export const createDeployment = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', - 'X-Correlation-Id': getTelemetry().sessionId, + 'X-Correlation-Id': getTelemetry().correlationId, }, body: JSON.stringify({ project_uuid: projectUuid, @@ -236,7 +236,7 @@ export const getDeploymentStatus = async ( 'X-Auth-Token': accessToken, Accept: 'text/event-stream', Connection: 'keep-alive', - 'X-Correlation-Id': getTelemetry().sessionId, + 'X-Correlation-Id': getTelemetry().correlationId, }, }, ); @@ -321,7 +321,7 @@ export const fetchProject = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', - 'X-Correlation-Id': getTelemetry().sessionId, + 'X-Correlation-Id': getTelemetry().correlationId, }, }, ); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index ff6b0cd865..79a4933a71 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -35,7 +35,7 @@ beforeAll(async () => { identify: mockIdentify, isEnabled: vi.fn(() => true), track: vi.fn(), - sessionId: 'test-session-uuid', + correlationId: 'test-session-uuid', commandName: 'unknown', durationMs: vi.fn().mockReturnValue(0), analytics: { diff --git a/packages/catalyst/src/cli/index.ts b/packages/catalyst/src/cli/index.ts index bcbcfad8cf..47d98191bb 100644 --- a/packages/catalyst/src/cli/index.ts +++ b/packages/catalyst/src/cli/index.ts @@ -25,11 +25,11 @@ const handleFatalError = async (error: unknown) => { if (telemetry.isEnabled()) { consola.info( - `\nTrace ID: ${telemetry.sessionId}\nShare this Trace ID with BigCommerce support.`, + `Correlation ID: ${telemetry.correlationId}\nShare this Correlation ID with BigCommerce support.`, ); } else { consola.info( - '\nEnable telemetry (`catalyst telemetry enable`) for improved troubleshooting with BigCommerce support.', + 'Enable telemetry (`catalyst telemetry enable`) for improved troubleshooting with BigCommerce support.', ); } diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index 8241f527d4..1dc66420ee 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -27,7 +27,7 @@ export async function fetchProjects( method: 'GET', headers: { 'X-Auth-Token': accessToken, - 'X-Correlation-Id': getTelemetry().sessionId, + 'X-Correlation-Id': getTelemetry().correlationId, }, }, ); @@ -79,7 +79,7 @@ export async function createProject( 'X-Auth-Token': accessToken, Accept: 'application/json', 'Content-Type': 'application/json', - 'X-Correlation-Id': getTelemetry().sessionId, + 'X-Correlation-Id': getTelemetry().correlationId, }, body: JSON.stringify({ name }), }, diff --git a/packages/catalyst/src/cli/lib/telemetry.spec.ts b/packages/catalyst/src/cli/lib/telemetry.spec.ts index 358dc195f5..2d05961731 100644 --- a/packages/catalyst/src/cli/lib/telemetry.spec.ts +++ b/packages/catalyst/src/cli/lib/telemetry.spec.ts @@ -14,11 +14,11 @@ afterEach(() => { }); describe('Telemetry', () => { - test('sessionId is a valid UUID', () => { + test('correlationId is a valid UUID', () => { const telemetry = new Telemetry(); const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - expect(telemetry.sessionId).toMatch(uuidRegex); + expect(telemetry.correlationId).toMatch(uuidRegex); }); test('commandName defaults to unknown', () => { @@ -54,6 +54,6 @@ describe('getTelemetry', () => { const second = getTelemetry(); expect(first).not.toBe(second); - expect(first.sessionId).not.toBe(second.sessionId); + expect(first.correlationId).not.toBe(second.correlationId); }); }); diff --git a/packages/catalyst/src/cli/lib/telemetry.ts b/packages/catalyst/src/cli/lib/telemetry.ts index d4b7d3658f..356f95e1a4 100644 --- a/packages/catalyst/src/cli/lib/telemetry.ts +++ b/packages/catalyst/src/cli/lib/telemetry.ts @@ -10,7 +10,7 @@ const TELEMETRY_KEY_ENABLED = 'telemetry.enabled'; const TELEMETRY_KEY_ID = `telemetry.anonymousId`; export class Telemetry { - readonly sessionId: string; + readonly correlationId: string; readonly analytics: Analytics; readonly startTime: number; commandName = 'unknown'; @@ -26,7 +26,7 @@ export class Telemetry { this.projectConfig = getProjectConfig(); - this.sessionId = randomUUID(); + this.correlationId = randomUUID(); this.startTime = Date.now(); this.analytics = new Analytics({ writeKey: process.env.CLI_SEGMENT_WRITE_KEY ?? 'not-a-valid-segment-write-key', @@ -47,7 +47,7 @@ export class Telemetry { anonymousId: this.getAnonymousId(), properties: { ...payload, - sessionId: this.sessionId, + correlationId: this.correlationId, nodeVersion: process.version, platform: process.platform, arch: process.arch, @@ -114,7 +114,7 @@ export class Telemetry { let telemetryInstance: Telemetry | undefined; // Singleton so the pre-hook, post-hook, error handler, and command bodies all -// share one sessionId for correlation. resetTelemetry() is for test isolation. +// share one correlationId. resetTelemetry() is for test isolation. export function getTelemetry(): Telemetry { telemetryInstance ??= new Telemetry(); diff --git a/packages/catalyst/vitest.setup.ts b/packages/catalyst/vitest.setup.ts index 26e1439cc5..535856723c 100644 --- a/packages/catalyst/vitest.setup.ts +++ b/packages/catalyst/vitest.setup.ts @@ -3,7 +3,7 @@ import { afterAll, afterEach, beforeAll, vi } from 'vitest'; import { server } from './tests/mocks/node'; const mockTelemetryInstance = { - sessionId: 'test-session-uuid', + correlationId: 'test-session-uuid', commandName: 'unknown', analytics: { track: vi.fn(), diff --git a/packages/create-catalyst/src/utils/telemetry/telemetry.ts b/packages/create-catalyst/src/utils/telemetry/telemetry.ts index 369a4e5698..34b363635b 100644 --- a/packages/create-catalyst/src/utils/telemetry/telemetry.ts +++ b/packages/create-catalyst/src/utils/telemetry/telemetry.ts @@ -18,7 +18,7 @@ interface Config { } export class Telemetry { - readonly sessionId: string; + readonly correlationId: string; readonly analytics: Analytics; private conf: Conf | null; @@ -39,7 +39,7 @@ export class Telemetry { this.conf = null; } - this.sessionId = randomBytes(32).toString('hex'); + this.correlationId = randomBytes(32).toString('hex'); this.analytics = new Analytics({ writeKey: process.env.CLI_SEGMENT_WRITE_KEY ?? 'not-a-valid-segment-write-key', }); @@ -55,7 +55,7 @@ export class Telemetry { anonymousId: this.getAnonymousId(), properties: { ...payload, - sessionId: this.sessionId, + correlationId: this.correlationId, }, context: { app: { From a6582db7cd6a59197a50e6d62f320d1d6cbd0715 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 3 Apr 2026 15:28:52 -0500 Subject: [PATCH 49/90] feat(cli): auto-detect environment variables as deploy secrets (#2965) --- .../catalyst/src/cli/commands/deploy.spec.ts | 86 +++++++++++++++++++ packages/catalyst/src/cli/commands/deploy.ts | 34 +++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 38ed52065c..e15b8a5348 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -24,6 +24,7 @@ import { program } from '../program'; import { buildCatalystProject } from './build'; import { + autoDetectSecrets, createDeployment, deploy, generateBundleZip, @@ -391,6 +392,91 @@ test('reads from env options', () => { ); }); +describe('autoDetectSecrets', () => { + test('auto-detects env vars from process.env', () => { + vi.stubEnv('BIGCOMMERCE_STORE_HASH', 'hash123'); + vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', '1'); + vi.stubEnv('BIGCOMMERCE_STOREFRONT_TOKEN', 'token456'); + vi.stubEnv('BIGCOMMERCE_API_HOST', 'api.bigcommerce.com'); + vi.stubEnv('BIGCOMMERCE_GRAPHQL_API_DOMAIN', 'graphql.bigcommerce.com'); + vi.stubEnv('AUTH_SECRET', 'secret789'); + + const result = autoDetectSecrets([]); + + expect(result).toEqual([ + { type: 'secret', key: 'BIGCOMMERCE_STORE_HASH', value: 'hash123' }, + { type: 'secret', key: 'BIGCOMMERCE_CHANNEL_ID', value: '1' }, + { type: 'secret', key: 'BIGCOMMERCE_STOREFRONT_TOKEN', value: 'token456' }, + { type: 'secret', key: 'BIGCOMMERCE_API_HOST', value: 'api.bigcommerce.com' }, + { type: 'secret', key: 'BIGCOMMERCE_GRAPHQL_API_DOMAIN', value: 'graphql.bigcommerce.com' }, + { type: 'secret', key: 'AUTH_SECRET', value: 'secret789' }, + ]); + + vi.unstubAllEnvs(); + }); + + test('skips env vars already provided by user', () => { + vi.stubEnv('BIGCOMMERCE_STORE_HASH', 'env-hash'); + vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', '99'); + + const result = autoDetectSecrets([ + { type: 'secret', key: 'BIGCOMMERCE_STORE_HASH', value: 'user-hash' }, + ]); + + expect(result).toContainEqual({ + type: 'secret', + key: 'BIGCOMMERCE_STORE_HASH', + value: 'user-hash', + }); + expect(result).not.toContainEqual( + expect.objectContaining({ key: 'BIGCOMMERCE_STORE_HASH', value: 'env-hash' }), + ); + expect(result).toContainEqual({ + type: 'secret', + key: 'BIGCOMMERCE_CHANNEL_ID', + value: '99', + }); + + vi.unstubAllEnvs(); + }); + + test('warns for required missing env vars', () => { + vi.stubEnv('BIGCOMMERCE_STORE_HASH', ''); + vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', ''); + vi.stubEnv('BIGCOMMERCE_STOREFRONT_TOKEN', ''); + + autoDetectSecrets([]); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('BIGCOMMERCE_STORE_HASH')); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('BIGCOMMERCE_CHANNEL_ID')); + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('BIGCOMMERCE_STOREFRONT_TOKEN'), + ); + + vi.unstubAllEnvs(); + }); + + test('silently skips optional missing env vars', () => { + vi.stubEnv('BIGCOMMERCE_API_HOST', ''); + vi.stubEnv('BIGCOMMERCE_GRAPHQL_API_DOMAIN', ''); + + autoDetectSecrets([]); + + const warnCalls = vi.mocked(consola.warn).mock.calls.flat().join(' '); + + expect(warnCalls).not.toContain('BIGCOMMERCE_API_HOST'); + expect(warnCalls).not.toContain('BIGCOMMERCE_GRAPHQL_API_DOMAIN'); + + vi.unstubAllEnvs(); + }); + + test('returns empty array when no env vars and no input', () => { + const result = autoDetectSecrets(undefined); + + expect(result).toEqual(expect.arrayContaining([])); + }); +}); + describe('--prebuilt flag', () => { test('skips build step when --prebuilt is passed', async () => { await program.parseAsync([ diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index dc8e76e7cf..8a7d4db760 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -178,6 +178,38 @@ export const parseEnvironmentVariables = (secretOption?: string[]) => { }); }; +const AUTO_DETECT_SECRETS = [ + { key: 'BIGCOMMERCE_STORE_HASH', warnIfMissing: true }, + { key: 'BIGCOMMERCE_CHANNEL_ID', warnIfMissing: true }, + { key: 'BIGCOMMERCE_STOREFRONT_TOKEN', warnIfMissing: true }, + { key: 'BIGCOMMERCE_API_HOST', warnIfMissing: false }, + { key: 'BIGCOMMERCE_GRAPHQL_API_DOMAIN', warnIfMissing: false }, + { key: 'AUTH_SECRET', warnIfMissing: false }, +]; + +export const autoDetectSecrets = ( + environmentVariables?: Array<{ type: 'secret' | 'plain_text'; key: string; value: string }>, +) => { + const secrets = environmentVariables ?? []; + const existingKeys = new Set(secrets.map((s) => s.key)); + + AUTO_DETECT_SECRETS.forEach(({ key, warnIfMissing }) => { + if (existingKeys.has(key)) { + return; + } + + const value = process.env[key]; + + if (value) { + secrets.push({ type: 'secret', key, value }); + } else if (warnIfMissing) { + consola.warn(`${key} is not set in the environment and was not provided via --secret.`); + } + }); + + return secrets; +}; + export const createDeployment = async ( projectUuid: string, uploadUuid: string, @@ -428,7 +460,7 @@ export const deploy = new Command('deploy') await uploadBundleZip(uploadSignature.upload_url); - const environmentVariables = parseEnvironmentVariables(options.secret); + const environmentVariables = autoDetectSecrets(parseEnvironmentVariables(options.secret)); const { deployment_uuid: deploymentUuid } = await createDeployment( projectUuid, From b157739808b56a30cb079ba55f5f651e528b58d8 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 9 Apr 2026 11:00:02 -0500 Subject: [PATCH 50/90] chore: add changeset for auto-detect deploy secrets (#2972) --- .changeset/auto-detect-deploy-secrets.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/auto-detect-deploy-secrets.md diff --git a/.changeset/auto-detect-deploy-secrets.md b/.changeset/auto-detect-deploy-secrets.md new file mode 100644 index 0000000000..84093c391e --- /dev/null +++ b/.changeset/auto-detect-deploy-secrets.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Auto-detect environment variables as deploy secrets. From 2d4d7f498443814e642666d85a1463b5417b5c5a Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 9 Apr 2026 11:07:01 -0500 Subject: [PATCH 51/90] chore: version @bigcommerce/catalyst@1.0.0-alpha.3 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/pre.json | 1 + packages/catalyst/CHANGELOG.md | 6 ++++++ packages/catalyst/package.json | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index b5f6afda95..4224520bc3 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -10,6 +10,7 @@ }, "changesets": [ "alpha-release-notes", + "auto-detect-deploy-secrets", "fix-env-var-names" ] } diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md index 17e367a057..98b03752c3 100644 --- a/packages/catalyst/CHANGELOG.md +++ b/packages/catalyst/CHANGELOG.md @@ -1,5 +1,11 @@ # @bigcommerce/catalyst +## 1.0.0-alpha.3 + +### Minor Changes + +- [#2972](https://github.com/bigcommerce/catalyst/pull/2972) [`e681933`](https://github.com/bigcommerce/catalyst/commit/e681933ebbe798198e4c1b8f6f20f67dc4ec36ad) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Auto-detect environment variables as deploy secrets. + ## 1.0.0-alpha.2 ### Patch Changes diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index d3e9a8d9a1..4011c1eced 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/catalyst", - "version": "1.0.0-alpha.2", + "version": "1.0.0-alpha.3", "type": "module", "bin": { "catalyst": "dist/cli.js" From 717abb6dbc5e3e1ba61845480f23a9c4b8c57a3f Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Tue, 14 Apr 2026 14:47:31 -0600 Subject: [PATCH 52/90] feat(cli): add Commander.js built-in help text to all commands (#2984) Add .description(), .addHelpText(), and .summary() to all CLI commands so that `catalyst --help` becomes the canonical reference. Hide internal-only options (--api-host, --login-url) from help output. Co-authored-by: Claude Opus 4.6 (1M context) --- .changeset/cli-built-in-help-text.md | 5 +++ packages/catalyst/src/cli/commands/auth.ts | 34 +++++++++++++++++-- packages/catalyst/src/cli/commands/build.ts | 12 +++++++ packages/catalyst/src/cli/commands/deploy.ts | 9 ++++- packages/catalyst/src/cli/commands/logs.ts | 18 ++++++++++ packages/catalyst/src/cli/commands/project.ts | 31 +++++++++++++++-- packages/catalyst/src/cli/commands/start.ts | 6 ++++ .../catalyst/src/cli/commands/telemetry.ts | 16 +++++++++ packages/catalyst/src/cli/commands/version.ts | 6 ++++ packages/catalyst/src/cli/index.spec.ts | 2 +- .../catalyst/src/cli/lib/shared-options.ts | 3 +- packages/catalyst/src/cli/program.ts | 5 ++- 12 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 .changeset/cli-built-in-help-text.md diff --git a/.changeset/cli-built-in-help-text.md b/.changeset/cli-built-in-help-text.md new file mode 100644 index 0000000000..dde3ff6971 --- /dev/null +++ b/.changeset/cli-built-in-help-text.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Add built-in help text to all CLI commands so `catalyst --help` is the canonical reference. diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts index 941826fac7..7c188a560b 100644 --- a/packages/catalyst/src/cli/commands/auth.ts +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -40,6 +40,14 @@ async function fetchStoreProfile(storeHash: string, accessToken: string, apiHost const whoami = new Command('whoami') .description('Verify stored credentials and display store/project info.') + .addHelpText( + 'after', + ` +Example: + $ catalyst auth whoami + + Logged in to My Store (abc123), connected to project my-project (43eba682-0c48-11f1-9bd5-827a48b0ce1e)`, + ) .addOption( new Option( '--store-hash ', @@ -55,7 +63,8 @@ const whoami = new Command('whoami') .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), + .default('api.bigcommerce.com') + .hideHelp(), ) .action(async (options) => { try { @@ -110,7 +119,19 @@ const whoami = new Command('whoami') }); const login = new Command('login') - .description('Authenticate via browser using the OAuth device code flow.') + .description( + 'Authenticate via browser using the OAuth device code flow. If already logged in, displays current credentials and suggests running `catalyst auth logout` to re-authenticate.', + ) + .addHelpText( + 'after', + ` +Examples: + # Login via browser (recommended) + $ catalyst auth login + + # Login with existing credentials (skips browser flow) + $ catalyst auth login --store-hash --access-token `, + ) .addOption( new Option( '--store-hash ', @@ -126,7 +147,8 @@ const login = new Command('login') .addOption( new Option('--login-url ', 'BigCommerce login URL.') .env('BIGCOMMERCE_LOGIN_URL') - .default(DEFAULT_LOGIN_URL), + .default(DEFAULT_LOGIN_URL) + .hideHelp(), ) .action(async (options) => { try { @@ -183,6 +205,12 @@ const login = new Command('login') const logout = new Command('logout') .description('Remove stored credentials for the current project.') + .addHelpText( + 'after', + ` +Example: + $ catalyst auth logout`, + ) .action(() => { try { const config = getProjectConfig(); diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 3a33f89559..958b1bb985 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -76,6 +76,18 @@ export async function buildCatalystProject(projectUuid: string): Promise { } export const build = new Command('build') + .description( + 'Build your Catalyst project using the OpenNext/Cloudflare build pipeline. Also runs a Wrangler dry-run to generate deployment artifacts.', + ) + .addHelpText( + 'after', + ` +Examples: + $ catalyst build + + # Include project UUID + $ catalyst build --project-uuid `, + ) .addOption( new Option( '--project-uuid ', diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 8a7d4db760..78229faabb 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -370,6 +370,12 @@ export const fetchProject = async ( export const deploy = new Command('deploy') .description('Deploy your application to Cloudflare.') + .addHelpText( + 'after', + ` +Example: + $ catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN=`, + ) .addOption( new Option( '--store-hash ', @@ -385,7 +391,8 @@ export const deploy = new Command('deploy') .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), + .default('api.bigcommerce.com') + .hideHelp(), ) .addOption( new Option( diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index bf2ad2371d..e554c007a4 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -233,6 +233,18 @@ export const tailLogs = async ( const tail = new Command('tail') .description('Tail live logs from your deployed application.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst logs tail + + # Tail logs with request format + $ catalyst logs tail --format request + + # Tail logs as raw JSON (useful for piping to other tools) + $ catalyst logs tail --format json`, + ) .addOption(storeHashOption().makeOptionMandatory()) .addOption(accessTokenOption().makeOptionMandatory()) .addOption(apiHostOption()) @@ -263,6 +275,12 @@ const tail = new Command('tail') const query = new Command('query') .description('Query historical logs from your deployed application.') + .addHelpText( + 'after', + ` +Example: + $ catalyst logs query`, + ) .addOption(storeHashOption().makeOptionMandatory()) .addOption(accessTokenOption().makeOptionMandatory()) .addOption(apiHostOption()) diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 070dce1119..a5ecde3193 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -8,6 +8,12 @@ import { getTelemetry } from '../lib/telemetry'; const list = new Command('list') .description('List BigCommerce infrastructure projects for your store.') + .addHelpText( + 'after', + ` +Example: + $ catalyst project list`, + ) .addOption( new Option( '--store-hash ', @@ -23,7 +29,8 @@ const list = new Command('list') .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), + .default('api.bigcommerce.com') + .hideHelp(), ) .action(async (options) => { const config = getProjectConfig(); @@ -55,6 +62,12 @@ const create = new Command('create') .description( 'Create a new BigCommerce infrastructure project and link it to your local Catalyst project.', ) + .addHelpText( + 'after', + ` +Example: + $ catalyst project create`, + ) .addOption( new Option( '--store-hash ', @@ -70,7 +83,8 @@ const create = new Command('create') .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), + .default('api.bigcommerce.com') + .hideHelp(), ) .action(async (options) => { const config = getProjectConfig(); @@ -99,6 +113,16 @@ export const link = new Command('link') .description( '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.', ) + .addHelpText( + 'after', + ` +Examples: + # Link interactively (prompts to select or create) + $ catalyst project link + + # Link using a project UUID directly + $ catalyst project link --project-uuid `, + ) .addOption( new Option( '--store-hash ', @@ -114,7 +138,8 @@ export const link = new Command('link') .addOption( new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), + .default('api.bigcommerce.com') + .hideHelp(), ) .option( '--project-uuid ', diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index 70a4ff108c..61c9d9bac3 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -9,6 +9,12 @@ export const start = new Command('start') .description( 'Start a local preview of your Catalyst storefront using the OpenNext Cloudflare adapter.', ) + .addHelpText( + 'after', + ` +Example: + $ catalyst start`, + ) .action(async () => { const envLocal = join(process.cwd(), '.env.local'); const devVars = join(process.cwd(), '.bigcommerce', '.dev.vars'); diff --git a/packages/catalyst/src/cli/commands/telemetry.ts b/packages/catalyst/src/cli/commands/telemetry.ts index 253704d3e2..e9a6537258 100644 --- a/packages/catalyst/src/cli/commands/telemetry.ts +++ b/packages/catalyst/src/cli/commands/telemetry.ts @@ -8,6 +8,22 @@ const telemetryService = getTelemetry(); let isEnabled = telemetryService.isEnabled(); export const telemetry = new Command('telemetry') + .description( + 'View or change CLI telemetry collection status. Enabling telemetry helps BigCommerce support diagnose and troubleshoot errors you encounter when using the CLI.', + ) + .addHelpText( + 'after', + ` +Examples: + # Show telemetry status + $ catalyst telemetry + + # Enable telemetry collection + $ catalyst telemetry enable + + # Disable telemetry collection + $ catalyst telemetry --disable`, + ) .addArgument(new Argument('[arg]').choices(['disable', 'enable', 'status'])) .addOption(new Option('--enable', `Enables CLI telemetry collection.`).conflicts('disable')) .option('--disable', `Disables CLI telemetry collection.`) diff --git a/packages/catalyst/src/cli/commands/version.ts b/packages/catalyst/src/cli/commands/version.ts index 935cf2f9a0..73f5af93da 100644 --- a/packages/catalyst/src/cli/commands/version.ts +++ b/packages/catalyst/src/cli/commands/version.ts @@ -5,6 +5,12 @@ import { consola } from '../lib/logger'; export const version = new Command('version') .description('Display detailed version information.') + .addHelpText( + 'after', + ` +Example: + $ catalyst version`, + ) .action(() => { consola.log('Version Information:'); consola.log(`CLI Version: ${PACKAGE_INFO.version}`); diff --git a/packages/catalyst/src/cli/index.spec.ts b/packages/catalyst/src/cli/index.spec.ts index 7eca4ec7da..4864f61ddc 100644 --- a/packages/catalyst/src/cli/index.spec.ts +++ b/packages/catalyst/src/cli/index.spec.ts @@ -17,7 +17,7 @@ describe('CLI program', () => { expect(program).toBeInstanceOf(Command); expect(program.name()).toBe(process.env.npm_package_name); expect(program.version()).toBe(process.env.npm_package_version); - expect(program.description()).toBe('CLI tool for Catalyst development'); + expect(program.description()).toContain('CLI tool for Catalyst development'); }); test('has expected commands', () => { diff --git a/packages/catalyst/src/cli/lib/shared-options.ts b/packages/catalyst/src/cli/lib/shared-options.ts index e0d29d802c..5bca46a8ee 100644 --- a/packages/catalyst/src/cli/lib/shared-options.ts +++ b/packages/catalyst/src/cli/lib/shared-options.ts @@ -17,7 +17,8 @@ export const accessTokenOption = () => export const apiHostOption = () => new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'); + .default('api.bigcommerce.com') + .hideHelp(); export const projectUuidOption = () => new Option( diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 782be2d3d3..8a5d7ecc9c 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -24,7 +24,10 @@ consola.log(colorize('cyanBright', `◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.ver program .name(PACKAGE_INFO.name) .version(PACKAGE_INFO.version) - .description('CLI tool for Catalyst development') + .summary('CLI tool for Catalyst development') + .description( + 'CLI tool for Catalyst development.\n\nConfiguration priority: flags > env file (--env-path) > process.env > .bigcommerce/project.json.\nRun `catalyst --help` for details on a specific command.', + ) .addOption( new Option( '--env-path ', From 10b66189f3281769808abce0f1670d5603918af8 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Mon, 27 Apr 2026 12:05:50 -0600 Subject: [PATCH 53/90] fix: update catalyst cli with new client id --- .changeset/wise-worms-hear.md | 5 +++++ packages/catalyst/src/cli/commands/start.spec.ts | 9 ++++++++- packages/catalyst/src/cli/lib/auth.spec.ts | 2 +- packages/catalyst/src/cli/lib/auth.ts | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .changeset/wise-worms-hear.md diff --git a/.changeset/wise-worms-hear.md b/.changeset/wise-worms-hear.md new file mode 100644 index 0000000000..1404faa546 --- /dev/null +++ b/.changeset/wise-worms-hear.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Update the CLI with the new client id. diff --git a/packages/catalyst/src/cli/commands/start.spec.ts b/packages/catalyst/src/cli/commands/start.spec.ts index 23329f1590..5ee3e0ec38 100644 --- a/packages/catalyst/src/cli/commands/start.spec.ts +++ b/packages/catalyst/src/cli/commands/start.spec.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { execa } from 'execa'; import { existsSync, lstatSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; import { consola } from '../lib/logger'; @@ -44,7 +45,13 @@ test('calls execa with OpenNext production optimized server', async () => { expect(execa).toHaveBeenCalledWith( 'pnpm', - ['exec', 'opennextjs-cloudflare', 'preview', '--config', '.bigcommerce/wrangler.jsonc'], + [ + 'exec', + 'opennextjs-cloudflare', + 'preview', + '--config', + join('.bigcommerce', 'wrangler.jsonc'), + ], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd(), diff --git a/packages/catalyst/src/cli/lib/auth.spec.ts b/packages/catalyst/src/cli/lib/auth.spec.ts index d086ccf920..0e2ce587b2 100644 --- a/packages/catalyst/src/cli/lib/auth.spec.ts +++ b/packages/catalyst/src/cli/lib/auth.spec.ts @@ -124,7 +124,7 @@ describe('waitForDeviceToken', () => { describe('constants', () => { test('DEVICE_OAUTH_CLIENT_ID matches create-catalyst', () => { - expect(DEVICE_OAUTH_CLIENT_ID).toBe('s1q4io7mah2lm1i6uwp9yl1eit80n3b'); + expect(DEVICE_OAUTH_CLIENT_ID).toBe('b8063bu6hhml4e0lqh22yut63atsbyv'); }); test('DEVICE_OAUTH_SCOPES contains expected scopes', () => { diff --git a/packages/catalyst/src/cli/lib/auth.ts b/packages/catalyst/src/cli/lib/auth.ts index a298c751b1..b59922b438 100644 --- a/packages/catalyst/src/cli/lib/auth.ts +++ b/packages/catalyst/src/cli/lib/auth.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -export const DEVICE_OAUTH_CLIENT_ID = 's1q4io7mah2lm1i6uwp9yl1eit80n3b'; +export const DEVICE_OAUTH_CLIENT_ID = 'b8063bu6hhml4e0lqh22yut63atsbyv'; export const DEVICE_OAUTH_SCOPES = [ 'store_v2_information', 'store_infrastructure_deployments_manage', From b78770decbf46ebc49d04a9b9568a589512b9841 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Tue, 28 Apr 2026 09:49:31 -0600 Subject: [PATCH 54/90] feat(cli): LTRAC-612 show global options in subcommand help (#2992) Apply `.configureHelp({ showGlobalOptions: true })` to every Command and subcommand so `catalyst --help` surfaces the root-level `--env-path` option (and other globals) under a "Global Options" section. Previously these were only visible on `catalyst --help`. Refs LTRAC-612 Co-authored-by: Claude --- .changeset/cli-show-env-path-in-help.md | 5 +++++ packages/catalyst/src/cli/commands/auth.ts | 4 ++++ packages/catalyst/src/cli/commands/build.ts | 1 + packages/catalyst/src/cli/commands/deploy.ts | 1 + packages/catalyst/src/cli/commands/logs.ts | 3 +++ packages/catalyst/src/cli/commands/project.ts | 4 ++++ packages/catalyst/src/cli/commands/start.ts | 1 + packages/catalyst/src/cli/commands/telemetry.ts | 1 + packages/catalyst/src/cli/commands/version.ts | 1 + packages/catalyst/src/cli/program.ts | 1 + 10 files changed, 22 insertions(+) create mode 100644 .changeset/cli-show-env-path-in-help.md diff --git a/.changeset/cli-show-env-path-in-help.md b/.changeset/cli-show-env-path-in-help.md new file mode 100644 index 0000000000..0666bc0acd --- /dev/null +++ b/.changeset/cli-show-env-path-in-help.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Show the global `--env-path` option in every subcommand's `--help` output. diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts index 7c188a560b..6c9e9f4b0f 100644 --- a/packages/catalyst/src/cli/commands/auth.ts +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -39,6 +39,7 @@ async function fetchStoreProfile(storeHash: string, accessToken: string, apiHost } const whoami = new Command('whoami') + .configureHelp({ showGlobalOptions: true }) .description('Verify stored credentials and display store/project info.') .addHelpText( 'after', @@ -119,6 +120,7 @@ Example: }); const login = new Command('login') + .configureHelp({ showGlobalOptions: true }) .description( 'Authenticate via browser using the OAuth device code flow. If already logged in, displays current credentials and suggests running `catalyst auth logout` to re-authenticate.', ) @@ -204,6 +206,7 @@ Examples: }); const logout = new Command('logout') + .configureHelp({ showGlobalOptions: true }) .description('Remove stored credentials for the current project.') .addHelpText( 'after', @@ -239,6 +242,7 @@ Example: }); export const auth = new Command('auth') + .configureHelp({ showGlobalOptions: true }) .description('Manage authentication for the BigCommerce CLI.') .addCommand(whoami) .addCommand(login) diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 958b1bb985..d7999363ee 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -76,6 +76,7 @@ export async function buildCatalystProject(projectUuid: string): Promise { } export const build = new Command('build') + .configureHelp({ showGlobalOptions: true }) .description( 'Build your Catalyst project using the OpenNext/Cloudflare build pipeline. Also runs a Wrangler dry-run to generate deployment artifacts.', ) diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 78229faabb..482d06cfef 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -369,6 +369,7 @@ export const fetchProject = async ( }; export const deploy = new Command('deploy') + .configureHelp({ showGlobalOptions: true }) .description('Deploy your application to Cloudflare.') .addHelpText( 'after', diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index e554c007a4..b612b9d808 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -232,6 +232,7 @@ export const tailLogs = async ( }; const tail = new Command('tail') + .configureHelp({ showGlobalOptions: true }) .description('Tail live logs from your deployed application.') .addHelpText( 'after', @@ -274,6 +275,7 @@ Examples: }); const query = new Command('query') + .configureHelp({ showGlobalOptions: true }) .description('Query historical logs from your deployed application.') .addHelpText( 'after', @@ -292,6 +294,7 @@ Example: }); export const logs = new Command('logs') + .configureHelp({ showGlobalOptions: true }) .description('View logs from your deployed application.') .addCommand(tail, { isDefault: true }) .addCommand(query); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index a5ecde3193..bec2ecb94e 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -7,6 +7,7 @@ import { resolveCredentials } from '../lib/resolve-credentials'; import { getTelemetry } from '../lib/telemetry'; const list = new Command('list') + .configureHelp({ showGlobalOptions: true }) .description('List BigCommerce infrastructure projects for your store.') .addHelpText( 'after', @@ -59,6 +60,7 @@ Example: }); const create = new Command('create') + .configureHelp({ showGlobalOptions: true }) .description( 'Create a new BigCommerce infrastructure project and link it to your local Catalyst project.', ) @@ -110,6 +112,7 @@ Example: }); export const link = new Command('link') + .configureHelp({ showGlobalOptions: true }) .description( '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.', ) @@ -221,6 +224,7 @@ Examples: }); export const project = new Command('project') + .configureHelp({ showGlobalOptions: true }) .description('Manage your BigCommerce infrastructure project.') .addCommand(create) .addCommand(list) diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index 61c9d9bac3..468cc6309e 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -6,6 +6,7 @@ import { join, relative } from 'node:path'; import { consola } from '../lib/logger'; export const start = new Command('start') + .configureHelp({ showGlobalOptions: true }) .description( 'Start a local preview of your Catalyst storefront using the OpenNext Cloudflare adapter.', ) diff --git a/packages/catalyst/src/cli/commands/telemetry.ts b/packages/catalyst/src/cli/commands/telemetry.ts index e9a6537258..c60b76e0dd 100644 --- a/packages/catalyst/src/cli/commands/telemetry.ts +++ b/packages/catalyst/src/cli/commands/telemetry.ts @@ -8,6 +8,7 @@ const telemetryService = getTelemetry(); let isEnabled = telemetryService.isEnabled(); export const telemetry = new Command('telemetry') + .configureHelp({ showGlobalOptions: true }) .description( 'View or change CLI telemetry collection status. Enabling telemetry helps BigCommerce support diagnose and troubleshoot errors you encounter when using the CLI.', ) diff --git a/packages/catalyst/src/cli/commands/version.ts b/packages/catalyst/src/cli/commands/version.ts index 73f5af93da..262f8fbdd4 100644 --- a/packages/catalyst/src/cli/commands/version.ts +++ b/packages/catalyst/src/cli/commands/version.ts @@ -4,6 +4,7 @@ import PACKAGE_INFO from '../../../package.json'; import { consola } from '../lib/logger'; export const version = new Command('version') + .configureHelp({ showGlobalOptions: true }) .description('Display detailed version information.') .addHelpText( 'after', diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 8a5d7ecc9c..2f327f653e 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -28,6 +28,7 @@ program .description( 'CLI tool for Catalyst development.\n\nConfiguration priority: flags > env file (--env-path) > process.env > .bigcommerce/project.json.\nRun `catalyst --help` for details on a specific command.', ) + .configureHelp({ showGlobalOptions: true }) .addOption( new Option( '--env-path ', From 8b96d68fc710f914472dc83f7a4fa9f1d3140747 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 29 Apr 2026 21:46:59 -0500 Subject: [PATCH 55/90] LTRAC-444: fix(cli) - Read store hash and access token from project.json (#2997) * LTRAC-444: fix(cli) - Read store hash and access token from project.json `catalyst logs tail` and `catalyst logs query` now resolve credentials via `resolveCredentials`, falling back to `.bigcommerce/project.json` when `--store-hash` / `--access-token` flags or `CATALYST_STORE_HASH` / `CATALYST_ACCESS_TOKEN` env vars are not provided. This matches the behavior already used by `project`, `deploy`, and `build`, and removes the need to re-pass credentials after running `catalyst project link`. Also migrates the shared `--store-hash` / `--access-token` option helpers from the legacy `BIGCOMMERCE_*` env var names to `CATALYST_*` for consistency with the rest of the CLI. Fixes LTRAC-444 Co-Authored-By: Claude * LTRAC-444: fix(cli) - Migrate projectUuidOption env var to CATALYST_PROJECT_UUID The shared `projectUuidOption()` helper was still using the legacy `BIGCOMMERCE_PROJECT_UUID` env var name. It was missed in the earlier `CATALYST_*` rename sweep because `logs` was the only consumer at the time. The inline declarations in `build.ts` and `deploy.ts` already use `CATALYST_PROJECT_UUID`, so this aligns the helper with the rest of the CLI and the alpha.2 patch notes. Refs LTRAC-444 Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../catalyst/src/cli/commands/logs.spec.ts | 75 ++++++++++++++++++- packages/catalyst/src/cli/commands/logs.ts | 30 ++++---- .../catalyst/src/cli/lib/shared-options.ts | 6 +- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts index cf695a303b..7dae33324a 100644 --- a/packages/catalyst/src/cli/commands/logs.spec.ts +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -1,9 +1,12 @@ import { Command } from 'commander'; +import Conf from 'conf'; import { http, HttpResponse } from 'msw'; -import { afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, 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 { logs, parseSSEEvent, tailLogs } from './logs'; @@ -11,6 +14,10 @@ import { logs, parseSSEEvent, tailLogs } from './logs'; let exitMock: MockInstance; let stdoutWriteMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + const projectUuid = '6b202364-10f3-11f1-8bc7-fe9b9d8b14ab'; const storeHash = 'test-store'; const accessToken = 'test-token'; @@ -69,15 +76,28 @@ const callTailLogs = async (format: Parameters[4], events?: str }); }; -beforeAll(() => { +beforeAll(async () => { consola.mockTypes(() => vi.fn()); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); stdoutWriteMock = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + [tmpDir, cleanup] = await mkTempDir(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); }); afterEach(() => { vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + await cleanup(); }); describe('command configuration', () => { @@ -486,6 +506,39 @@ describe('retry and reconnect', () => { }); }); +describe('credential resolution', () => { + test('falls back to project.json for storeHash and accessToken', async () => { + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use(createOneShotLogHandler([`data: ${JSON.stringify(validLogEvent)}\n\n`])); + + await program.parseAsync(['node', 'catalyst', 'logs', 'tail', '--project-uuid', projectUuid]); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); + + test('exits with error when no credentials are provided', async () => { + 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; + + await program.parseAsync(['node', 'catalyst', 'logs', 'tail', '--project-uuid', projectUuid]); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(consola.info).toHaveBeenCalledWith( + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + describe('query subcommand', () => { test('exits with error as not yet implemented', async () => { await program.parseAsync([ @@ -502,6 +555,24 @@ describe('query subcommand', () => { expect(consola.error).toHaveBeenCalledWith('The query command is not yet implemented.'); expect(exitMock).toHaveBeenCalledWith(1); }); + + test('exits with missing credentials error when none are provided', async () => { + 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; + + await expect(program.parseAsync(['node', 'catalyst', 'logs', 'query'])).rejects.toThrow( + 'Missing credentials', + ); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + }); }); describe('program integration', () => { diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index b612b9d808..702efadb41 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -3,6 +3,8 @@ import { colorize } from 'consola/utils'; import { z } from 'zod'; import { consola } from '../lib/logger'; +import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; import { accessTokenOption, apiHostOption, @@ -246,8 +248,8 @@ Examples: # Tail logs as raw JSON (useful for piping to other tools) $ catalyst logs tail --format json`, ) - .addOption(storeHashOption().makeOptionMandatory()) - .addOption(accessTokenOption().makeOptionMandatory()) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) .addOption(apiHostOption()) .addOption(projectUuidOption()) .addOption( @@ -257,17 +259,14 @@ Examples: ) .action(async (options) => { try { - await telemetry.identify(options.storeHash); + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await telemetry.identify(storeHash); const projectUuid = resolveProjectUuid(options); - await tailLogs( - projectUuid, - options.storeHash, - options.accessToken, - options.apiHost, - options.format, - ); + await tailLogs(projectUuid, storeHash, accessToken, options.apiHost, options.format); } catch (error) { consola.error(error); process.exit(1); @@ -283,12 +282,15 @@ const query = new Command('query') Example: $ catalyst logs query`, ) - .addOption(storeHashOption().makeOptionMandatory()) - .addOption(accessTokenOption().makeOptionMandatory()) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) .addOption(apiHostOption()) .addOption(projectUuidOption()) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .action((_options) => { + .action((options) => { + const config = getProjectConfig(); + + resolveCredentials(options, config); + consola.error('The query command is not yet implemented.'); process.exit(1); }); diff --git a/packages/catalyst/src/cli/lib/shared-options.ts b/packages/catalyst/src/cli/lib/shared-options.ts index 5bca46a8ee..f753314b23 100644 --- a/packages/catalyst/src/cli/lib/shared-options.ts +++ b/packages/catalyst/src/cli/lib/shared-options.ts @@ -6,13 +6,13 @@ export const storeHashOption = () => new Option( '--store-hash ', 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('BIGCOMMERCE_STORE_HASH'); + ).env('CATALYST_STORE_HASH'); export const accessTokenOption = () => new Option( '--access-token ', 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('BIGCOMMERCE_ACCESS_TOKEN'); + ).env('CATALYST_ACCESS_TOKEN'); export const apiHostOption = () => new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') @@ -24,7 +24,7 @@ export const projectUuidOption = () => new Option( '--project-uuid ', 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('BIGCOMMERCE_PROJECT_UUID'); + ).env('CATALYST_PROJECT_UUID'); export const resolveProjectUuid = (options: { projectUuid?: string }) => { const config = getProjectConfig(); From 4752a135ab920d3f4af2f6c8ec3c0d894ff1130e Mon Sep 17 00:00:00 2001 From: Jordan Arldt Date: Thu, 7 May 2026 12:11:35 -0500 Subject: [PATCH 56/90] TRAC-137: Add native hosting option in catalyst CLI (#3003) --- packages/catalyst/package.json | 9 + .../catalyst/src/cli/commands/build.spec.ts | 50 +- packages/catalyst/src/cli/commands/build.ts | 18 + .../catalyst/src/cli/commands/create.spec.ts | 471 ++++++++++ packages/catalyst/src/cli/commands/create.ts | 503 +++++++++++ .../catalyst/src/cli/commands/deploy.spec.ts | 256 ++++++ packages/catalyst/src/cli/commands/deploy.ts | 126 ++- .../catalyst/src/cli/commands/project.spec.ts | 235 ++++- packages/catalyst/src/cli/commands/project.ts | 181 ++-- .../catalyst/src/cli/commands/start.spec.ts | 39 + packages/catalyst/src/cli/commands/start.ts | 17 + .../catalyst/src/cli/commands/telemetry.ts | 2 +- .../src/cli/lib/build-workspace-packages.ts | 33 + packages/catalyst/src/cli/lib/channels.ts | 175 ++++ packages/catalyst/src/cli/lib/checkout-ref.ts | 42 + .../catalyst/src/cli/lib/clone-catalyst.ts | 46 + .../src/cli/lib/commerce-hosting.spec.ts | 806 ++++++++++++++++++ .../catalyst/src/cli/lib/commerce-hosting.ts | 427 ++++++++++ .../catalyst/src/cli/lib/has-github-ssh.ts | 26 + .../src/cli/lib/install-dependencies.ts | 16 + .../catalyst/src/cli/lib/is-exec-exception.ts | 5 + packages/catalyst/src/cli/lib/localization.ts | 64 ++ packages/catalyst/src/cli/lib/login.ts | 41 + .../catalyst/src/cli/lib/project-config.ts | 11 - .../src/cli/lib/project-state.spec.ts | 164 ++++ .../catalyst/src/cli/lib/project-state.ts | 67 ++ packages/catalyst/src/cli/lib/project.ts | 110 ++- .../src/cli/lib/reset-branch-to-ref.ts | 15 + .../src/cli/lib/setup-core-project.spec.ts | 110 +++ .../src/cli/lib/setup-core-project.ts | 40 + .../catalyst/src/cli/lib/shared-options.ts | 6 +- .../src/cli/lib/sort-package-json.spec.ts | 81 ++ .../catalyst/src/cli/lib/sort-package-json.ts | 32 + packages/catalyst/src/cli/lib/telemetry.ts | 14 +- packages/catalyst/src/cli/lib/user-config.ts | 36 + packages/catalyst/src/cli/lib/write-env.ts | 13 + packages/catalyst/src/cli/program.ts | 21 +- packages/catalyst/tsconfig.json | 1 + pnpm-lock.yaml | 28 +- 39 files changed, 4153 insertions(+), 184 deletions(-) create mode 100644 packages/catalyst/src/cli/commands/create.spec.ts create mode 100644 packages/catalyst/src/cli/commands/create.ts create mode 100644 packages/catalyst/src/cli/lib/build-workspace-packages.ts create mode 100644 packages/catalyst/src/cli/lib/channels.ts create mode 100644 packages/catalyst/src/cli/lib/checkout-ref.ts create mode 100644 packages/catalyst/src/cli/lib/clone-catalyst.ts create mode 100644 packages/catalyst/src/cli/lib/commerce-hosting.spec.ts create mode 100644 packages/catalyst/src/cli/lib/commerce-hosting.ts create mode 100644 packages/catalyst/src/cli/lib/has-github-ssh.ts create mode 100644 packages/catalyst/src/cli/lib/install-dependencies.ts create mode 100644 packages/catalyst/src/cli/lib/is-exec-exception.ts create mode 100644 packages/catalyst/src/cli/lib/localization.ts create mode 100644 packages/catalyst/src/cli/lib/login.ts create mode 100644 packages/catalyst/src/cli/lib/project-state.spec.ts create mode 100644 packages/catalyst/src/cli/lib/project-state.ts create mode 100644 packages/catalyst/src/cli/lib/reset-branch-to-ref.ts create mode 100644 packages/catalyst/src/cli/lib/setup-core-project.spec.ts create mode 100644 packages/catalyst/src/cli/lib/setup-core-project.ts create mode 100644 packages/catalyst/src/cli/lib/sort-package-json.spec.ts create mode 100644 packages/catalyst/src/cli/lib/sort-package-json.ts create mode 100644 packages/catalyst/src/cli/lib/user-config.ts create mode 100644 packages/catalyst/src/cli/lib/write-env.ts diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index 4011c1eced..d17f486639 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -22,14 +22,20 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" }, "dependencies": { + "@inquirer/prompts": "^7.5.3", "@segment/analytics-node": "^2.2.1", "adm-zip": "^0.5.16", "commander": "^14.0.0", "conf": "^13.1.0", "consola": "^3.4.2", + "cross-spawn": "^7.0.6", "dotenv": "^16.5.0", "execa": "^9.6.0", + "fs-extra": "^11.3.0", + "lodash.kebabcase": "^4.1.1", + "nypm": "^0.5.4", "open": "^10.1.0", + "std-env": "^3.9.0", "yocto-spinner": "^1.0.0", "zod": "^4.0.5" }, @@ -38,6 +44,9 @@ "@bigcommerce/eslint-config-catalyst": "workspace:^", "@commander-js/extra-typings": "^14.0.0", "@types/adm-zip": "^0.5.7", + "@types/cross-spawn": "^6.0.6", + "@types/fs-extra": "^11.0.4", + "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.15.30", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", diff --git a/packages/catalyst/src/cli/commands/build.spec.ts b/packages/catalyst/src/cli/commands/build.spec.ts index 9de6f02008..ecdb46ea87 100644 --- a/packages/catalyst/src/cli/commands/build.spec.ts +++ b/packages/catalyst/src/cli/commands/build.spec.ts @@ -1,11 +1,47 @@ import { Command } from 'commander'; -import { expect, test, vi } from 'vitest'; +import { execa } from 'execa'; +import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; + +import { consola } from '../lib/logger'; +import { getProjectState } from '../lib/project-state'; +import { program } from '../program'; import { build } from './build'; +vi.mock('execa', () => ({ + execa: vi.fn(() => Promise.resolve({})), + __esModule: true, +})); + +vi.mock('../lib/project-state', () => ({ + getProjectState: vi.fn(), +})); + +const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, +}; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions vi.spyOn(process, 'exit').mockImplementation(() => null as never); +beforeAll(() => { + consola.wrapAll(); +}); + +beforeEach(() => { + consola.mockTypes(() => vi.fn()); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + test('properly configured Command instance', () => { expect(build).toBeInstanceOf(Command); expect(build.name()).toBe('build'); @@ -13,3 +49,15 @@ test('properly configured Command instance', () => { expect.arrayContaining([expect.objectContaining({ long: '--project-uuid' })]), ); }); + +test('falls through to `next build` when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + await program.parseAsync(['node', 'catalyst', 'build']); + + expect(execa).toHaveBeenCalledWith( + 'pnpm', + ['exec', 'next', 'build'], + expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }), + ); +}); diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index d7999363ee..e1107ee47c 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { getModuleCliPath } from '../lib/get-module-cli-path'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { getWranglerConfig } from '../lib/wrangler-config'; const WRANGLER_VERSION = '4.24.3'; @@ -96,6 +97,23 @@ Examples: ).env('CATALYST_PROJECT_UUID'), ) .action(async (options) => { + // Project must be transformed (middleware swapped in, OpenNext dep installed) + // before the OpenNext build pipeline can run. If it isn't, fall through to + // `next build` so this command works for self-hosted Catalyst projects too. + const state = getProjectState(); + + if (!state.isTransformed) { + consola.info('Project is not set up for Commerce Hosting — running `next build`.'); + consola.info('To deploy to Commerce Hosting, run `catalyst deploy`.'); + + await execa('pnpm', ['exec', 'next', 'build'], { + stdio: 'inherit', + cwd: process.cwd(), + }); + + return; + } + const config = getProjectConfig(); const projectUuid = options.projectUuid ?? config.get('projectUuid'); diff --git a/packages/catalyst/src/cli/commands/create.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts new file mode 100644 index 0000000000..11818b45c0 --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -0,0 +1,471 @@ +import { Command } from 'commander'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { buildWorkspacePackages } from '../lib/build-workspace-packages'; +import { cloneCatalyst } from '../lib/clone-catalyst'; +import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; +import { consola } from '../lib/logger'; +import { login } from '../lib/login'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { hasProjectsAccess } from '../lib/project'; +import { setupCoreProject } from '../lib/setup-core-project'; +import { writeEnv } from '../lib/write-env'; +import { program } from '../program'; + +import { create } from './create'; + +// Mock all side-effecting modules so the action runs end-to-end without +// actually cloning, installing, writing files, or hitting the network. +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + select: vi.fn(), +})); + +vi.mock('../lib/login', () => ({ + login: vi.fn().mockResolvedValue({ + storeHash: 'login-store-hash', + accessToken: 'login-access-token', + }), +})); + +vi.mock('../lib/clone-catalyst', () => ({ cloneCatalyst: vi.fn() })); +vi.mock('../lib/setup-core-project', () => ({ setupCoreProject: vi.fn() })); +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../lib/build-workspace-packages', () => ({ buildWorkspacePackages: vi.fn() })); +vi.mock('../lib/write-env', () => ({ writeEnv: vi.fn() })); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + setupCommerceHosting: vi.fn(), + promptForCommerceHostingProject: vi.fn().mockResolvedValue({ + uuid: 'commerce-project-uuid', + name: 'commerce-project', + }), + }; +}); + +vi.mock('../lib/project', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + hasProjectsAccess: vi.fn().mockResolvedValue(true), + }; +}); + +vi.mock('../lib/localization', () => ({ + getAvailableLocales: vi.fn().mockResolvedValue([ + { name: 'English', value: 'en' }, + { name: 'Spanish', value: 'es' }, + ]), +})); + +const { mockIdentify } = vi.hoisted(() => ({ mockIdentify: vi.fn() })); + +vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'create', + durationMs: vi.fn().mockReturnValue(0), + analytics: { closeAndFlush: vi.fn().mockResolvedValue(undefined) }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; +}); + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; +let testCounter = 0; + +const storeHash = 'flag-store-hash'; +const accessToken = 'flag-access-token'; + +// Each test gets a unique --project-name so the computed projectDir +// (`${tmpDir}/${name}`) doesn't collide with prior tests' directories +// when cloneCatalyst's mock creates them. +const uniqueProjectName = () => `test-project-${(testCounter += 1)}`; + +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(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +test('properly configured Command instance', () => { + expect(create).toBeInstanceOf(Command); + expect(create.name()).toBe('create'); + expect(create.description()).toBe( + 'Scaffold and connect a Catalyst storefront to your BigCommerce store.', + ); +}); + +describe('happy paths', () => { + test('scaffolds with full creds + flag-provided channel info (no commerce hosting)', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + expect(login).not.toHaveBeenCalled(); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(cloneCatalyst).toHaveBeenCalled(); + expect(setupCoreProject).toHaveBeenCalled(); + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).toHaveBeenCalled(); + expect(buildWorkspacePackages).toHaveBeenCalled(); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + BIGCOMMERCE_STORE_HASH: storeHash, + BIGCOMMERCE_CHANNEL_ID: '42', + BIGCOMMERCE_STOREFRONT_TOKEN: 'flag-storefront-token', + CATALYST_ACCESS_TOKEN: accessToken, + }), + ); + }); + + test('--hosting commerce sets up commerce hosting and writes BIGCOMMERCE_ACCESS_TOKEN', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--hosting', + 'commerce', + ]); + + expect(hasProjectsAccess).toHaveBeenCalledWith(storeHash, accessToken, 'api.bigcommerce.com'); + expect(promptForCommerceHostingProject).toHaveBeenCalled(); + expect(setupCommerceHosting).toHaveBeenCalledWith({ + projectDir: join(tmpDir, projectName), + projectUuid: 'commerce-project-uuid', + storeHash, + accessToken, + }); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ BIGCOMMERCE_ACCESS_TOKEN: accessToken }), + ); + }); + + test('login is invoked when creds are missing — channel info alone is insufficient', async () => { + // Regression test for edge case #1: previously, providing channel info via + // flags caused the login gate to be skipped, leaving BIGCOMMERCE_STORE_HASH + // unset and the storefront unable to start. + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + expect(login).toHaveBeenCalled(); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + BIGCOMMERCE_STORE_HASH: 'login-store-hash', + BIGCOMMERCE_CHANNEL_ID: '42', + BIGCOMMERCE_STOREFRONT_TOKEN: 'flag-storefront-token', + }), + ); + }); + + test('warns when --use-existing is passed without --hosting commerce', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--use-existing', + ]); + + expect(consola.warn).toHaveBeenCalledWith( + '--use-existing has no effect without --hosting commerce. Ignoring.', + ); + }); +}); + +describe('parser validation', () => { + test('--channel-id with non-numeric value throws InvalidArgumentError', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--channel-id', + 'abc', + ]), + ).rejects.toThrow(/not a valid channel ID/); + }); + + test('--env without = throws InvalidArgumentError', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--env', + 'BAD_VALUE', + ]), + ).rejects.toThrow(/Expected KEY=VALUE/); + }); + + test('--env with KEY=VAL=UE preserves the full value past the first =', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--env', + 'CONNECTION_STRING=postgres://user:pass@host/db?ssl=true', + ]); + + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + CONNECTION_STRING: 'postgres://user:pass@host/db?ssl=true', + }), + ); + }); +}); + +describe('ordering invariants', () => { + test('writeEnv runs before installDependencies and buildWorkspacePackages', async () => { + // Regression test for edge case #2: previously env vars were written after + // install/build, which would break any future workspace build script that + // reads env vars. + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + const [writeEnvOrder] = vi.mocked(writeEnv).mock.invocationCallOrder; + const [installOrder] = vi.mocked(installDependencies).mock.invocationCallOrder; + const [buildOrder] = vi.mocked(buildWorkspacePackages).mock.invocationCallOrder; + + expect(writeEnvOrder).toBeLessThan(installOrder); + expect(writeEnvOrder).toBeLessThan(buildOrder); + }); +}); + +describe('failure handling', () => { + test('mid-flow failure surfaces cleanup warning when projectDir exists', async () => { + // Regression test for edge case #5. cloneCatalyst's mock creates the dir + // so the cleanup-warning's pathExistsSync check passes; installDependencies + // then throws to simulate a mid-flow failure. + const projectName = uniqueProjectName(); + const projectDir = join(tmpDir, projectName); + + vi.mocked(cloneCatalyst).mockImplementationOnce(() => { + mkdirSync(projectDir, { recursive: true }); + }); + vi.mocked(installDependencies).mockRejectedValueOnce(new Error('install failed')); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]), + ).rejects.toThrow('install failed'); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining(`'${projectDir}' may be in a partial state`), + ); + }); + + test('mid-flow failure does not log cleanup warning if projectDir does not exist', async () => { + // cloneCatalyst is mocked but does NOT create the dir, so pathExistsSync + // returns false and the cleanup warning is suppressed. + vi.mocked(cloneCatalyst).mockImplementationOnce(() => { + throw new Error('clone failed before creating directory'); + }); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]), + ).rejects.toThrow('clone failed before creating directory'); + + expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('partial state')); + }); +}); + +describe('--hosting commerce preconditions', () => { + test('exits with error when hasProjectsAccess returns false', async () => { + vi.mocked(hasProjectsAccess).mockResolvedValueOnce(false); + + // The promptForCommerceHostingProject mock would normally return a project, + // but after process.exit (mocked) the action falls through. Make it throw + // so we can verify the precondition fired before reaching the prompt. + vi.mocked(promptForCommerceHostingProject).mockRejectedValueOnce( + new Error('should not have prompted after access denied'), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--hosting', + 'commerce', + ]), + ).rejects.toThrow(/should not have prompted/); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('does not have access to the Infrastructure Projects API'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts new file mode 100644 index 0000000000..05e4db8e24 --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.ts @@ -0,0 +1,503 @@ +import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings'; +import { input, select } from '@inquirer/prompts'; +import { execSync } from 'child_process'; +import { colorize } from 'consola/utils'; +import { pathExistsSync } from 'fs-extra/esm'; +import kebabCase from 'lodash.kebabcase'; +import { join } from 'path'; + +import { DEFAULT_LOGIN_URL } from '../lib/auth'; +import { buildWorkspacePackages } from '../lib/build-workspace-packages'; +import { + checkChannelEligibility, + createChannel, + fetchAvailableChannels, + getChannelInit, +} from '../lib/channels'; +import { cloneCatalyst } from '../lib/clone-catalyst'; +import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; +import { getAvailableLocales } from '../lib/localization'; +import { consola } from '../lib/logger'; +import { login } from '../lib/login'; +import { hasProjectsAccess, type ProjectListItem } from '../lib/project'; +import { setupCoreProject } from '../lib/setup-core-project'; +import { accessTokenOption, storeHashOption } from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; +import { writeEnv } from '../lib/write-env'; + +function getPlatformCheckCommand(command: string): string { + const isWindows = process.platform === 'win32'; + + return isWindows ? `where.exe ${command}` : `which ${command}`; +} + +function parseChannelId(value: string): number { + const parsed = parseInt(value, 10); + + if (Number.isNaN(parsed)) { + throw new InvalidArgumentError(`"${value}" is not a valid channel ID (expected a number).`); + } + + return parsed; +} + +// Variadic argParser: called once per `--env KEY=VALUE`, accumulating into a +// merged record. Splits on the first `=` so values containing `=` are preserved. +function parseEnvFlag( + value: string, + previous: Record = {}, +): Record { + const eqIdx = value.indexOf('='); + + if (eqIdx <= 0) { + throw new InvalidArgumentError(`Expected KEY=VALUE, got "${value}".`); + } + + const key = value.substring(0, eqIdx); + const val = value.substring(eqIdx + 1); + + if (!val) { + throw new InvalidArgumentError(`Expected KEY=VALUE with non-empty value, got "${value}".`); + } + + return { ...previous, [key]: val }; +} + +async function handleChannelCreation( + storeHash: string, + accessToken: string, + apiHost: string, + cliApiOrigin: string, +) { + const newChannelName = await input({ + message: 'What would you like to name your new channel?', + }); + + const availableLocales = await getAvailableLocales(storeHash, accessToken, apiHost); + + const storefrontLocale = await select({ + message: 'Which default language would you like to set for your channel?', + default: 'en', + choices: availableLocales, + theme: { + style: { + help: () => colorize('dim', '(Select locale from the list or start typing the name)'), + }, + }, + }); + + const shouldAddAdditionalLocales = await select({ + message: 'Would you like to add additional languages?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + + let additionalLocales: string[] = []; + + if (shouldAddAdditionalLocales) { + const localeOptions = availableLocales + .filter(({ value }) => value !== storefrontLocale) + .map(({ name, value }) => ({ label: name, value, hint: value })); + + // consola's multiselect returns the value strings at runtime, but its typed + // return is loose (the whole option array). Recursion + cast avoids the + // no-await-in-loop / no-constant-condition lint hits and re-prompts on overflow. + const pickLocales = async (): Promise => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selected = (await consola.prompt( + 'Which additional languages would you like to add to your channel?', + { type: 'multiselect', options: localeOptions, cancel: 'reject' }, + )) as unknown as string[]; + + if (selected.length > 4) { + consola.warn('You can only select up to 4 additional languages. Please try again.'); + + return pickLocales(); + } + + return selected; + }; + + additionalLocales = await pickLocales(); + } + + const shouldInstallSampleData = await select({ + message: 'Would you like to install sample data?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + + return createChannel( + newChannelName, + storefrontLocale, + additionalLocales, + shouldInstallSampleData, + storeHash, + accessToken, + cliApiOrigin, + ); +} + +async function handleChannelSelection(storeHash: string, accessToken: string, apiHost: string) { + const channelSortOrder = ['catalyst', 'next', 'bigcommerce']; + const channels = await fetchAvailableChannels(storeHash, accessToken, apiHost); + + const existingChannel = await select({ + message: 'Which channel would you like to use?', + choices: channels + .sort((a, b) => { + const aIndex = channelSortOrder.indexOf(a.platform); + const bIndex = channelSortOrder.indexOf(b.platform); + + if (aIndex === -1 && bIndex === -1) { + return 0; + } + + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + + return aIndex - bIndex; + }) + .map((ch) => ({ + name: ch.name, + value: ch, + description: `Channel Platform: ${ + ch.platform === 'bigcommerce' + ? 'Stencil' + : ch.platform.charAt(0).toUpperCase() + ch.platform.slice(1) + }`, + })), + }); + + return existingChannel.id; +} + +async function setupProject(options: { + projectName?: string; + projectDir: string; +}): Promise<{ projectName: string; projectDir: string }> { + let { projectName, projectDir } = options; + + if (!pathExistsSync(projectDir)) { + consola.error(`--project-dir ${projectDir} is not a valid path`); + process.exit(1); + } + + if (projectName) { + projectName = kebabCase(projectName); + projectDir = join(options.projectDir, projectName); + + if (pathExistsSync(projectDir)) { + consola.error(`${projectDir} already exists`); + process.exit(1); + } + } + + if (!projectName) { + const validateProjectName = (i: string) => { + const formatted = kebabCase(i); + + if (!formatted) return 'Project name is required'; + + const targetDir = join(options.projectDir, formatted); + + if (pathExistsSync(targetDir)) return `Destination '${targetDir}' already exists`; + + projectName = formatted; + projectDir = targetDir; + + return true; + }; + + await input({ + message: 'What do you want to name your project directory?', + default: 'my-catalyst-app', + validate: validateProjectName, + }); + } + + if (!projectName) throw new Error('Something went wrong, projectName is not defined'); + if (!projectDir) throw new Error('Something went wrong, projectDir is not defined'); + + return { projectName, projectDir }; +} + +function checkRequiredTools() { + try { + execSync(getPlatformCheckCommand('git'), { stdio: 'ignore' }); + } catch { + consola.error('git is required to create a Catalyst project'); + process.exit(1); + } + + try { + execSync(getPlatformCheckCommand('pnpm'), { stdio: 'ignore' }); + } catch { + consola.error( + 'pnpm is required to create a Catalyst project. Enable it by running `corepack enable pnpm`.', + ); + process.exit(1); + } +} + +export const create = new Command('create') + .configureHelp({ showGlobalOptions: true }) + .description('Scaffold and connect a Catalyst storefront to your BigCommerce store.') + .addHelpText( + 'after', + ` +Examples: + # Interactive scaffold (default — self-hosted, no hosting prompt) + $ catalyst create + + # Non-interactive: skip the project-name prompt + $ catalyst create --project-name my-store + + # Eagerly set up Commerce Hosting at create time + $ catalyst create --project-name my-store --hosting commerce`, + ) + .option('--project-name ', 'Name of your Catalyst project') + .option('--project-dir ', 'Directory in which to create your project', process.cwd()) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .option('--channel-id ', 'BigCommerce channel ID', parseChannelId) + .option('--storefront-token ', 'BigCommerce storefront token') + .option( + '--gh-ref ', + 'Clone a specific ref from the source repository', + '@bigcommerce/catalyst-core@latest', + ) + .option('--reset-main', 'Reset the main branch to the gh-ref') + .option('--repository ', 'GitHub repository to clone from', 'bigcommerce/catalyst') + .option( + '--env ', + 'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).', + parseEnvFlag, + ) + .addOption( + new Option( + '--hosting ', + 'Hosting mode: "self-hosted" (default) or "commerce" to set up Commerce Hosting at create time. When omitted, scaffolding is hosting-agnostic; run `catalyst deploy` later to opt in.', + ).choices(['self-hosted', 'commerce'] as const), + ) + .option( + '--use-existing', + 'Only used with --hosting commerce and --project-name. When the named project already exists on the store, reuse it instead of prompting. Has no effect without --hosting commerce.', + ) + .addOption( + new Option('--bigcommerce-hostname ', 'BigCommerce hostname') + .default('bigcommerce.com') + .hideHelp(), + ) + .addOption( + new Option('--login-url ', 'BigCommerce login URL.') + .env('BIGCOMMERCE_LOGIN_URL') + .default(DEFAULT_LOGIN_URL) + .hideHelp(), + ) + .addOption( + new Option('--cli-api-origin ', 'Catalyst CLI API origin') + .default('https://cxm-prd.bigcommerceapp.com') + .hideHelp(), + ) + // eslint-disable-next-line complexity + .action(async (options) => { + const { ghRef, repository } = options; + + if (options.useExisting && options.hosting !== 'commerce') { + consola.warn('--use-existing has no effect without --hosting commerce. Ignoring.'); + } + + checkRequiredTools(); + + const { projectName, projectDir } = await setupProject({ + projectName: options.projectName, + projectDir: options.projectDir, + }); + + let storeHash = options.storeHash; + let accessToken = options.accessToken; + let channelId = options.channelId; + let storefrontToken = options.storefrontToken; + + let envVars: Record = {}; + + // Always require store creds. `--channel-id` + `--storefront-token` aren't + // enough on their own — the storefront also needs BIGCOMMERCE_STORE_HASH at + // runtime, and downstream catalyst commands (deploy, project, ...) need an + // access token. Device login covers the missing pieces; the user picks the + // store during the OAuth flow regardless of any partial flags they passed. + if (!storeHash || !accessToken) { + const credentials = await login(options.loginUrl); + + storeHash = credentials.storeHash; + accessToken = credentials.accessToken; + } + + const useCommerceHosting = options.hosting === 'commerce'; + + await getTelemetry().identify(storeHash); + + // Seed env vars from local state when all three were flag-provided. Channel + // resolution below overwrites this wholesale via `envVars = { ...initData.envVars }`, + // which is fine — that path means the user wanted us to fetch fresh values. + if (storeHash && channelId && storefrontToken) { + envVars.BIGCOMMERCE_STORE_HASH = storeHash; + envVars.BIGCOMMERCE_CHANNEL_ID = channelId.toString(); + envVars.BIGCOMMERCE_STOREFRONT_TOKEN = storefrontToken; + } + + // Resolve channel only when we have creds and are missing channel info. + if (storeHash && accessToken && (!channelId || !storefrontToken)) { + const apiHost = `api.${options.bigcommerceHostname}`; + const cliApiOrigin = options.cliApiOrigin; + + if (channelId && !storefrontToken) { + const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin); + + envVars = { ...initData.envVars }; + storefrontToken = initData.storefrontToken; + } else if (!channelId) { + const eligibility = await checkChannelEligibility(storeHash, accessToken, cliApiOrigin); + + if (!eligibility.eligible) { + consola.warn(eligibility.message); + } + + let shouldCreateChannel; + + if (eligibility.eligible) { + shouldCreateChannel = await select({ + message: 'Would you like to create a new channel?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + } + + if (shouldCreateChannel) { + const channelData = await handleChannelCreation( + storeHash, + accessToken, + apiHost, + cliApiOrigin, + ); + + channelId = channelData.channelId; + storefrontToken = channelData.storefrontToken; + envVars = { ...channelData.envVars }; + + consola.success('Channel created successfully.'); + consola.warn( + '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.', + ); + } + + if (!shouldCreateChannel) { + channelId = await handleChannelSelection(storeHash, accessToken, apiHost); + + const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin); + + envVars = { ...initData.envVars }; + storefrontToken = initData.storefrontToken; + } + } + } + + if (options.env) { + Object.assign(envVars, options.env); + } + + if (options.storeHash) envVars.BIGCOMMERCE_STORE_HASH = options.storeHash; + if (options.channelId) envVars.BIGCOMMERCE_CHANNEL_ID = options.channelId.toString(); + if (options.storefrontToken) envVars.BIGCOMMERCE_STOREFRONT_TOKEN = options.storefrontToken; + + if (useCommerceHosting && accessToken) { + envVars.BIGCOMMERCE_ACCESS_TOKEN = accessToken; + } + + // Pre-populate CATALYST_ACCESS_TOKEN so subsequent catalyst CLI commands + // (`catalyst deploy`, `catalyst project ...`, etc.) work without re-auth. + // CATALYST_STORE_HASH isn't written — the CLI falls back to BIGCOMMERCE_STORE_HASH + // (already in envVars from the channel init response) at startup. + if (accessToken) envVars.CATALYST_ACCESS_TOKEN = accessToken; + + // Resolve the Commerce Hosting project before cloning so credential checks + // and prompts happen up-front. We defer the file mutations + // (`setupCommerceHosting`) until after the clone. + let commerceHostingProject: ProjectListItem | undefined; + + if (useCommerceHosting && storeHash && accessToken) { + const apiHost = `api.${options.bigcommerceHostname}`; + const hasAccess = await hasProjectsAccess(storeHash, accessToken, apiHost); + + if (!hasAccess) { + consola.error( + 'This store does not have access to the Infrastructure Projects API. Contact support@bigcommerce.com to enable it.', + ); + process.exit(1); + } + + commerceHostingProject = await promptForCommerceHostingProject( + { storeHash, accessToken, apiHost }, + projectName, + !!options.projectName, + options.useExisting, + ); + } + + consola.info(`Creating '${projectName}' at '${projectDir}'`); + + // Anything that mutates `projectDir` runs inside this block. If a step + // fails, the directory is likely partially populated — surface that to the + // user so they can clean up before retrying. We don't auto-delete because + // they may want to inspect the partial state first. + try { + cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain }); + setupCoreProject(projectDir); + + if (useCommerceHosting && commerceHostingProject && storeHash && accessToken) { + setupCommerceHosting({ + projectDir, + projectUuid: commerceHostingProject.uuid, + storeHash, + accessToken, + }); + } + + // Write env before install/build — keeps env vars available to any future + // workspace build script that might read them, and matters today for + // postinstall scripts that may resolve env-driven config. + writeEnv(projectDir, envVars); + + await installDependencies(projectDir); + buildWorkspacePackages(projectDir); + } catch (error) { + if (pathExistsSync(projectDir)) { + consola.warn( + `Setup failed before completion. '${projectDir}' may be in a partial state — review and delete it before re-running 'catalyst create'.`, + ); + } + + throw error; + } + + consola.success(`Created '${projectName}' at '${projectDir}'`); + consola.info('Next steps:'); + consola.info(colorize('yellow', ` cd ${projectName}/core && pnpm run dev`)); + + if (useCommerceHosting) { + consola.info( + colorize( + 'yellow', + ` Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`, + ), + ); + } + }); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index e15b8a5348..a723311415 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -17,9 +17,12 @@ import { import { server } from '../../../tests/mocks/node'; import { textHistory } from '../../../tests/mocks/spinner'; +import { setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { mkTempDir } from '../lib/mk-temp-dir'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; import { buildCatalystProject } from './build'; @@ -42,6 +45,36 @@ vi.mock('./build', async (importOriginal) => { return { ...actual, buildCatalystProject: vi.fn() }; }); +// Default to a transformed project so the deploy flow's transformation guard +// is a no-op for tests that don't care about it. Tests that exercise the +// guard override this per-case via `vi.mocked(getProjectState).mockReturnValueOnce(...)`. +vi.mock('../lib/project-state', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getProjectState: vi.fn(() => ({ + projectUuid: 'mock-uuid', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, + })), + }; +}); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, setupCommerceHosting: vi.fn() }; +}); + +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn(), +})); + let exitMock: MockInstance; let tmpDir: string; @@ -288,6 +321,136 @@ describe('deployment and event streaming', () => { }); }); +describe('linked project verification', () => { + test('proceeds when the linked project still exists on the server', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--dry-run', + ]); + + expect(consola.info).not.toHaveBeenCalledWith('No project is currently linked.'); + expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('no longer exists')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts for a new project when the linked uuid no longer exists', async () => { + const config = getProjectConfig(); + const staleUuid = '00000000-0000-0000-0000-000000000000'; + + config.set('projectUuid', staleUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(projectUuid)); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining(`The linked project (${staleUuid}) no longer exists`), + ); + expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); + expect(config.get('projectUuid')).toBe(projectUuid); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('prompts for a project when none is linked yet', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(projectUuid)); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(consola.info).toHaveBeenCalledWith('No project is currently linked.'); + expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); + expect(config.get('projectUuid')).toBe(projectUuid); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('offers to create when no projects exist on the store', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message) => { + expect(message).toContain('There are not any hosting projects that you can link to yet'); + + return Promise.resolve(true); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return Promise.resolve('My New Project'); + }); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('exits gracefully with guidance when user declines to create', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await expect(program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run'])).rejects.toThrow( + 'No infrastructure project linked', + ); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); +}); + test('--dry-run skips upload and deployment', async () => { await program.parseAsync([ 'node', @@ -561,3 +724,96 @@ describe('--prebuilt flag', () => { await emptyDistCleanup(); }); }); + +describe('transformation guard', () => { + const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, + }; + + test('runs setupCommerceHosting + installDependencies when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(consola.prompt).toHaveBeenCalledWith( + expect.stringContaining('not yet set up for Commerce Hosting deployments'), + expect.objectContaining({ type: 'confirm' }), + ); + expect(setupCommerceHosting).toHaveBeenCalledWith({ + projectDir: dirname(tmpDir), + projectUuid, + storeHash, + accessToken, + }); + expect(installDependencies).toHaveBeenCalledWith(dirname(tmpDir)); + }); + + test('exits gracefully when user declines to run setup', async () => { + vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); + vi.mocked(consola.prompt).mockResolvedValueOnce(false); + + // In production, process.exit halts. In tests it's mocked, so we can only + // verify the user-visible signals: the guidance log and the exit code. + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to deploy, re-run `catalyst deploy` to complete setup.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('skips setup when project is already transformed', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 482d06cfef..51d949dc6a 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -2,14 +2,27 @@ import AdmZip from 'adm-zip'; import { Command, Option } from 'commander'; import { colorize } from 'consola/utils'; import { access, readdir, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { + NoLinkedProjectError, + selectOrCreateInfrastructureProject, + setupCommerceHosting, +} from '../lib/commerce-hosting'; import { getDeploymentErrorMessage } from '../lib/deployment-errors'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + storeHashOption, +} from '../lib/shared-options'; import { getTelemetry } from '../lib/telemetry'; import { buildCatalystProject } from './build'; @@ -377,30 +390,10 @@ export const deploy = new Command('deploy') Example: $ catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN=`, ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Read from .bigcommerce/project.json when not provided.', - ).env('CATALYST_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account. Read from .bigcommerce/project.json when not provided.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) - .addOption( - new Option( - '--project-uuid ', - 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('CATALYST_PROJECT_UUID'), - ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) .addOption( new Option( '--secret ', @@ -418,15 +411,88 @@ Example: const config = getProjectConfig(); const { storeHash, accessToken } = resolveCredentials(options, config); const telemetry = getTelemetry(); - const projectUuid = options.projectUuid ?? config.get('projectUuid'); - 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 .', + await telemetry.identify(storeHash); + + // Resolve a *valid* projectUuid before doing any expensive build/upload + // work. If the linked UUID no longer exists on the server (e.g. project + // deleted out from under us), prompt the user to pick a new one rather + // than failing mid-deploy. + const linkedProjectUuid = options.projectUuid ?? config.get('projectUuid'); + let projectUuid: string; + + const promptForProject = async (): Promise<{ uuid: string; name: string }> => { + try { + return await selectOrCreateInfrastructureProject({ + storeHash, + accessToken, + apiHost: options.apiHost, + }); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", + ); + process.exit(0); + } + + throw error; + } + }; + + if (linkedProjectUuid) { + const existing = await fetchProject( + linkedProjectUuid, + storeHash, + accessToken, + options.apiHost, ); + + if (existing) { + projectUuid = linkedProjectUuid; + } else { + consola.warn( + `The linked project (${linkedProjectUuid}) no longer exists on this store. It may have been deleted.`, + ); + + const selected = await promptForProject(); + + projectUuid = selected.uuid; + config.set('projectUuid', projectUuid); + consola.success(`Linked project "${selected.name}".`); + } + } else { + consola.info('No project is currently linked.'); + + const selected = await promptForProject(); + + projectUuid = selected.uuid; + config.set('projectUuid', projectUuid); + consola.success(`Linked project "${selected.name}".`); } - await telemetry.identify(storeHash); + // The OpenNext build pipeline requires the project to be transformed + // (proxy.ts → middleware.ts, @opennextjs/cloudflare installed). Run setup + // here so first-run `catalyst deploy` works on a fresh self-hosted scaffold + // without forcing the user to re-run after a separate setup step. + if (!getProjectState().isTransformed) { + const shouldSetup = await consola.prompt( + 'Your project is not yet set up for Commerce Hosting deployments. Would you like to run the Commerce Hosting setup now?', + { type: 'confirm', initial: true }, + ); + + if (!shouldSetup) { + consola.info("When you're ready to deploy, re-run `catalyst deploy` to complete setup."); + process.exit(0); + } + + const projectDir = dirname(process.cwd()); + + setupCommerceHosting({ projectDir, projectUuid, storeHash, accessToken }); + consola.success('Commerce Hosting setup complete.'); + + await installDependencies(projectDir); + } if (options.prebuilt) { const distDir = join(process.cwd(), '.bigcommerce', 'dist'); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 79a4933a71..3f4871e860 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -1,16 +1,66 @@ import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; -import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + MockInstance, + test, + vi, +} from 'vitest'; import { server } from '../../../tests/mocks/node'; +import { setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { mkTempDir } from '../lib/mk-temp-dir'; import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; import { link, project } from './project'; +vi.mock('../lib/project-state', () => ({ + getProjectState: vi.fn(), +})); + +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + setupCommerceHosting: vi.fn(), + }; +}); + +const transformedState = { + projectUuid: 'abc-123', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, +}; + +const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, +}; + let exitMock: MockInstance; let tmpDir: string; @@ -60,6 +110,12 @@ beforeAll(async () => { config = getProjectConfig(); }); +beforeEach(() => { + // Default to a fully-transformed project so existing tests skip the + // post-link Commerce Hosting setup prompt. Override per-test as needed. + vi.mocked(getProjectState).mockReturnValue(transformedState); +}); + afterEach(() => { vi.clearAllMocks(); config.delete('storeHash'); @@ -231,6 +287,46 @@ describe('project list', () => { expect(exitMock).toHaveBeenCalledWith(0); }); + test('marks the currently linked project with [linked]', async () => { + config.set('projectUuid', projectUuid2); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'list', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + const logCalls = vi.mocked(consola.log).mock.calls.map(([msg]) => String(msg)); + + const linkedLine = logCalls.find((line) => line.includes(projectUuid2)); + const otherLine = logCalls.find((line) => line.includes(projectUuid1)); + + expect(linkedLine).toContain('[linked]'); + expect(otherLine).not.toContain('[linked]'); + }); + + test('does not mark any project when nothing is linked', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'list', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + const logCalls = vi.mocked(consola.log).mock.calls.map(([msg]) => String(msg)); + + expect(logCalls.every((line) => !line.includes('[linked]'))).toBe(true); + }); + test('with insufficient credentials exits with error', async () => { const savedStoreHash = process.env.CATALYST_STORE_HASH; const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; @@ -511,6 +607,70 @@ describe('project link', () => { consolaPromptMock.mockRestore(); }); + test('marks the currently linked project with [linked] in the select prompt', async () => { + config.set('projectUuid', projectUuid2); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + const linkedOption = options.find((o) => o.value === projectUuid2); + const otherOption = options.find((o) => o.value === projectUuid1); + + expect(linkedOption?.label).toContain('[linked]'); + expect(otherOption?.label).not.toContain('[linked]'); + + return Promise.resolve(projectUuid2); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + consolaPromptMock.mockRestore(); + }); + + test('exits gracefully with guidance when user declines to create from empty list', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow('No infrastructure project linked'); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst project link`.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + test('errors when infrastructure projects API is not found', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => @@ -538,6 +698,79 @@ describe('project link', () => { expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); }); + describe('post-link Commerce Hosting setup prompt', () => { + test('does not prompt when project is already transformed', async () => { + const consolaPromptMock = vi.spyOn(consola, 'prompt'); + + vi.mocked(getProjectState).mockReturnValue(transformedState); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + ]); + + const promptMessages = consolaPromptMock.mock.calls.map(([msg]) => msg); + + expect(promptMessages).not.toContain( + expect.stringContaining('not fully set up for Commerce Hosting'), + ); + + consolaPromptMock.mockRestore(); + }); + + test('prompts and runs setup when user accepts', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + const consolaPromptMock = vi.spyOn(consola, 'prompt').mockImplementation(async (message) => { + expect(message).toContain('not fully set up for Commerce Hosting'); + + return Promise.resolve(true); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + ]); + + expect(setupCommerceHosting).toHaveBeenCalledWith( + expect.objectContaining({ projectUuid: projectUuid1 }), + ); + expect(installDependencies).toHaveBeenCalled(); + + consolaPromptMock.mockRestore(); + }); + + test('skips setup when user declines', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + ]); + + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).not.toHaveBeenCalled(); + + consolaPromptMock.mockRestore(); + }); + }); + test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { await expect(program.parseAsync(['node', 'catalyst', 'project', 'link'])).rejects.toThrow( 'Missing credentials', diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index bec2ecb94e..d016494610 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -1,11 +1,55 @@ -import { Command, Option } from 'commander'; - +import { Command } from 'commander'; +import { colorize } from 'consola/utils'; +import { dirname } from 'node:path'; + +import { + NoLinkedProjectError, + selectOrCreateInfrastructureProject, + setupCommerceHosting, +} from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { createProject, fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + storeHashOption, +} from '../lib/shared-options'; import { getTelemetry } from '../lib/telemetry'; +// `catalyst project link` runs from inside `core/`, so the project root (which +// `setupCommerceHosting` and `installDependencies` expect) is one level up. +async function offerCommerceHostingSetup( + projectUuid: string, + credentials?: { storeHash: string; accessToken: string }, +) { + if (getProjectState().isTransformed) return; + + const shouldSetup = await consola.prompt( + 'Your project has been linked, but is not fully set up for Commerce Hosting deployments yet. Would you like to run the setup now?', + { type: 'confirm', initial: true }, + ); + + if (!shouldSetup) return; + + const projectDir = dirname(process.cwd()); + + setupCommerceHosting({ + projectDir, + projectUuid, + storeHash: credentials?.storeHash, + accessToken: credentials?.accessToken, + }); + + consola.success('Commerce Hosting setup complete.'); + + await installDependencies(projectDir); +} + const list = new Command('list') .configureHelp({ showGlobalOptions: true }) .description('List BigCommerce infrastructure projects for your store.') @@ -15,24 +59,9 @@ const list = new Command('list') Example: $ catalyst project list`, ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('CATALYST_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) .action(async (options) => { const config = getProjectConfig(); const { storeHash, accessToken } = resolveCredentials(options, config); @@ -52,8 +81,12 @@ Example: return; } + const linkedProjectUuid = config.get('projectUuid'); + projects.forEach((p) => { - consola.log(`${p.name} (${p.uuid})`); + const marker = p.uuid === linkedProjectUuid ? ` ${colorize('green', '[linked]')}` : ''; + + consola.log(`${p.name} (${p.uuid})${marker}`); }); process.exit(0); @@ -70,24 +103,9 @@ const create = new Command('create') Example: $ catalyst project create`, ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('CATALYST_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) .action(async (options) => { const config = getProjectConfig(); const { storeHash, accessToken } = resolveCredentials(options, config); @@ -126,28 +144,10 @@ Examples: # Link using a project UUID directly $ catalyst project link --project-uuid `, ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('CATALYST_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) - .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.', - ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) .action(async (options) => { const config = getProjectConfig(); @@ -168,6 +168,7 @@ Examples: if (options.projectUuid) { writeProjectConfig(options.projectUuid); + await offerCommerceHostingSetup(options.projectUuid); process.exit(0); @@ -178,47 +179,29 @@ Examples: await getTelemetry().identify(storeHash); - consola.start('Fetching projects...'); - - const projects = await fetchProjects(storeHash, accessToken, options.apiHost); - - consola.success('Projects fetched.'); + let selected; + + try { + selected = await selectOrCreateInfrastructureProject( + { storeHash, accessToken, apiHost: options.apiHost }, + config.get('projectUuid'), + ); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst project link`.", + ); + process.exit(0); + + // Unreachable in production; prevents continuation when process.exit is mocked in tests. + throw error; + } - const promptOptions = [ - ...projects.map((proj) => ({ - label: proj.name, - value: proj.uuid, - hint: proj.uuid, - })), - { - label: 'Create a new project', - value: 'create', - hint: 'Create a new infrastructure project for this BigCommerce store.', - }, - ]; - - let projectUuid = await consola.prompt( - 'Select a project or create a new project (Press to select).', - { - type: 'select', - options: promptOptions, - cancel: 'reject', - }, - ); - - if (projectUuid === 'create') { - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); - - const data = await createProject(newProjectName, storeHash, accessToken, options.apiHost); - - projectUuid = data.uuid; - - consola.success(`Project "${data.name}" created successfully.`); + throw error; } - writeProjectConfig(projectUuid, { storeHash, accessToken }); + writeProjectConfig(selected.uuid, { storeHash, accessToken }); + await offerCommerceHostingSetup(selected.uuid, { storeHash, accessToken }); process.exit(0); }); diff --git a/packages/catalyst/src/cli/commands/start.spec.ts b/packages/catalyst/src/cli/commands/start.spec.ts index 5ee3e0ec38..b442d00184 100644 --- a/packages/catalyst/src/cli/commands/start.spec.ts +++ b/packages/catalyst/src/cli/commands/start.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; import { consola } from '../lib/logger'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; import { start } from './start'; @@ -20,12 +21,37 @@ vi.mock('execa', () => ({ __esModule: true, })); +vi.mock('../lib/project-state', () => ({ + getProjectState: vi.fn(), +})); + +const transformedState = { + projectUuid: 'abc-123', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, +}; + +const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, +}; + beforeAll(() => { consola.wrapAll(); }); beforeEach(() => { consola.mockTypes(() => vi.fn()); + vi.mocked(getProjectState).mockReturnValue(transformedState); }); afterEach(() => { @@ -93,3 +119,16 @@ test('warns when .env.local does not exist', async () => { expect(symlinkSync).not.toHaveBeenCalled(); }); + +test('falls through to `next start` when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + await program.parseAsync(['node', 'catalyst', 'start']); + + expect(execa).toHaveBeenCalledWith( + 'pnpm', + ['exec', 'next', 'start'], + expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }), + ); + expect(symlinkSync).not.toHaveBeenCalled(); +}); diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index 468cc6309e..9d1749ad9e 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -4,6 +4,7 @@ import { existsSync, lstatSync, symlinkSync } from 'node:fs'; import { join, relative } from 'node:path'; import { consola } from '../lib/logger'; +import { getProjectState } from '../lib/project-state'; export const start = new Command('start') .configureHelp({ showGlobalOptions: true }) @@ -17,6 +18,22 @@ Example: $ catalyst start`, ) .action(async () => { + // Project must be transformed before the OpenNext preview can run. If it + // isn't, fall through to `next start` so this command works for self-hosted + // Catalyst projects too. + const state = getProjectState(); + + if (!state.isTransformed) { + consola.info('Project is not set up for Commerce Hosting — running `next start`.'); + + await execa('pnpm', ['exec', 'next', 'start'], { + stdio: 'inherit', + cwd: process.cwd(), + }); + + return; + } + const envLocal = join(process.cwd(), '.env.local'); const devVars = join(process.cwd(), '.bigcommerce', '.dev.vars'); diff --git a/packages/catalyst/src/cli/commands/telemetry.ts b/packages/catalyst/src/cli/commands/telemetry.ts index c60b76e0dd..9075e73dcb 100644 --- a/packages/catalyst/src/cli/commands/telemetry.ts +++ b/packages/catalyst/src/cli/commands/telemetry.ts @@ -38,7 +38,7 @@ Examples: telemetryService.setEnabled(false); if (isEnabled) { - consola.success('Your preference has been saved to .bigcommerce/project.json'); + consola.success('Your preference has been saved.'); } else { consola.info(`Catalyst CLI telemetry collection is already disabled.`); } diff --git a/packages/catalyst/src/cli/lib/build-workspace-packages.ts b/packages/catalyst/src/cli/lib/build-workspace-packages.ts new file mode 100644 index 0000000000..4533f72701 --- /dev/null +++ b/packages/catalyst/src/cli/lib/build-workspace-packages.ts @@ -0,0 +1,33 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import yoctoSpinner from 'yocto-spinner'; + +const WORKSPACE_PACKAGES = ['packages/catalyst', 'packages/client'] as const; + +// Catalyst monorepo layouts ship pre-built workspace packages, but a fresh +// clone needs them rebuilt before `core` can resolve them. Skip silently when +// the layout doesn't match (e.g. flat repo, custom fork) so this stays a no-op +// for non-monorepo scaffolds. +export const buildWorkspacePackages = (projectDir: string) => { + const hasCore = existsSync(join(projectDir, 'core')); + const hasAllWorkspacePackages = WORKSPACE_PACKAGES.every((pkg) => + existsSync(join(projectDir, pkg)), + ); + + if (!hasCore || !hasAllWorkspacePackages) return; + + WORKSPACE_PACKAGES.forEach((pkg) => { + const spinner = yoctoSpinner().start(`Building ${pkg}...`); + + try { + execSync('pnpm build', { cwd: join(projectDir, pkg), stdio: 'ignore' }); + spinner.success(`Built ${pkg}.`); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + spinner.error(`Failed to build ${pkg}: ${message}`); + throw error; + } + }); +}; diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts new file mode 100644 index 0000000000..81e3cd0178 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -0,0 +1,175 @@ +import { z } from 'zod'; + +import { getTelemetry } from './telemetry'; + +// `origin` is the CLI-API gateway (configured via `--cli-api-origin`, default +// `https://cxm-prd.bigcommerceapp.com`). Distinct from `apiHost` used for the +// BC store API (api.bigcommerce.com). +const cliApiUrl = (origin: string, storeHash: string, path: string) => + `${origin}/stores/${storeHash}/cli-api/v3${path}`; + +const channelsUrl = (storeHash: string, apiHost: string, query: Record = {}) => { + const params = new URLSearchParams(query).toString(); + + return `https://${apiHost}/stores/${storeHash}/v3/channels${params ? `?${params}` : ''}`; +}; + +const authHeaders = (accessToken: string) => ({ + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + Accept: 'application/json', +}); + +// envVars values are coerced to strings: the BC API returns mixed primitives +// (e.g. BIGCOMMERCE_CHANNEL_ID is a number) but they all end up in .env.local as text. +const envVarsSchema = z.record(z.string(), z.coerce.string()); + +const channelSchema = z.object({ + id: z.number(), + name: z.string(), + platform: z.string(), +}); + +export type Channel = z.infer; + +const channelsResponseSchema = z.object({ + data: z.array(channelSchema), +}); + +const initResponseSchema = z.object({ + data: z.object({ + storefront_api_token: z.string(), + envVars: envVarsSchema, + }), +}); + +const createChannelResponseSchema = z.object({ + data: z.object({ + id: z.number(), + storefront_api_token: z.string(), + envVars: envVarsSchema, + }), +}); + +const eligibilityResponseSchema = z.object({ + data: z.object({ + eligible: z.boolean(), + message: z.string(), + }), +}); + +export interface ChannelInit { + storefrontToken: string; + envVars: Record; +} + +export async function getChannelInit( + channelId: number | string, + storeHash: string, + accessToken: string, + origin: string, +): Promise { + const response = await fetch(cliApiUrl(origin, storeHash, `/channels/${channelId}/init`), { + method: 'GET', + headers: authHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error( + `GET /channels/${channelId}/init failed: ${response.status} ${response.statusText}`, + ); + } + + const { data } = initResponseSchema.parse(await response.json()); + + return { storefrontToken: data.storefront_api_token, envVars: data.envVars }; +} + +export interface ChannelEligibility { + eligible: boolean; + message: string; +} + +export async function checkChannelEligibility( + storeHash: string, + accessToken: string, + origin: string, +): Promise { + const response = await fetch(cliApiUrl(origin, storeHash, '/channels/catalyst/eligibility'), { + method: 'GET', + headers: authHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error( + `GET /channels/catalyst/eligibility failed: ${response.status} ${response.statusText}`, + ); + } + + return eligibilityResponseSchema.parse(await response.json()).data; +} + +export interface CreatedChannel { + channelId: number; + storefrontToken: string; + envVars: Record; +} + +export async function createChannel( + name: string, + storefrontLocale: string, + additionalLocales: string[], + installSampleData: boolean, + storeHash: string, + accessToken: string, + origin: string, +): Promise { + const response = await fetch(cliApiUrl(origin, storeHash, '/channels/catalyst'), { + method: 'POST', + headers: { ...authHeaders(accessToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + initialData: { type: installSampleData ? 'sample' : 'none' }, + deployStorefront: true, + devOrigin: 'http://localhost:3000', + storefrontLanguage: storefrontLocale, + additionalLocales, + }), + }); + + if (!response.ok) { + throw new Error(`POST /channels/catalyst failed: ${response.status} ${response.statusText}`); + } + + const { data } = createChannelResponseSchema.parse(await response.json()); + + return { + channelId: data.id, + storefrontToken: data.storefront_api_token, + envVars: data.envVars, + }; +} + +export async function fetchAvailableChannels( + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + channelsUrl(storeHash, apiHost, { available: 'true', type: 'storefront' }), + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + Accept: 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`GET /v3/channels failed: ${response.status} ${response.statusText}`); + } + + return channelsResponseSchema.parse(await response.json()).data; +} diff --git a/packages/catalyst/src/cli/lib/checkout-ref.ts b/packages/catalyst/src/cli/lib/checkout-ref.ts new file mode 100644 index 0000000000..1bddda6fd5 --- /dev/null +++ b/packages/catalyst/src/cli/lib/checkout-ref.ts @@ -0,0 +1,42 @@ +import { sync as spawnSync } from 'cross-spawn'; + +import { isExecException } from './is-exec-exception'; +import { consola } from './logger'; + +export function checkoutRef(repoDir: string, ref: string): void { + try { + const spawn = spawnSync('git', ['checkout', ref, '--'], { + cwd: repoDir, + encoding: 'utf8', + shell: false, + }); + + const stderr = spawn.stderr.trim(); + + if (spawn.status !== 0 && stderr) { + throw new Error(stderr); + } + + consola.success(`Checked out ref ${ref} successfully.`); + } catch (error: unknown) { + if (isExecException(error)) { + const stderr = error.stderr ? error.stderr.toString() : ''; + + if ( + stderr.includes(`fatal: reference is not a tree: ${ref}`) || + stderr.includes(`fatal: ambiguous argument '${ref}'`) || + stderr.includes(`unknown revision or path not in the working tree`) + ) { + consola.error(`Ref '${ref}' not found in the repository.`); + } else { + consola.error(`Error checking out ref '${ref}':`, stderr.trim()); + } + } else if (error instanceof Error) { + consola.error(`Error checking out ref '${ref}':`, error.message); + } else { + consola.error(`Unknown error occurred while checking out ref '${ref}'.`); + } + + consola.warn(`Falling back to the default branch.`); + } +} diff --git a/packages/catalyst/src/cli/lib/clone-catalyst.ts b/packages/catalyst/src/cli/lib/clone-catalyst.ts new file mode 100644 index 0000000000..52eb009ace --- /dev/null +++ b/packages/catalyst/src/cli/lib/clone-catalyst.ts @@ -0,0 +1,46 @@ +import { execSync } from 'child_process'; + +import { checkoutRef } from './checkout-ref'; +import { hasGitHubSSH } from './has-github-ssh'; +import { consola } from './logger'; +import { resetBranchToRef } from './reset-branch-to-ref'; + +export const cloneCatalyst = ({ + repository, + projectName, + projectDir, + ghRef, + resetMain = false, +}: { + repository: string; + projectName: string; + projectDir: string; + ghRef?: string; + resetMain?: boolean; +}) => { + const useSSH = hasGitHubSSH(); + + consola.info(`Cloning ${repository} using ${useSSH ? 'SSH' : 'HTTPS'}...`); + + const cloneCommand = `git clone ${ + useSSH ? `git@github.com:${repository}` : `https://github.com/${repository}` + }.git${projectName ? ` ${projectName}` : ''}`; + + execSync(cloneCommand, { stdio: 'inherit' }); + + execSync('git remote rename origin upstream', { cwd: projectDir, stdio: 'inherit' }); + + if (ghRef) { + if (resetMain) { + execSync('git checkout -b main', { cwd: projectDir, stdio: 'inherit' }); + + resetBranchToRef(projectDir, ghRef); + + consola.success(`Reset main to ${ghRef} successfully.`); + + return; + } + + checkoutRef(projectDir, ghRef); + } +}; diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts new file mode 100644 index 0000000000..473e0d7e1f --- /dev/null +++ b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts @@ -0,0 +1,806 @@ +import { input, select } from '@inquirer/prompts'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { z } from 'zod'; + +import { + promptAndCreateCommerceHostingProject, + promptForCommerceHostingProject, + setupCommerceHosting, +} from './commerce-hosting'; +import * as projectLib from './project'; +import { InfrastructureProjectValidationError } from './project'; + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + select: vi.fn(), + Separator: class FakeSeparator { + type = 'separator'; + }, +})); + +const inputMock = vi.mocked(input); +const selectMock = vi.mocked(select); + +const API = { storeHash: 'store', accessToken: 'token', apiHost: 'api.example.com' }; + +function withTtyValue(value: boolean): () => void { + const previous = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + + Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); + + return () => { + if (previous) { + Object.defineProperty(process.stdin, 'isTTY', previous); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } + }; +} + +const withInteractiveTty = () => withTtyValue(true); +const withNonInteractiveTty = () => withTtyValue(false); + +let fetchProjectsSpy: MockInstance; +let createProjectSpy: MockInstance; +let consoleErrorSpy: MockInstance<(typeof console)['error']>; + +beforeEach(() => { + inputMock.mockReset(); + selectMock.mockReset(); + fetchProjectsSpy = vi.spyOn(projectLib, 'fetchProjects').mockResolvedValue([]); + createProjectSpy = vi.spyOn(projectLib, 'createProject'); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + consoleErrorSpy.mockRestore(); +}); + +function createdProject(uuid: string, name: string) { + return { + uuid, + name, + date_created: new Date(), + date_modified: new Date(), + }; +} + +describe('promptAndCreateCommerceHostingProject', () => { + it('returns the created project when the first attempt succeeds', async () => { + inputMock.mockResolvedValueOnce('my-project'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'my-project')); + + const result = await promptAndCreateCommerceHostingProject(API, []); + + expect(result).toEqual({ uuid: 'u', name: 'my-project' }); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + expect(createProjectSpy).toHaveBeenCalledWith( + 'my-project', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('trims whitespace from the entered name before calling the API', async () => { + inputMock.mockResolvedValueOnce(' spaced '); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'spaced')); + + await promptAndCreateCommerceHostingProject(API, []); + + expect(createProjectSpy).toHaveBeenCalledWith( + 'spaced', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('re-prompts after a validation error and succeeds on retry', async () => { + inputMock.mockResolvedValueOnce('###').mockResolvedValueOnce('good-name'); + + createProjectSpy + .mockRejectedValueOnce(new InfrastructureProjectValidationError('Invalid name')) + .mockResolvedValueOnce(createdProject('u', 'good-name')); + + const result = await promptAndCreateCommerceHostingProject(API, []); + + expect(result).toEqual({ uuid: 'u', name: 'good-name' }); + expect(inputMock).toHaveBeenCalledTimes(2); + expect(createProjectSpy).toHaveBeenCalledTimes(2); + }); + + it('re-prompts multiple times until the server accepts the name', async () => { + inputMock + .mockResolvedValueOnce('bad1') + .mockResolvedValueOnce('bad2') + .mockResolvedValueOnce('good'); + + createProjectSpy + .mockRejectedValueOnce(new InfrastructureProjectValidationError('first failure')) + .mockRejectedValueOnce(new InfrastructureProjectValidationError('second failure')) + .mockResolvedValueOnce(createdProject('u', 'good')); + + const result = await promptAndCreateCommerceHostingProject(API, []); + + expect(result).toEqual({ uuid: 'u', name: 'good' }); + expect(inputMock).toHaveBeenCalledTimes(3); + expect(createProjectSpy).toHaveBeenCalledTimes(3); + }); + + it('does not retry on non-validation errors', async () => { + inputMock.mockResolvedValueOnce('whatever'); + createProjectSpy.mockRejectedValueOnce(new Error('500 server error')); + + await expect(promptAndCreateCommerceHostingProject(API, [])).rejects.toThrow( + '500 server error', + ); + + expect(inputMock).toHaveBeenCalledTimes(1); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + }); + + it('passes the supplied default name to the initial prompt', async () => { + inputMock.mockResolvedValueOnce('my-catalyst-store'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'my-catalyst-store')); + + await promptAndCreateCommerceHostingProject(API, [], 'my-catalyst-store'); + + expect(inputMock.mock.calls[0]?.[0].default).toBe('my-catalyst-store'); + }); + + it('preserves the original default on retry so the user is not stuck with the rejected value', async () => { + inputMock.mockResolvedValueOnce('bad-name').mockResolvedValueOnce('fixed-name'); + + createProjectSpy + .mockRejectedValueOnce(new InfrastructureProjectValidationError('Invalid')) + .mockResolvedValueOnce(createdProject('u', 'fixed-name')); + + await promptAndCreateCommerceHostingProject(API, [], 'original-default'); + + expect(inputMock.mock.calls[0]?.[0].default).toBe('original-default'); + expect(inputMock.mock.calls[1]?.[0].default).toBe('original-default'); + }); + + it('uses a validator that rejects empty input', async () => { + inputMock.mockResolvedValueOnce('ok'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'ok')); + + await promptAndCreateCommerceHostingProject(API, []); + + const validator = inputMock.mock.calls[0]?.[0].validate; + + expect(validator).toBeDefined(); + expect(validator?.('')).toBe('Project name is required'); + expect(validator?.(' ')).toBe('Project name is required'); + expect(validator?.('name')).toBe(true); + }); + + it('rejects names that already exist on the store', async () => { + inputMock.mockResolvedValueOnce('available'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'available')); + + await promptAndCreateCommerceHostingProject(API, ['taken-one', 'taken-two']); + + const validator = inputMock.mock.calls[0]?.[0].validate; + + expect(validator).toBeDefined(); + expect(validator?.('taken-one')).toBe( + 'A Commerce Hosting project named "taken-one" already exists', + ); + expect(validator?.(' taken-two ')).toBe( + 'A Commerce Hosting project named "taken-two" already exists', + ); + expect(validator?.('available')).toBe(true); + }); + + it('rejects names that match an existing project case-insensitively, and reports the stored name', async () => { + inputMock.mockResolvedValueOnce('different'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'different')); + + await promptAndCreateCommerceHostingProject(API, ['MyProject']); + + const validator = inputMock.mock.calls[0]?.[0].validate; + + expect(validator?.('myproject')).toBe( + 'A Commerce Hosting project named "MyProject" already exists', + ); + expect(validator?.('MYPROJECT')).toBe( + 'A Commerce Hosting project named "MyProject" already exists', + ); + expect(validator?.(' MyProject ')).toBe( + 'A Commerce Hosting project named "MyProject" already exists', + ); + }); +}); + +describe('promptForCommerceHostingProject', () => { + it('silently auto-creates with the supplied default name when no Commerce Hosting project conflicts (no existing projects)', async () => { + fetchProjectsSpy.mockResolvedValue([]); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'fresh')); + + const result = await promptForCommerceHostingProject(API, 'fresh'); + + expect(result).toEqual({ uuid: 'u', name: 'fresh' }); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledWith( + 'fresh', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('silently auto-creates with the supplied default name when other projects exist but none conflict', async () => { + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'aaa', name: 'unrelated-one' }, + { uuid: 'bbb', name: 'unrelated-two' }, + ]); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'my-store')); + + const result = await promptForCommerceHostingProject(API, 'my-store'); + + expect(result).toEqual({ uuid: 'new', name: 'my-store' }); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledWith( + 'my-store', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('returns a selected existing project without calling create', async () => { + const existing = [ + { uuid: 'aaa', name: 'first' }, + { uuid: 'bbb', name: 'second' }, + ]; + + fetchProjectsSpy.mockResolvedValue(existing); + + selectMock + .mockResolvedValueOnce('select-from-list') + .mockResolvedValueOnce({ uuid: 'bbb', name: 'second' }); + + const result = await promptForCommerceHostingProject(API, 'first'); + + expect(result).toEqual({ uuid: 'bbb', name: 'second' }); + expect(createProjectSpy).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalledTimes(2); + }); + + it('routes to the create flow when the default name conflicts and the user chooses to create a new project', async () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'default-name' }]); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'new-proj')); + + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('new-proj'); + + const result = await promptForCommerceHostingProject(API, 'default-name'); + + expect(result).toEqual({ uuid: 'new', name: 'new-proj' }); + expect(createProjectSpy).toHaveBeenCalledWith( + 'new-proj', + API.storeHash, + API.accessToken, + API.apiHost, + ); + expect(inputMock.mock.calls[0]?.[0].default).toBe('default-name'); + }); + + it('shows the conflict-aware message and three choices when a conflict exists', async () => { + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'aaa', name: 'My-Store' }, + { uuid: 'bbb', name: 'other-project' }, + ]); + + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('something-else'); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'something-else')); + + await promptForCommerceHostingProject(API, 'my-store'); + + expect(selectMock.mock.calls[0]?.[0].message).toBe( + 'It looks like you already have an existing Commerce Hosting project named "My-Store". Would you like to use it, select from your projects, or create a new one?', + ); + expect(selectMock.mock.calls[0]?.[0].choices).toEqual([ + { name: 'Use "My-Store"', value: 'use-named' }, + { name: 'Select from my projects', value: 'select-from-list' }, + { name: 'Create a new project', value: 'create' }, + ]); + }); + + it('returns the conflicting project directly when the user picks Use ""', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + + fetchProjectsSpy.mockResolvedValue([conflict, { uuid: 'bbb', name: 'other-project' }]); + + selectMock.mockResolvedValueOnce('use-named'); + + const result = await promptForCommerceHostingProject(API, 'my-store'); + + expect(result).toEqual(conflict); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(createProjectSpy).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + }); + + it('shows all projects (including the conflict) and a "Create a new project" option in the list', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + const other = { uuid: 'bbb', name: 'other-project' }; + + fetchProjectsSpy.mockResolvedValue([conflict, other]); + + selectMock.mockResolvedValueOnce('select-from-list').mockResolvedValueOnce(other); + + const result = await promptForCommerceHostingProject(API, 'my-store'); + + expect(result).toEqual(other); + + const projectChoices = selectMock.mock.calls[1]?.[0].choices ?? []; + + expect(projectChoices[0]).toEqual({ name: 'My-Store', value: conflict, description: 'aaa' }); + expect(projectChoices[1]).toEqual({ + name: 'other-project', + value: other, + description: 'bbb', + }); + expect(projectChoices[projectChoices.length - 1]).toEqual({ + name: 'Create a new project', + value: 'create-new', + }); + }); + + it('routes to the create flow when the user picks "Create a new project" from the list', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + const other = { uuid: 'bbb', name: 'other-project' }; + + fetchProjectsSpy.mockResolvedValue([conflict, other]); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'fresh-name')); + + selectMock.mockResolvedValueOnce('select-from-list').mockResolvedValueOnce('create-new'); + inputMock.mockResolvedValueOnce('fresh-name'); + + const result = await promptForCommerceHostingProject(API, 'my-store'); + + expect(result).toEqual({ uuid: 'new', name: 'fresh-name' }); + expect(createProjectSpy).toHaveBeenCalledWith( + 'fresh-name', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('omits Select from my projects when the conflict is the only existing project', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + + fetchProjectsSpy.mockResolvedValue([conflict]); + + selectMock.mockResolvedValueOnce('use-named'); + + await promptForCommerceHostingProject(API, 'my-store'); + + expect(selectMock.mock.calls[0]?.[0].choices).toEqual([ + { name: 'Use "My-Store"', value: 'use-named' }, + { name: 'Create a new project', value: 'create' }, + ]); + expect(selectMock.mock.calls[0]?.[0].message).toBe( + 'It looks like you already have an existing Commerce Hosting project named "My-Store". Would you like to use it, or create a new one?', + ); + }); + + it('skips all prompts and creates with the supplied name when autoUseDefaultName is true', async () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'other', name: 'unrelated' }]); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'auto-name')); + + const result = await promptForCommerceHostingProject(API, 'auto-name', true); + + expect(result).toEqual({ uuid: 'u', name: 'auto-name' }); + expect(fetchProjectsSpy).toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledWith( + 'auto-name', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('returns the existing project when --project-name collides and the user picks Yes', async () => { + const existing = { uuid: 'aaa', name: 'taken-name' }; + + fetchProjectsSpy.mockResolvedValue([existing]); + + selectMock.mockResolvedValueOnce(true); + + const restoreTty = withInteractiveTty(); + + try { + const result = await promptForCommerceHostingProject(API, 'taken-name', true); + + expect(result).toEqual(existing); + expect(createProjectSpy).not.toHaveBeenCalled(); + expect(selectMock.mock.calls[0]?.[0].message).toMatch( + /A Commerce Hosting project named "taken-name" already exists/, + ); + } finally { + restoreTty(); + } + }); + + it('reuses the existing project without prompting when --use-existing is passed', async () => { + const existing = { uuid: 'aaa', name: 'taken-name' }; + + fetchProjectsSpy.mockResolvedValue([existing]); + + const result = await promptForCommerceHostingProject(API, 'taken-name', true, true); + + expect(result).toEqual(existing); + expect(selectMock).not.toHaveBeenCalled(); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + it('exits without prompting in non-interactive environments when --use-existing is not passed', async () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + const restoreTty = withNonInteractiveTty(); + + try { + await expect(promptForCommerceHostingProject(API, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(selectMock).not.toHaveBeenCalled(); + expect(createProjectSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + restoreTty(); + } + }); + + it('detects --project-name collision case-insensitively and reports the stored name', async () => { + const existing = { uuid: 'aaa', name: 'MyProject' }; + + fetchProjectsSpy.mockResolvedValue([existing]); + + selectMock.mockResolvedValueOnce(true); + + const restoreTty = withInteractiveTty(); + + try { + const result = await promptForCommerceHostingProject(API, 'myproject', true); + + expect(result).toEqual(existing); + expect(createProjectSpy).not.toHaveBeenCalled(); + expect(selectMock.mock.calls[0]?.[0].message).toMatch( + /A Commerce Hosting project named "MyProject" already exists/, + ); + } finally { + restoreTty(); + } + }); + + it('exits when --project-name collides and the user picks No', async () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + + selectMock.mockResolvedValueOnce(false); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + const restoreTty = withInteractiveTty(); + + try { + await expect(promptForCommerceHostingProject(API, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(createProjectSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + restoreTty(); + } + }); + + it('exits the process when auto-create fails with a validation error instead of re-prompting', async () => { + fetchProjectsSpy.mockResolvedValue([]); + createProjectSpy.mockRejectedValueOnce( + new InfrastructureProjectValidationError('Name already taken'), + ); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + + await expect(promptForCommerceHostingProject(API, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(inputMock).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + + exitSpy.mockRestore(); + }); + + it('propagates errors from fetchProjects', async () => { + fetchProjectsSpy.mockRejectedValue(new Error('network down')); + + await expect(promptForCommerceHostingProject(API, 'whatever')).rejects.toThrow('network down'); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + }); +}); + +describe('setupCommerceHosting', () => { + const packageJsonSchema = z.record(z.string(), z.unknown()); + const projectJsonSchema = z.object({ + projectUuid: z.string(), + framework: z.string(), + storeHash: z.string().optional(), + accessToken: z.string().optional(), + }); + + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'catalyst-create-test-')); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + function writeCorePackageJson(contents: unknown) { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); + } + + function writeCoreProxyFile(contents: string) { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'proxy.ts'), contents); + } + + function readCorePackageJson() { + return packageJsonSchema.parse( + JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), + ); + } + + function readProjectJson() { + return projectJsonSchema.parse( + JSON.parse(readFileSync(join(projectDir, 'core', '.bigcommerce', 'project.json'), 'utf-8')), + ); + } + + it('adds the OpenNext Cloudflare dep while preserving existing dependencies', () => { + writeCorePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { next: '^15.0.0', react: '^18.0.0' }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const pkg = readCorePackageJson(); + + expect(pkg.dependencies).toMatchObject({ next: '^15.0.0', react: '^18.0.0' }); + expect(pkg.dependencies).toHaveProperty('@opennextjs/cloudflare'); + }); + + it('does not modify package.json scripts (handled by setupCoreProject)', () => { + writeCorePackageJson({ + scripts: { + dev: 'npm run generate && next dev', + build: 'npm run generate && next build', + start: 'next start', + }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + expect(readCorePackageJson().scripts).toEqual({ + dev: 'npm run generate && next dev', + build: 'npm run generate && next build', + start: 'next start', + }); + }); + + it('preserves unrelated top-level package.json fields', () => { + writeCorePackageJson({ + name: '@bigcommerce/catalyst-core', + description: 'test description', + version: '1.2.3', + private: true, + scripts: { dev: 'next dev' }, + devDependencies: { jest: '^29.0.0' }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const pkg = readCorePackageJson(); + + expect(pkg.name).toBe('@bigcommerce/catalyst-core'); + expect(pkg.description).toBe('test description'); + expect(pkg.version).toBe('1.2.3'); + expect(pkg.private).toBe(true); + expect(pkg.devDependencies).toEqual({ jest: '^29.0.0' }); + }); + + it('writes core/.bigcommerce/project.json with the correct shape', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); + + expect(readProjectJson()).toEqual({ projectUuid: 'uuid-xyz', framework: 'catalyst' }); + }); + + it('includes storeHash and accessToken in project.json when provided', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ + projectDir, + projectUuid: 'uuid-xyz', + storeHash: 'abc123', + accessToken: 'token-xyz', + }); + + expect(readProjectJson()).toEqual({ + projectUuid: 'uuid-xyz', + framework: 'catalyst', + storeHash: 'abc123', + accessToken: 'token-xyz', + }); + }); + + it('omits storeHash and accessToken when not provided', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); + + const projectJson = readProjectJson(); + + expect(projectJson.storeHash).toBeUndefined(); + expect(projectJson.accessToken).toBeUndefined(); + }); + + it('includes only the credentials that are provided', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ + projectDir, + projectUuid: 'uuid-xyz', + storeHash: 'abc123', + }); + + const projectJson = readProjectJson(); + + expect(projectJson.storeHash).toBe('abc123'); + expect(projectJson.accessToken).toBeUndefined(); + }); + + it('throws when core/package.json is missing', () => { + expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).toThrow(); + }); + + it('throws when core/package.json has an invalid shape', () => { + writeCorePackageJson({ dependencies: { next: 42 } }); + + expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).toThrow(); + }); + + describe('core/.env.local symlink', () => { + it('creates a symlink at core/.env.local pointing to ../.env.local', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const coreEnvPath = join(projectDir, 'core', '.env.local'); + + expect(lstatSync(coreEnvPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(coreEnvPath)).toBe(join('..', '.env.local')); + }); + + it('keeps both files in sync via the symlink target', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + writeFileSync(join(projectDir, '.env.local'), 'FOO=bar\n'); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + expect(readFileSync(join(projectDir, 'core', '.env.local'), 'utf-8')).toBe('FOO=bar\n'); + + writeFileSync(join(projectDir, 'core', '.env.local'), 'FOO=baz\n'); + + expect(readFileSync(join(projectDir, '.env.local'), 'utf-8')).toBe('FOO=baz\n'); + }); + + it('does not clobber an existing core/.env.local file', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + mkdirSync(join(projectDir, 'core'), { recursive: true }); + writeFileSync(join(projectDir, 'core', '.env.local'), 'PRESERVE=me\n'); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const coreEnvPath = join(projectDir, 'core', '.env.local'); + + expect(lstatSync(coreEnvPath).isSymbolicLink()).toBe(false); + expect(readFileSync(coreEnvPath, 'utf-8')).toBe('PRESERVE=me\n'); + }); + }); + + describe('proxy.ts → middleware.ts conversion', () => { + const proxyFixture = [ + "import { composeProxies } from './proxies/compose-proxies';", + '', + 'export const proxy = composeProxies();', + '', + 'export const config = {', + " matcher: ['/((?!api).*)'],", + '};', + '', + ].join('\n'); + + it('renames proxy.ts to middleware.ts, renames the export, and injects the edge runtime', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + writeCoreProxyFile(proxyFixture); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const middlewarePath = join(projectDir, 'core', 'middleware.ts'); + const proxyPath = join(projectDir, 'core', 'proxy.ts'); + + expect(existsSync(middlewarePath)).toBe(true); + expect(existsSync(proxyPath)).toBe(false); + + const middleware = readFileSync(middlewarePath, 'utf-8'); + + expect(middleware).toContain('export const middleware = composeProxies()'); + expect(middleware).not.toContain('export const proxy'); + expect(middleware).toContain("runtime: 'experimental-edge'"); + }); + + it('preserves the rest of the file contents', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + writeCoreProxyFile(proxyFixture); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const middleware = readFileSync(join(projectDir, 'core', 'middleware.ts'), 'utf-8'); + + expect(middleware).toContain("import { composeProxies } from './proxies/compose-proxies';"); + expect(middleware).toContain("matcher: ['/((?!api).*)']"); + }); + + it('is a no-op when proxy.ts does not exist', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).not.toThrow(); + expect(existsSync(join(projectDir, 'core', 'middleware.ts'))).toBe(false); + }); + }); +}); diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.ts b/packages/catalyst/src/cli/lib/commerce-hosting.ts new file mode 100644 index 0000000000..4457f276b7 --- /dev/null +++ b/packages/catalyst/src/cli/lib/commerce-hosting.ts @@ -0,0 +1,427 @@ +import { input, select, Separator } from '@inquirer/prompts'; +import { colorize } from 'consola/utils'; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { dirname, join } from 'path'; +import { z } from 'zod'; + +import { consola } from './logger'; +import { + createProject, + fetchProjects, + InfrastructureProjectValidationError, + type ProjectListItem, +} from './project'; +import { sortPackageJsonFields } from './sort-package-json'; + +const OPENNEXT_CLOUDFLARE_VERSION = '1.17.3'; + +const corePackageJsonSchema = z.looseObject({ + dependencies: z.record(z.string(), z.string()).optional(), +}); + +const writeJson = (path: string, value: unknown) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +const symlinkRootEnvToCore = (projectDir: string) => { + const coreEnvPath = join(projectDir, 'core', '.env.local'); + + if (lstatSync(coreEnvPath, { throwIfNoEntry: false })) return; + + try { + symlinkSync('../.env.local', coreEnvPath); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + consola.warn( + `Could not create symlink at core/.env.local: ${message}\n` + + 'On Windows, enable Developer Mode or run as administrator to allow symlinks.\n' + + 'You will need to keep .env.local and core/.env.local in sync manually.', + ); + } +}; + +const convertProxyToMiddleware = (projectDir: string) => { + const proxyPath = join(projectDir, 'core', 'proxy.ts'); + const middlewarePath = join(projectDir, 'core', 'middleware.ts'); + + if (!existsSync(proxyPath)) return; + + const contents = readFileSync(proxyPath, 'utf-8') + .replace('export const proxy', 'export const middleware') + .replace('export const config = {', "export const config = {\n runtime: 'experimental-edge',"); + + writeFileSync(middlewarePath, contents); + unlinkSync(proxyPath); +}; + +export const setupCommerceHosting = ({ + projectDir, + projectUuid, + storeHash, + accessToken, +}: { + projectDir: string; + projectUuid: string; + storeHash?: string; + accessToken?: string; +}) => { + const corePackageJsonPath = join(projectDir, 'core', 'package.json'); + const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); + + pkg.dependencies = { + ...pkg.dependencies, + '@opennextjs/cloudflare': OPENNEXT_CLOUDFLARE_VERSION, + }; + + writeJson(corePackageJsonPath, sortPackageJsonFields(pkg)); + + const projectJson: Record = { + projectUuid, + framework: 'catalyst', + }; + + if (storeHash) projectJson.storeHash = storeHash; + if (accessToken) projectJson.accessToken = accessToken; + + writeJson(join(projectDir, 'core', '.bigcommerce', 'project.json'), projectJson); + + symlinkRootEnvToCore(projectDir); + convertProxyToMiddleware(projectDir); +}; + +interface CommerceHostingApiContext { + storeHash: string; + accessToken: string; + apiHost: string; +} + +// Thrown by `selectOrCreateInfrastructureProject` when the user declines the +// "Would you like to create one?" prompt that surfaces when no projects exist. +// Callers translate this into a context-appropriate error (e.g. "Cannot deploy +// without being linked to a project"). +export class NoLinkedProjectError extends Error { + constructor() { + super('No infrastructure project linked: user declined to create one.'); + this.name = 'NoLinkedProjectError'; + } +} + +async function promptForNewProjectName(api: CommerceHostingApiContext): Promise { + const newProjectName = await consola.prompt('Enter a name for the new project:', { + type: 'text', + }); + + const data = await createProject( + String(newProjectName), + api.storeHash, + api.accessToken, + api.apiHost, + ); + + consola.success(`Project "${data.name}" created successfully.`); + + return { uuid: data.uuid, name: data.name }; +} + +// Generic "select an existing project, or create a new one" prompt — used by +// `catalyst project link` and by `catalyst deploy` when its linked project is +// missing. Distinct from `promptForCommerceHostingProject` which has +// default-name + auto-create semantics tailored to `catalyst create`. +// +// Pass `linkedProjectUuid` to decorate the matching project's label with +// `[linked]` so the user can see which one is the current selection. +export async function selectOrCreateInfrastructureProject( + api: CommerceHostingApiContext, + linkedProjectUuid?: string, +): Promise { + consola.start('Fetching projects...'); + + const existingProjects = await fetchProjects(api.storeHash, api.accessToken, api.apiHost); + + consola.success('Projects fetched.'); + + // No existing projects on the store — skip the select prompt and offer + // creation directly. Declining means we have nothing to link to. + if (existingProjects.length === 0) { + const shouldCreate = await consola.prompt( + 'There are not any hosting projects that you can link to yet. Would you like to create one?', + { type: 'confirm', initial: true }, + ); + + if (!shouldCreate) { + throw new NoLinkedProjectError(); + } + + return promptForNewProjectName(api); + } + + const promptOptions = [ + ...existingProjects.map((p) => ({ + label: p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, + value: p.uuid, + hint: p.uuid, + })), + { + label: 'Create a new project', + value: 'create', + hint: 'Create a new hosting project for this Catalyst storefront.', + }, + ]; + + const selected = await consola.prompt( + 'Select a project or create a new project (Press to select).', + { type: 'select', options: promptOptions, cancel: 'reject' }, + ); + + if (selected === 'create') { + return promptForNewProjectName(api); + } + + const matched = existingProjects.find((p) => p.uuid === selected); + + if (!matched) { + throw new Error(`Selected project ${String(selected)} not found in fetched list.`); + } + + return matched; +} + +export async function promptForCommerceHostingProject( + api: CommerceHostingApiContext, + defaultName: string, + autoUseDefaultName?: boolean, + useExistingOnCollision?: boolean, +): Promise { + const existingProjects = await fetchProjects(api.storeHash, api.accessToken, api.apiHost); + const takenNames = existingProjects.map((project) => project.name); + + if (autoUseDefaultName) { + return autoCreateCommerceHostingProject( + api, + defaultName, + existingProjects, + useExistingOnCollision, + ); + } + + const conflict = existingProjects.find( + (project) => project.name.toLowerCase() === defaultName.toLowerCase(), + ); + + if (!conflict) { + return autoCreateCommerceHostingProject( + api, + defaultName, + existingProjects, + useExistingOnCollision, + ); + } + + type Action = 'use-named' | 'select-from-list' | 'create'; + + const hasOtherProjects = existingProjects.length > 1; + + const choices: Array<{ name: string; value: Action }> = [ + { name: `Use "${conflict.name}"`, value: 'use-named' }, + ]; + + if (hasOtherProjects) { + choices.push({ name: 'Select from my projects', value: 'select-from-list' }); + } + + choices.push({ name: 'Create a new project', value: 'create' }); + + const action = await select({ + message: hasOtherProjects + ? `It looks like you already have an existing Commerce Hosting project named "${conflict.name}". Would you like to use it, select from your projects, or create a new one?` + : `It looks like you already have an existing Commerce Hosting project named "${conflict.name}". Would you like to use it, or create a new one?`, + choices, + }); + + if (action === 'use-named') { + consola.success(`Using existing Commerce Hosting project "${conflict.name}"`); + + return conflict; + } + + if (action === 'create') { + return promptAndCreateCommerceHostingProject(api, takenNames, defaultName); + } + + const selected = await select({ + message: 'Which Commerce Hosting project would you like to use?', + choices: [ + ...existingProjects.map((project) => ({ + name: project.name, + value: project, + description: project.uuid, + })), + new Separator(), + { name: 'Create a new project', value: 'create-new' as const }, + ], + }); + + if (selected === 'create-new') { + return promptAndCreateCommerceHostingProject(api, takenNames, defaultName); + } + + consola.success(`Using existing Commerce Hosting project "${selected.name}"`); + + return selected; +} + +export async function promptAndCreateCommerceHostingProject( + api: CommerceHostingApiContext, + takenNames: readonly string[], + defaultName?: string, +): Promise { + const projectName = await input({ + message: 'What would you like to name your Commerce Hosting project?', + default: defaultName, + validate: (value) => { + const trimmed = value.trim(); + + if (!trimmed) return 'Project name is required'; + + const conflict = takenNames.find((taken) => taken.toLowerCase() === trimmed.toLowerCase()); + + if (conflict) { + return `A Commerce Hosting project named "${conflict}" already exists`; + } + + return true; + }, + theme: { + style: { + help: () => + colorize( + 'dim', + '(The project that hosts your storefront on Commerce — often matches your folder name.)', + ), + }, + }, + }); + + try { + const created = await createProject( + projectName.trim(), + api.storeHash, + api.accessToken, + api.apiHost, + ); + + consola.success(`Commerce Hosting project "${created.name}" created successfully`); + + return { uuid: created.uuid, name: created.name }; + } catch (error) { + if (error instanceof InfrastructureProjectValidationError) { + consola.error(error.message); + + return promptAndCreateCommerceHostingProject(api, takenNames, defaultName); + } + + throw error; + } +} + +async function resolveCollisionChoice( + existingName: string, + useExistingOnCollision: boolean | undefined, +): Promise { + if (useExistingOnCollision === true) return true; + + if (!process.stdin.isTTY) return false; + + return select({ + message: `A Commerce Hosting project named "${existingName}" already exists. Use the existing project?`, + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); +} + +async function autoCreateCommerceHostingProject( + api: CommerceHostingApiContext, + name: string, + existingProjects: readonly ProjectListItem[], + useExistingOnCollision?: boolean, +): Promise { + const existing = existingProjects.find( + (project) => project.name.toLowerCase() === name.toLowerCase(), + ); + + if (existing) { + const shouldUseExisting = await resolveCollisionChoice(existing.name, useExistingOnCollision); + + if (shouldUseExisting) { + consola.success(`Using existing Commerce Hosting project "${existing.name}"`); + + return existing; + } + + consola.error( + 'Not reusing the existing project. Re-run with a different --project-name, or pass --use-existing to reuse it.', + ); + process.exit(1); + } + + try { + const created = await createProject(name, api.storeHash, api.accessToken, api.apiHost); + + consola.success(`Commerce Hosting project "${created.name}" created successfully`); + + return { uuid: created.uuid, name: created.name }; + } catch (error) { + if (error instanceof InfrastructureProjectValidationError) { + consola.error( + `Failed to create Commerce Hosting project "${name}": ${error.message}\nRe-run with a different --project-name.`, + ); + process.exit(1); + } + + throw error; + } +} + +// Orchestrates prompt + file mutations. Callable from `catalyst create --hosting commerce` +// (eager) and `catalyst deploy` (lazy). Idempotent — safe to re-run. +export async function runCommerceHostingSetup({ + api, + projectDir, + defaultProjectName, + autoUseProjectName, + useExistingOnCollision, +}: { + api: CommerceHostingApiContext; + projectDir: string; + defaultProjectName: string; + autoUseProjectName?: boolean; + useExistingOnCollision?: boolean; +}): Promise { + const project = await promptForCommerceHostingProject( + api, + defaultProjectName, + autoUseProjectName, + useExistingOnCollision, + ); + + setupCommerceHosting({ + projectDir, + projectUuid: project.uuid, + storeHash: api.storeHash, + accessToken: api.accessToken, + }); + + return project; +} diff --git a/packages/catalyst/src/cli/lib/has-github-ssh.ts b/packages/catalyst/src/cli/lib/has-github-ssh.ts new file mode 100644 index 0000000000..509d72538c --- /dev/null +++ b/packages/catalyst/src/cli/lib/has-github-ssh.ts @@ -0,0 +1,26 @@ +import { execSync } from 'child_process'; + +import { isExecException } from './is-exec-exception'; + +export function hasGitHubSSH(): boolean { + try { + const output = execSync('ssh -T git@github.com', { + encoding: 'utf8', + stdio: 'pipe', + }).toString(); + + return output.includes('successfully authenticated'); + } catch (error: unknown) { + if (isExecException(error)) { + const stdout = error.stdout ? error.stdout.toString() : ''; + const stderr = error.stderr ? error.stderr.toString() : ''; + const combinedOutput = stdout + stderr; + + if (combinedOutput.includes('successfully authenticated')) { + return true; + } + } + + return false; + } +} diff --git a/packages/catalyst/src/cli/lib/install-dependencies.ts b/packages/catalyst/src/cli/lib/install-dependencies.ts new file mode 100644 index 0000000000..76c56c09b6 --- /dev/null +++ b/packages/catalyst/src/cli/lib/install-dependencies.ts @@ -0,0 +1,16 @@ +import { installDependencies as installDeps } from 'nypm'; +import yoctoSpinner from 'yocto-spinner'; + +export const installDependencies = async (projectDir: string) => { + const spinner = yoctoSpinner().start('Installing dependencies. This could take a minute...'); + + try { + await installDeps({ cwd: projectDir, silent: true, packageManager: 'pnpm' }); + spinner.success('Dependencies installed successfully.'); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + spinner.error(`Failed to install dependencies: ${message}`); + throw error; + } +}; diff --git a/packages/catalyst/src/cli/lib/is-exec-exception.ts b/packages/catalyst/src/cli/lib/is-exec-exception.ts new file mode 100644 index 0000000000..9cdf5a6280 --- /dev/null +++ b/packages/catalyst/src/cli/lib/is-exec-exception.ts @@ -0,0 +1,5 @@ +import { ExecException } from 'node:child_process'; + +export function isExecException(error: unknown): error is ExecException { + return typeof error === 'object' && error !== null && 'stdout' in error && 'stderr' in error; +} diff --git a/packages/catalyst/src/cli/lib/localization.ts b/packages/catalyst/src/cli/lib/localization.ts new file mode 100644 index 0000000000..1f85b1a10f --- /dev/null +++ b/packages/catalyst/src/cli/lib/localization.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { getTelemetry } from './telemetry'; + +const allowedLocales = [ + 'en', + 'da', + 'es-AR', + 'es-CL', + 'es-CO', + 'es-MX', + 'es-PE', + 'es-419', + 'es', + 'it', + 'nl', + 'pl', + 'pt', + 'de', + 'fr', + 'ja', + 'no', + 'pt-BR', + 'sv', +]; + +const AvailableLocalesSuccessSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + name: z.string(), + fallback: z.string().nullable(), + is_supported: z.boolean(), + }), + ), +}); + +export const getAvailableLocales = async ( + storeHash: string, + accessToken: string, + apiHost: string, +) => { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/settings/store/available-locales`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + Accept: 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error( + `GET /v3/settings/store/available-locales failed: ${response.status} ${response.statusText}`, + ); + } + + return AvailableLocalesSuccessSchema.parse(await response.json()) + .data.filter(({ id }) => allowedLocales.includes(id)) + .map(({ name, id }) => ({ name: `${name} (${id})`, value: id })); +}; diff --git a/packages/catalyst/src/cli/lib/login.ts b/packages/catalyst/src/cli/lib/login.ts new file mode 100644 index 0000000000..49cb4344fe --- /dev/null +++ b/packages/catalyst/src/cli/lib/login.ts @@ -0,0 +1,41 @@ +import { colorize } from 'consola/utils'; +import open from 'open'; +import yoctoSpinner from 'yocto-spinner'; + +import { requestDeviceCode, waitForDeviceToken } from './auth'; +import { consola } from './logger'; + +export interface LoginResult { + storeHash: string; + accessToken: string; +} + +export async function login(loginUrl: string): Promise { + const deviceCode = await requestDeviceCode(loginUrl); + + consola.info( + `${colorize('yellow', 'Your one-time code:')} ${colorize('bold', deviceCode.user_code)}`, + ); + + try { + await open(deviceCode.verification_uri); + consola.info(`Opened ${deviceCode.verification_uri} in your browser.`); + } catch { + consola.info(`Open ${deviceCode.verification_uri} in your browser and enter the code above.`); + } + + const spinner = yoctoSpinner().start('Waiting for authentication...'); + + const credentials = await waitForDeviceToken( + loginUrl, + deviceCode.device_code, + deviceCode.interval, + ); + + spinner.success('Authentication complete.'); + + return { + storeHash: credentials.store_hash, + accessToken: credentials.access_token, + }; +} diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 43c32baa85..07eddd7506 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -7,10 +7,6 @@ export interface ProjectConfigSchema { framework: 'catalyst'; storeHash?: string; accessToken?: string; - telemetry: { - enabled: boolean; - anonymousId: string; - }; } export function getProjectConfig() { @@ -27,13 +23,6 @@ export function getProjectConfig() { }, storeHash: { type: 'string' }, accessToken: { type: 'string' }, - telemetry: { - type: 'object', - properties: { - enabled: { type: 'boolean' }, - anonymousId: { type: 'string' }, - }, - }, }, }); } diff --git a/packages/catalyst/src/cli/lib/project-state.spec.ts b/packages/catalyst/src/cli/lib/project-state.spec.ts new file mode 100644 index 0000000000..5788908786 --- /dev/null +++ b/packages/catalyst/src/cli/lib/project-state.spec.ts @@ -0,0 +1,164 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { mkTempDir } from './mk-temp-dir'; +import { getProjectState } from './project-state'; + +let tmpDir: string; +let cleanup: () => Promise; + +const writeFileEnsured = async (path: string, contents: string) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); +}; + +const writeProjectJson = (cwd: string, value: unknown) => + writeFileEnsured(join(cwd, '.bigcommerce', 'project.json'), JSON.stringify(value)); + +const writePackageJson = (cwd: string, value: unknown) => + writeFileEnsured(join(cwd, 'package.json'), JSON.stringify(value)); + +beforeEach(async () => { + [tmpDir, cleanup] = await mkTempDir(); +}); + +afterEach(async () => { + await cleanup(); +}); + +describe('getProjectState', () => { + test('empty directory returns all-false flags', () => { + const state = getProjectState(tmpDir); + + expect(state).toEqual({ + projectUuid: undefined, + hasMiddleware: false, + hasProxy: false, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, + }); + }); + + test('does not create .bigcommerce/ as a side effect', () => { + getProjectState(tmpDir); + + expect(existsSync(join(tmpDir, '.bigcommerce'))).toBe(false); + }); + + test('isLinked when projectUuid is set', async () => { + await writeProjectJson(tmpDir, { projectUuid: 'abc-123' }); + + const state = getProjectState(tmpDir); + + expect(state.projectUuid).toBe('abc-123'); + expect(state.isLinked).toBe(true); + expect(state.isTransformed).toBe(false); + expect(state.isFullySetUp).toBe(false); + }); + + test('malformed project.json is treated as unlinked', async () => { + await writeFileEnsured(join(tmpDir, '.bigcommerce', 'project.json'), '{not json'); + + const state = getProjectState(tmpDir); + + expect(state.projectUuid).toBeUndefined(); + expect(state.isLinked).toBe(false); + }); + + test('hasMiddleware/hasProxy reflect file presence', async () => { + await writeFileEnsured(join(tmpDir, 'proxy.ts'), '// proxy'); + + const before = getProjectState(tmpDir); + + expect(before.hasProxy).toBe(true); + expect(before.hasMiddleware).toBe(false); + + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + + const after = getProjectState(tmpDir); + + expect(after.hasProxy).toBe(true); + expect(after.hasMiddleware).toBe(true); + }); + + test('hasOpenNextDep reflects package.json dependencies', async () => { + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + expect(getProjectState(tmpDir).hasOpenNextDep).toBe(true); + }); + + test('package.json without OpenNext dep returns false', async () => { + await writePackageJson(tmpDir, { dependencies: { next: '15.0.0' } }); + + expect(getProjectState(tmpDir).hasOpenNextDep).toBe(false); + }); + + test('isTransformed requires middleware present, proxy absent, and OpenNext dep installed', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + expect(getProjectState(tmpDir).isTransformed).toBe(true); + }); + + test('isTransformed is false if proxy.ts still exists', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writeFileEnsured(join(tmpDir, 'proxy.ts'), '// proxy'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + expect(getProjectState(tmpDir).isTransformed).toBe(false); + }); + + test('isTransformed is false if OpenNext dep missing', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + + expect(getProjectState(tmpDir).isTransformed).toBe(false); + }); + + test('isFullySetUp requires both linked and transformed', async () => { + await writeProjectJson(tmpDir, { projectUuid: 'abc-123' }); + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + const state = getProjectState(tmpDir); + + expect(state.isLinked).toBe(true); + expect(state.isTransformed).toBe(true); + expect(state.isFullySetUp).toBe(true); + }); + + test('linked but untransformed (e.g. after `catalyst project create` only)', async () => { + await writeProjectJson(tmpDir, { projectUuid: 'abc-123' }); + await writeFileEnsured(join(tmpDir, 'proxy.ts'), '// proxy'); + + const state = getProjectState(tmpDir); + + expect(state.isLinked).toBe(true); + expect(state.isTransformed).toBe(false); + expect(state.isFullySetUp).toBe(false); + }); + + test('transformed but unlinked (e.g. mid-setup before UUID is written)', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + const state = getProjectState(tmpDir); + + expect(state.isLinked).toBe(false); + expect(state.isTransformed).toBe(true); + expect(state.isFullySetUp).toBe(false); + }); +}); diff --git a/packages/catalyst/src/cli/lib/project-state.ts b/packages/catalyst/src/cli/lib/project-state.ts new file mode 100644 index 0000000000..479ebc0158 --- /dev/null +++ b/packages/catalyst/src/cli/lib/project-state.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; + +const projectJsonSchema = z.looseObject({ + projectUuid: z.string().optional(), +}); + +const packageJsonSchema = z.looseObject({ + dependencies: z.record(z.string(), z.string()).optional(), +}); + +export interface ProjectState { + projectUuid: string | undefined; + hasMiddleware: boolean; + hasProxy: boolean; + hasOpenNextDep: boolean; + + // Derived signals — `isLinked` reflects intent (UUID registered via + // `catalyst project create` or commerce-hosting setup); `isTransformed` + // reflects on-disk readiness (middleware.ts swapped in, OpenNext dep + // installed). A deploy needs both, but `catalyst build` only cares about + // `isTransformed` so it can dispatch to OpenNext vs `next build`. + isLinked: boolean; + isTransformed: boolean; + isFullySetUp: boolean; +} + +const safeReadJson = (path: string): unknown => { + try { + return JSON.parse(readFileSync(path, 'utf-8')); + } catch { + return null; + } +}; + +// Read-only inspection of a Catalyst project at `cwd` (typically `core/`). +// Avoids `getProjectConfig()` deliberately — that would instantiate `Conf` +// and create `.bigcommerce/` as a side effect. +export function getProjectState(cwd: string = process.cwd()): ProjectState { + const projectJson = projectJsonSchema.safeParse( + safeReadJson(join(cwd, '.bigcommerce', 'project.json')), + ); + const projectUuid = projectJson.success ? projectJson.data.projectUuid : undefined; + + const hasMiddleware = existsSync(join(cwd, 'middleware.ts')); + const hasProxy = existsSync(join(cwd, 'proxy.ts')); + + const pkgJson = packageJsonSchema.safeParse(safeReadJson(join(cwd, 'package.json'))); + const hasOpenNextDep = pkgJson.success + ? Boolean(pkgJson.data.dependencies?.['@opennextjs/cloudflare']) + : false; + + const isLinked = Boolean(projectUuid); + const isTransformed = hasMiddleware && !hasProxy && hasOpenNextDep; + const isFullySetUp = isLinked && isTransformed; + + return { + projectUuid, + hasMiddleware, + hasProxy, + hasOpenNextDep, + isLinked, + isTransformed, + isFullySetUp, + }; +} diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index 1dc66420ee..32d6f1dd5c 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -2,6 +2,13 @@ import { z } from 'zod'; import { getTelemetry } from './telemetry'; +export class InfrastructureProjectValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InfrastructureProjectValidationError'; + } +} + const fetchProjectsSchema = z.object({ data: z.array( z.object({ @@ -16,21 +23,44 @@ export interface ProjectListItem { name: string; } +function projectsUrl(storeHash: string, apiHost: string) { + return `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`; +} + +function authHeaders(accessToken: string) { + return { + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + }; +} + +export async function hasProjectsAccess( + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch(projectsUrl(storeHash, apiHost), { + method: 'GET', + headers: authHeaders(accessToken), + }); + + if (response.status === 200) return true; + if (response.status === 403) return false; + + throw new Error( + `GET /v3/infrastructure/projects failed: ${response.status} ${response.statusText}`, + ); +} + export async function fetchProjects( storeHash: string, accessToken: string, apiHost: string, ): Promise { - const response = await fetch( - `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, - { - method: 'GET', - headers: { - 'X-Auth-Token': accessToken, - 'X-Correlation-Id': getTelemetry().correlationId, - }, - }, - ); + const response = await fetch(projectsUrl(storeHash, apiHost), { + method: 'GET', + headers: authHeaders(accessToken), + }); if (response.status === 403) { throw new Error( @@ -65,28 +95,51 @@ export interface CreateProjectResult { date_modified: Date; } +const validationErrorBodySchema = z.object({ + title: z.string().optional(), + detail: z.string().optional(), + errors: z.record(z.string(), z.string()).optional(), +}); + +function extractValidationMessage(body: unknown): string | null { + const parsed = validationErrorBodySchema.safeParse(body); + + if (!parsed.success) return null; + + const { title, detail, errors } = parsed.data; + + if (errors && Object.keys(errors).length > 0) { + return Object.values(errors).join('; '); + } + + return detail ?? title ?? null; +} + export async function createProject( name: string, storeHash: string, accessToken: string, apiHost: string, ): Promise { - const response = await fetch( - `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, - { - method: 'POST', - headers: { - 'X-Auth-Token': accessToken, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Correlation-Id': getTelemetry().correlationId, - }, - body: JSON.stringify({ name }), + const response = await fetch(projectsUrl(storeHash, apiHost), { + method: 'POST', + headers: { + ...authHeaders(accessToken), + Accept: 'application/json', + 'Content-Type': 'application/json', }, - ); - - if (response.status === 502) { - throw new Error('Failed to create project, is the name already in use?'); + body: JSON.stringify({ name }), + }); + + if (response.status === 400 || response.status === 422) { + const body: unknown = await response.json().catch(() => null); + const fallback = + response.status === 422 + ? "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)" + : response.statusText; + const message = extractValidationMessage(body) ?? fallback; + + throw new InfrastructureProjectValidationError(message); } if (response.status === 403) { @@ -95,10 +148,9 @@ export async function createProject( ); } - if (response.status === 422) { - throw new Error( - "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", - ); + // TODO: TRAC-592 - remove this check once the API returns proper 400/422 with validation messages for duplicate names instead of 502 + if (response.status === 502) { + throw new Error('Failed to create project, is the name already in use?'); } if (!response.ok) { diff --git a/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts b/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts new file mode 100644 index 0000000000..9a68c41ecf --- /dev/null +++ b/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts @@ -0,0 +1,15 @@ +import { sync as spawnSync } from 'cross-spawn'; + +export function resetBranchToRef(projectDir: string, ghRef: string) { + const spawn = spawnSync('git', ['reset', '--hard', ghRef, '--'], { + cwd: projectDir, + encoding: 'utf8', + shell: false, + }); + + const stderr = spawn.stderr.trim(); + + if (spawn.status !== 0 && stderr) { + throw new Error(stderr); + } +} diff --git a/packages/catalyst/src/cli/lib/setup-core-project.spec.ts b/packages/catalyst/src/cli/lib/setup-core-project.spec.ts new file mode 100644 index 0000000000..e781a46990 --- /dev/null +++ b/packages/catalyst/src/cli/lib/setup-core-project.spec.ts @@ -0,0 +1,110 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import PACKAGE_INFO from '../../../package.json'; + +import { setupCoreProject } from './setup-core-project'; + +const packageJsonSchema = z.looseObject({ + name: z.string().optional(), + scripts: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), +}); + +let projectDir: string; + +const writeCorePackageJson = (contents: unknown) => { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); +}; + +const readCorePackageJson = () => + packageJsonSchema.parse( + JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), + ); + +beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'catalyst-setup-core-test-')); +}); + +afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); +}); + +describe('setupCoreProject', () => { + it('overrides build, start, and deploy scripts to dispatch through the catalyst CLI', () => { + writeCorePackageJson({ + scripts: { + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + build: 'npm run generate && next build', + start: 'next start', + }, + }); + + setupCoreProject(projectDir); + + expect(readCorePackageJson().scripts).toEqual({ + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + build: 'npm run generate && catalyst build', + start: 'catalyst start', + deploy: 'npm run generate && catalyst deploy', + }); + }); + + it('keeps the `npm run generate &&` prefix on build', () => { + writeCorePackageJson({ scripts: { build: 'next build' } }); + + setupCoreProject(projectDir); + + expect(readCorePackageJson().scripts?.build).toBe('npm run generate && catalyst build'); + }); + + it('leaves unrelated scripts (dev, generate, lint, etc.) untouched', () => { + writeCorePackageJson({ + scripts: { + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + lint: 'eslint .', + typecheck: 'tsc --noEmit', + }, + }); + + setupCoreProject(projectDir); + + expect(readCorePackageJson().scripts).toMatchObject({ + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + lint: 'eslint .', + typecheck: 'tsc --noEmit', + }); + }); + + it('adds @bigcommerce/catalyst dep at the CLI version', () => { + writeCorePackageJson({ dependencies: { next: '^15.0.0' } }); + + setupCoreProject(projectDir); + + const pkg = readCorePackageJson(); + + expect(pkg.dependencies?.['@bigcommerce/catalyst']).toBe(PACKAGE_INFO.version); + expect(pkg.dependencies?.next).toBe('^15.0.0'); + }); + + it('preserves unrelated top-level fields like name and version', () => { + writeCorePackageJson({ + name: '@bigcommerce/catalyst-core', + scripts: { dev: 'next dev' }, + }); + + setupCoreProject(projectDir); + + expect(readCorePackageJson().name).toBe('@bigcommerce/catalyst-core'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/setup-core-project.ts b/packages/catalyst/src/cli/lib/setup-core-project.ts new file mode 100644 index 0000000000..0d812c8408 --- /dev/null +++ b/packages/catalyst/src/cli/lib/setup-core-project.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { z } from 'zod'; + +import PACKAGE_INFO from '../../../package.json'; + +import { sortPackageJsonFields } from './sort-package-json'; + +const corePackageJsonSchema = z.looseObject({ + scripts: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), +}); + +const writeJson = (path: string, value: unknown) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +// Wires Catalyst CLI scripts and the `@bigcommerce/catalyst` dep into a freshly +// cloned `core/`. Always runs at create time, regardless of hosting choice — +// `catalyst build` / `catalyst start` / `catalyst deploy` dispatch on project +// state, so these scripts work for self-hosted projects too without rewrite. +export const setupCoreProject = (projectDir: string) => { + const corePackageJsonPath = join(projectDir, 'core', 'package.json'); + const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); + + pkg.scripts = { + ...pkg.scripts, + build: 'npm run generate && catalyst build', + start: 'catalyst start', + deploy: 'npm run generate && catalyst deploy', + }; + + pkg.dependencies = { + ...pkg.dependencies, + '@bigcommerce/catalyst': PACKAGE_INFO.version, + }; + + writeJson(corePackageJsonPath, sortPackageJsonFields(pkg)); +}; diff --git a/packages/catalyst/src/cli/lib/shared-options.ts b/packages/catalyst/src/cli/lib/shared-options.ts index f753314b23..7c0d0b517f 100644 --- a/packages/catalyst/src/cli/lib/shared-options.ts +++ b/packages/catalyst/src/cli/lib/shared-options.ts @@ -5,13 +5,13 @@ import { getProjectConfig } from './project-config'; export const storeHashOption = () => new Option( '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', + 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Read from .bigcommerce/project.json or .env when not provided.', ).env('CATALYST_STORE_HASH'); export const accessTokenOption = () => new Option( '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', + 'BigCommerce access token. Can be found after creating a store-level API account. Read from .bigcommerce/project.json or .env when not provided.', ).env('CATALYST_ACCESS_TOKEN'); export const apiHostOption = () => @@ -23,7 +23,7 @@ export const apiHostOption = () => export const projectUuidOption = () => new Option( '--project-uuid ', - 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', + 'BigCommerce infrastructure project UUID. Read from .bigcommerce/project.json or .env when not provided.', ).env('CATALYST_PROJECT_UUID'); export const resolveProjectUuid = (options: { projectUuid?: string }) => { diff --git a/packages/catalyst/src/cli/lib/sort-package-json.spec.ts b/packages/catalyst/src/cli/lib/sort-package-json.spec.ts new file mode 100644 index 0000000000..6fc1daf120 --- /dev/null +++ b/packages/catalyst/src/cli/lib/sort-package-json.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { sortPackageJsonFields } from './sort-package-json'; + +describe('sortPackageJsonFields', () => { + it('places known fields in canonical order', () => { + const input = { + dependencies: { next: '^15.0.0' }, + scripts: { build: 'next build' }, + version: '1.0.0', + name: '@bigcommerce/catalyst-core', + }; + + expect(Object.keys(sortPackageJsonFields(input))).toEqual([ + 'name', + 'version', + 'scripts', + 'dependencies', + ]); + }); + + it('preserves all canonical fields when present', () => { + const input = { + devDependencies: { vitest: '^3.0.0' }, + dependencies: { next: '^15.0.0' }, + scripts: { build: 'next build' }, + engines: { node: '^20.0.0' }, + private: true, + version: '1.0.0', + description: 'A Catalyst storefront', + name: '@bigcommerce/catalyst-core', + }; + + expect(Object.keys(sortPackageJsonFields(input))).toEqual([ + 'name', + 'description', + 'version', + 'private', + 'engines', + 'scripts', + 'dependencies', + 'devDependencies', + ]); + }); + + it('appends unknown fields after canonical ones, preserving their relative order', () => { + const input = { + keywords: ['catalyst'], + name: '@bigcommerce/catalyst-core', + repository: { type: 'git', url: 'git+https://example.com/repo.git' }, + scripts: { build: 'next build' }, + license: 'MIT', + }; + + expect(Object.keys(sortPackageJsonFields(input))).toEqual([ + 'name', + 'scripts', + 'keywords', + 'repository', + 'license', + ]); + }); + + it('does not mutate values', () => { + const input = { + name: '@bigcommerce/catalyst-core', + scripts: { build: 'next build' }, + dependencies: { next: '^15.0.0' }, + }; + + const result = sortPackageJsonFields(input); + + expect(result.scripts).toEqual({ build: 'next build' }); + expect(result.dependencies).toEqual({ next: '^15.0.0' }); + expect(result.name).toBe('@bigcommerce/catalyst-core'); + }); + + it('handles an empty object', () => { + expect(sortPackageJsonFields({})).toEqual({}); + }); +}); diff --git a/packages/catalyst/src/cli/lib/sort-package-json.ts b/packages/catalyst/src/cli/lib/sort-package-json.ts new file mode 100644 index 0000000000..56b49fd17b --- /dev/null +++ b/packages/catalyst/src/cli/lib/sort-package-json.ts @@ -0,0 +1,32 @@ +// Canonical order for top-level fields in core/package.json. Anything not in +// this list is appended afterwards in its original position relative to other +// unknown keys, so we don't drop or reshuffle uncommon fields like `keywords`. +const FIELD_ORDER = [ + 'name', + 'description', + 'version', + 'private', + 'engines', + 'scripts', + 'dependencies', + 'devDependencies', +] as const; + +export function sortPackageJsonFields>(pkg: T): T { + const ordered: Record = {}; + + FIELD_ORDER.forEach((field) => { + if (field in pkg) { + ordered[field] = pkg[field]; + } + }); + + Object.keys(pkg).forEach((key) => { + if (!(key in ordered)) { + ordered[key] = pkg[key]; + } + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return ordered as T; +} diff --git a/packages/catalyst/src/cli/lib/telemetry.ts b/packages/catalyst/src/cli/lib/telemetry.ts index 356f95e1a4..6c74e2a96d 100644 --- a/packages/catalyst/src/cli/lib/telemetry.ts +++ b/packages/catalyst/src/cli/lib/telemetry.ts @@ -4,7 +4,7 @@ import { randomBytes, randomUUID } from 'node:crypto'; import PACKAGE_INFO from '../../../package.json'; -import { getProjectConfig, ProjectConfigSchema } from './project-config'; +import { getUserConfig, UserConfigSchema } from './user-config'; const TELEMETRY_KEY_ENABLED = 'telemetry.enabled'; const TELEMETRY_KEY_ID = `telemetry.anonymousId`; @@ -15,7 +15,7 @@ export class Telemetry { readonly startTime: number; commandName = 'unknown'; - private projectConfig: Conf; + private userConfig: Conf; private CATALYST_TELEMETRY_DISABLED: string | undefined; private readonly projectName = 'catalyst-cli'; @@ -24,7 +24,7 @@ export class Telemetry { constructor() { this.CATALYST_TELEMETRY_DISABLED = process.env.CATALYST_TELEMETRY_DISABLED; - this.projectConfig = getProjectConfig(); + this.userConfig = getUserConfig(); this.correlationId = randomUUID(); this.startTime = Date.now(); @@ -86,18 +86,18 @@ export class Telemetry { setEnabled = (_enabled: boolean) => { const enabled = Boolean(_enabled); - this.projectConfig.set('telemetry.enabled', enabled); + this.userConfig.set('telemetry.enabled', enabled); }; isEnabled() { return ( !this.CATALYST_TELEMETRY_DISABLED && - this.projectConfig.get(TELEMETRY_KEY_ENABLED, true) + this.userConfig.get(TELEMETRY_KEY_ENABLED, true) ); } private getAnonymousId(): string { - const val = this.projectConfig.get(TELEMETRY_KEY_ID); + const val = this.userConfig.get(TELEMETRY_KEY_ID); if (val) { return val; @@ -105,7 +105,7 @@ export class Telemetry { const generated = randomBytes(32).toString('hex'); - this.projectConfig.set(TELEMETRY_KEY_ID, generated); + this.userConfig.set(TELEMETRY_KEY_ID, generated); return generated; } diff --git a/packages/catalyst/src/cli/lib/user-config.ts b/packages/catalyst/src/cli/lib/user-config.ts new file mode 100644 index 0000000000..2fb7f48ebe --- /dev/null +++ b/packages/catalyst/src/cli/lib/user-config.ts @@ -0,0 +1,36 @@ +import Conf from 'conf'; + +export interface UserConfigSchema { + telemetry: { + enabled: boolean; + anonymousId: string; + }; +} + +let userConfigInstance: Conf | undefined; + +// User-scoped config (per-machine, not per-project). Lives in the OS config dir +// — keeping telemetry out of the user's working directory so commands like +// `catalyst create` don't drop a stray `.bigcommerce/` next to where they're run. +export function getUserConfig(): Conf { + userConfigInstance ??= new Conf({ + projectName: 'catalyst-cli', + projectSuffix: '', + configName: 'config', + schema: { + telemetry: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + anonymousId: { type: 'string' }, + }, + }, + }, + }); + + return userConfigInstance; +} + +export function resetUserConfig(): void { + userConfigInstance = undefined; +} diff --git a/packages/catalyst/src/cli/lib/write-env.ts b/packages/catalyst/src/cli/lib/write-env.ts new file mode 100644 index 0000000000..bbf2f49b1c --- /dev/null +++ b/packages/catalyst/src/cli/lib/write-env.ts @@ -0,0 +1,13 @@ +import { outputFileSync } from 'fs-extra/esm'; +import { join } from 'path'; + +// Writes core/.env.local — Next.js (and all the catalyst CLI commands) read env vars +// from there, since `core/` is the package they run inside. +export const writeEnv = (projectDir: string, envVars: Record) => { + outputFileSync( + join(projectDir, 'core', '.env.local'), + `${Object.entries(envVars) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n`, + ); +}; diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 2f327f653e..52a6bc411a 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -2,12 +2,14 @@ import { Option } from '@commander-js/extra-typings'; import { Command } from 'commander'; import { colorize } from 'consola/utils'; import { config } from 'dotenv'; +import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import PACKAGE_INFO from '../../package.json'; import { auth } from './commands/auth'; import { build } from './commands/build'; +import { create } from './commands/create'; import { deploy } from './commands/deploy'; import { logs } from './commands/logs'; import { project } from './commands/project'; @@ -17,6 +19,22 @@ import { version } from './commands/version'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; +// Auto-load .env.local from cwd if present, matching Next.js convention. +// `--env-path` (loaded later via argParser) overrides anything we load here. +const defaultEnvPath = resolve(process.cwd(), '.env.local'); + +if (existsSync(defaultEnvPath)) { + config({ path: defaultEnvPath }); +} + +// CATALYST_STORE_HASH falls back to BIGCOMMERCE_STORE_HASH so freshly-scaffolded +// projects work without duplicating the same value under two names. Aliasing +// here means every command's `.env('CATALYST_STORE_HASH')` binding picks it up +// without per-command changes. +if (!process.env.CATALYST_STORE_HASH && process.env.BIGCOMMERCE_STORE_HASH) { + process.env.CATALYST_STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH; +} + export const program = new Command(); consola.log(colorize('cyanBright', `◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.version}\n`)); @@ -26,7 +44,7 @@ program .version(PACKAGE_INFO.version) .summary('CLI tool for Catalyst development') .description( - 'CLI tool for Catalyst development.\n\nConfiguration priority: flags > env file (--env-path) > process.env > .bigcommerce/project.json.\nRun `catalyst --help` for details on a specific command.', + 'CLI tool for Catalyst development.\n\nConfiguration priority: flags > env file (--env-path) > process.env > .env.local (auto-loaded from cwd) > .bigcommerce/project.json.\n\nCATALYST_STORE_HASH falls back to BIGCOMMERCE_STORE_HASH if unset.\n\nRun `catalyst --help` for details on a specific command.', ) .configureHelp({ showGlobalOptions: true }) .addOption( @@ -62,6 +80,7 @@ program }), ) .addCommand(version) + .addCommand(create) .addCommand(start) .addCommand(build) .addCommand(deploy) diff --git a/packages/catalyst/tsconfig.json b/packages/catalyst/tsconfig.json index 37e0c17d44..d7bf0af065 100644 --- a/packages/catalyst/tsconfig.json +++ b/packages/catalyst/tsconfig.json @@ -3,6 +3,7 @@ "display": "Node", "compilerOptions": { "strict": true, + "lib": ["esnext", "dom", "dom.iterable"], "target": "es2020", "module": "esnext", "moduleResolution": "bundler", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a57fcfd13..825aa98813 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: packages/catalyst: dependencies: + '@inquirer/prompts': + specifier: ^7.5.3 + version: 7.5.3(@types/node@22.15.30) '@opennextjs/cloudflare': specifier: 1.17.3 version: 1.17.3(encoding@0.1.13)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(wrangler@4.31.0) @@ -324,15 +327,30 @@ importers: consola: specifier: ^3.4.2 version: 3.4.2 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 dotenv: specifier: ^16.5.0 version: 16.5.0 execa: specifier: ^9.6.0 version: 9.6.0 + fs-extra: + specifier: ^11.3.0 + version: 11.3.0 + lodash.kebabcase: + specifier: ^4.1.1 + version: 4.1.1 + nypm: + specifier: ^0.5.4 + version: 0.5.4 open: specifier: ^10.1.0 version: 10.1.2 + std-env: + specifier: ^3.9.0 + version: 3.9.0 yocto-spinner: specifier: ^1.0.0 version: 1.0.0 @@ -352,6 +370,15 @@ importers: '@types/adm-zip': specifier: ^0.5.7 version: 0.5.7 + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 + '@types/lodash.kebabcase': + specifier: ^4.1.9 + version: 4.1.9 '@types/node': specifier: ^22.15.30 version: 22.15.30 @@ -8452,7 +8479,6 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: From 7c497b207008c5c13a3355f7140f614596d3caaf Mon Sep 17 00:00:00 2001 From: Jordan Arldt Date: Thu, 7 May 2026 14:06:45 -0500 Subject: [PATCH 57/90] TRAC-614: Add project delete command to catalyst CLI (#3004) --- .../catalyst/src/cli/commands/project.spec.ts | 359 +++++++++++++++++- packages/catalyst/src/cli/commands/project.ts | 119 +++++- packages/catalyst/src/cli/lib/project.ts | 26 ++ packages/catalyst/tests/mocks/handlers.ts | 6 + 4 files changed, 507 insertions(+), 3 deletions(-) diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 3f4871e860..d8f417768f 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -131,7 +131,7 @@ afterAll(async () => { }); describe('project', () => { - test('has create, link, and list subcommands', () => { + test('has create, link, list, and delete subcommands', () => { expect(project).toBeInstanceOf(Command); expect(project.name()).toBe('project'); expect(project.description()).toBe('Manage your BigCommerce infrastructure project.'); @@ -152,6 +152,19 @@ describe('project', () => { expect(listCmd).toBeDefined(); expect(listCmd?.description()).toContain('List BigCommerce infrastructure projects'); + + const deleteCmd = project.commands.find((cmd) => cmd.name() === 'delete'); + + expect(deleteCmd).toBeDefined(); + expect(deleteCmd?.description()).toContain( + 'Permanently delete a BigCommerce infrastructure project', + ); + expect(deleteCmd?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--force' }), + ]), + ); }); }); @@ -786,3 +799,347 @@ describe('project link', () => { expect(exitMock).toHaveBeenCalledWith(1); }); }); + +describe('project delete', () => { + test('with --project-uuid and --force deletes without prompting', async () => { + const consolaPromptMock = vi.spyOn(consola, 'prompt'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consolaPromptMock).not.toHaveBeenCalled(); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(consola.start).toHaveBeenCalledWith(`Deleting project ${projectUuid1}...`); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('with --project-uuid prompts for confirmation and deletes on accept', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async (message, opts) => { + expect(message).toContain('Are you sure you want to delete project'); + expect(message).toContain(projectUuid1); + expect(message).toContain('irreversible'); + expect(opts).toMatchObject({ type: 'confirm' }); + + return Promise.resolve(true); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('aborts when user declines the confirmation prompt', async () => { + let deleteRequested = false; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => { + deleteRequested = true; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(deleteRequested).toBe(false); + expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('without --project-uuid fetches projects and prompts to select one', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + expect(message).toContain('Select a project to delete'); + + // 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: 'Cancel', value: 'cancel' }); + + return Promise.resolve(projectUuid2); + }) + .mockImplementationOnce(async (message) => { + expect(message).toContain('"Project Two"'); + expect(message).toContain(projectUuid2); + + return Promise.resolve(true); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid2} deleted.`); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('marks the currently linked project with [linked] in the select prompt', async () => { + config.set('projectUuid', projectUuid2); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + const linkedOption = options.find((o) => o.value === projectUuid2); + const otherOption = options.find((o) => o.value === projectUuid1); + + expect(linkedOption?.label).toContain('[linked]'); + expect(otherOption?.label).not.toContain('[linked]'); + + return Promise.resolve(projectUuid1); + }) + .mockImplementationOnce(async () => Promise.resolve(true)); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + consolaPromptMock.mockRestore(); + }); + + test('aborts when user selects Cancel from the project list', async () => { + let deleteRequested = false; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => { + deleteRequested = true; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve('cancel')); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(deleteRequested).toBe(false); + expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('exits cleanly when there are no projects to delete', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith('No projects found.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('clears linked projectUuid from config when the deleted project was linked', async () => { + config.set('projectUuid', projectUuid1); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + ]); + + expect(config.get('projectUuid')).toBeUndefined(); + expect(consola.info).toHaveBeenCalledWith( + 'Removed project UUID from .bigcommerce/project.json.', + ); + }); + + test('preserves linked projectUuid when a different project is deleted', async () => { + config.set('projectUuid', projectUuid2); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + ]); + + expect(config.get('projectUuid')).toBe(projectUuid2); + expect(consola.info).not.toHaveBeenCalledWith( + 'Removed project UUID from .bigcommerce/project.json.', + ); + }); + + test('with insufficient credentials exits with error', async () => { + 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; + + await expect(program.parseAsync(['node', 'catalyst', 'project', 'delete'])).rejects.toThrow( + 'Missing credentials', + ); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('throws when API returns 404', async () => { + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => HttpResponse.json({}, { status: 404 }), + ), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow(`Project ${projectUuid1} not found.`); + }); + + test('throws when API returns 403', async () => { + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => HttpResponse.json({}, { status: 403 }), + ), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); + }); +}); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index d016494610..2df93cd37b 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -9,7 +9,7 @@ import { } from '../lib/commerce-hosting'; import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; -import { createProject, fetchProjects } from '../lib/project'; +import { createProject, deleteProject, fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; import { getProjectState } from '../lib/project-state'; import { resolveCredentials } from '../lib/resolve-credentials'; @@ -206,9 +206,124 @@ Examples: process.exit(0); }); +const del = new Command('delete') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Permanently delete a BigCommerce infrastructure project. This action is irreversible.', + ) + .addHelpText( + 'after', + ` +Examples: + # Select a project to delete interactively + $ catalyst project delete + + # Delete a specific project (still prompts for confirmation) + $ catalyst project delete --project-uuid + + # Skip the confirmation prompt + $ catalyst project delete --project-uuid --force`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--force', 'Skip the confirmation prompt before deleting.') + .action(async (options) => { + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await getTelemetry().identify(storeHash); + + let targetUuid: string | undefined = options.projectUuid; + let targetName: string | undefined; + + if (!targetUuid) { + consola.start('Fetching projects...'); + + const projects = await fetchProjects(storeHash, accessToken, options.apiHost); + + consola.success('Projects fetched.'); + + if (projects.length === 0) { + consola.info('No projects found.'); + process.exit(0); + + return; + } + + const linkedProjectUuid = config.get('projectUuid'); + + const selected = await consola.prompt( + 'Select a project to delete (Press to select).', + { + type: 'select', + options: [ + ...projects.map((p) => ({ + label: + p.uuid === linkedProjectUuid + ? `${p.name} ${colorize('green', '[linked]')}` + : p.name, + value: p.uuid, + hint: p.uuid, + })), + { label: 'Cancel', value: 'cancel', hint: 'Exit without deleting any project.' }, + ], + cancel: 'reject', + }, + ); + + if (selected === 'cancel') { + consola.info('Aborted. No project was deleted.'); + process.exit(0); + + return; + } + + const matched = projects.find((p) => p.uuid === selected); + + if (!matched) { + throw new Error(`Selected project ${String(selected)} not found in fetched list.`); + } + + targetUuid = matched.uuid; + targetName = matched.name; + } + + if (!options.force) { + const label = targetName ? `"${targetName}" (${targetUuid})` : targetUuid; + + const confirmed = await consola.prompt( + `Are you sure you want to delete project ${label}? This action is irreversible and will permanently destroy the project and all of its data.`, + { type: 'confirm', initial: false }, + ); + + if (!confirmed) { + consola.info('Aborted. No project was deleted.'); + process.exit(0); + + return; + } + } + + consola.start(`Deleting project ${targetUuid}...`); + + await deleteProject(targetUuid, storeHash, accessToken, options.apiHost); + + consola.success(`Project ${targetUuid} deleted.`); + + if (config.get('projectUuid') === targetUuid) { + config.delete('projectUuid'); + consola.info('Removed project UUID from .bigcommerce/project.json.'); + } + + process.exit(0); + }); + export const project = new Command('project') .configureHelp({ showGlobalOptions: true }) .description('Manage your BigCommerce infrastructure project.') .addCommand(create) .addCommand(list) - .addCommand(link); + .addCommand(link) + .addCommand(del); diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index 32d6f1dd5c..52e99c37bc 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -163,3 +163,29 @@ export async function createProject( return data; } + +export async function deleteProject( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch(`${projectsUrl(storeHash, apiHost)}/${projectUuid}`, { + method: 'DELETE', + headers: authHeaders(accessToken), + }); + + if (response.status === 403) { + throw new Error( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); + } + + if (response.status === 404) { + throw new Error(`Project ${projectUuid} not found.`); + } + + if (!response.ok) { + throw new Error(`Failed to delete project: ${response.statusText}`); + } +} diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index c16ea054db..72c4004939 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -138,4 +138,10 @@ export const handlers = [ }, }), ), + + // Handler for deleteProject + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => new HttpResponse(null, { status: 204 }), + ), ]; From eaa1ec37c7c44cb1182bb1d2043f3f2cf98ed2fc Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Tue, 12 May 2026 09:01:35 -0600 Subject: [PATCH 58/90] build(cli): TRAC-668 bump wrangler to 4.90.0 (#3010) wrangler@4.24.3 produces a broken bundle for Next.js 16.2.x: the app-page-turbo.runtime.prod.js webpack runtime fails to initialize at request time with "Cannot read properties of undefined (reading 'require')". wrangler@4.90.0 bundles it correctly. Refs TRAC-668 Co-authored-by: Claude --- packages/catalyst/src/cli/commands/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index e1107ee47c..a8da79513f 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -9,7 +9,7 @@ import { getProjectConfig } from '../lib/project-config'; import { getProjectState } from '../lib/project-state'; import { getWranglerConfig } from '../lib/wrangler-config'; -const WRANGLER_VERSION = '4.24.3'; +const WRANGLER_VERSION = '4.90.0'; export async function buildCatalystProject(projectUuid: string): Promise { const coreDir = process.cwd(); From 98f6ef4e8f45e0f4fc928f73989d576f60a19e00 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Tue, 12 May 2026 15:36:28 -0600 Subject: [PATCH 59/90] chore: version @bigcommerce/catalyst@1.0.0-alpha.4 Move @commander-js/extra-typings from devDependencies to dependencies. It is externalized in the tsup build and must be installed at runtime; previously missing from the published package caused CLI startup to fail with ERR_MODULE_NOT_FOUND. Co-Authored-By: Claude --- .changeset/pre.json | 5 ++++- packages/catalyst/CHANGELOG.md | 10 ++++++++++ packages/catalyst/package.json | 4 ++-- pnpm-lock.yaml | 7 ++++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 4224520bc3..b7ec98f24e 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -11,6 +11,9 @@ "changesets": [ "alpha-release-notes", "auto-detect-deploy-secrets", - "fix-env-var-names" + "cli-built-in-help-text", + "cli-show-env-path-in-help", + "fix-env-var-names", + "wise-worms-hear" ] } diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md index 98b03752c3..7882428b6e 100644 --- a/packages/catalyst/CHANGELOG.md +++ b/packages/catalyst/CHANGELOG.md @@ -1,5 +1,15 @@ # @bigcommerce/catalyst +## 1.0.0-alpha.4 + +### Patch Changes + +- [`de04f42`](https://github.com/bigcommerce/catalyst/commit/de04f42696b30b675bec2625eefa1e825b4a97ba) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add built-in help text to all CLI commands so `catalyst --help` is the canonical reference. + +- [`e740158`](https://github.com/bigcommerce/catalyst/commit/e7401583603aae85d4adef23b4b72eeb96e11907) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Show the global `--env-path` option in every subcommand's `--help` output. + +- [`e5b4dee`](https://github.com/bigcommerce/catalyst/commit/e5b4dee1fc108d648b6110707b572c1fc9bc2e7c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update the CLI with the new client id. + ## 1.0.0-alpha.3 ### Minor Changes diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index d17f486639..e4b17d1b1c 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/catalyst", - "version": "1.0.0-alpha.3", + "version": "1.0.0-alpha.4", "type": "module", "bin": { "catalyst": "dist/cli.js" @@ -22,6 +22,7 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" }, "dependencies": { + "@commander-js/extra-typings": "^14.0.0", "@inquirer/prompts": "^7.5.3", "@segment/analytics-node": "^2.2.1", "adm-zip": "^0.5.16", @@ -42,7 +43,6 @@ "devDependencies": { "@bigcommerce/eslint-config": "^2.11.0", "@bigcommerce/eslint-config-catalyst": "workspace:^", - "@commander-js/extra-typings": "^14.0.0", "@types/adm-zip": "^0.5.7", "@types/cross-spawn": "^6.0.6", "@types/fs-extra": "^11.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 825aa98813..3a33ccc3c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: packages/catalyst: dependencies: + '@commander-js/extra-typings': + specifier: ^14.0.0 + version: 14.0.0(commander@14.0.0) '@inquirer/prompts': specifier: ^7.5.3 version: 7.5.3(@types/node@22.15.30) @@ -364,9 +367,6 @@ importers: '@bigcommerce/eslint-config-catalyst': specifier: workspace:^ version: link:../eslint-config-catalyst - '@commander-js/extra-typings': - specifier: ^14.0.0 - version: 14.0.0(commander@14.0.0) '@types/adm-zip': specifier: ^0.5.7 version: 0.5.7 @@ -8479,6 +8479,7 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} + deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: From 4b1618d40593c74457718e2db7da522ec1ec122f Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 13 May 2026 10:55:38 -0500 Subject: [PATCH 60/90] feat(cli): show deployment hostnames in project list output (#2988) --- .changeset/project-list-deployed-url.md | 5 ++ .../catalyst/src/cli/commands/deploy.spec.ts | 8 +-- packages/catalyst/src/cli/commands/deploy.ts | 16 ++--- .../catalyst/src/cli/commands/dev.spec.ts | 36 ---------- .../catalyst/src/cli/commands/project.spec.ts | 7 ++ packages/catalyst/src/cli/commands/project.ts | 10 +++ .../src/cli/lib/commerce-hosting.spec.ts | 71 +++++++++++-------- .../catalyst/src/cli/lib/commerce-hosting.ts | 7 +- packages/catalyst/src/cli/lib/project.ts | 2 + packages/catalyst/tests/mocks/handlers.ts | 21 ++++-- 10 files changed, 97 insertions(+), 86 deletions(-) create mode 100644 .changeset/project-list-deployed-url.md delete mode 100644 packages/catalyst/src/cli/commands/dev.spec.ts diff --git a/.changeset/project-list-deployed-url.md b/.changeset/project-list-deployed-url.md new file mode 100644 index 0000000000..0ff85acf7f --- /dev/null +++ b/.changeset/project-list-deployed-url.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +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. diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index a723311415..1a349660e5 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -234,7 +234,7 @@ describe('deployment and event streaming', () => { start(controller) { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"processing","progress":75}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"processing","progress":75}}`, ), ); setTimeout(() => { @@ -244,7 +244,7 @@ describe('deployment and event streaming', () => { setTimeout(() => { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"finalizing","progress":99}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"finalizing","progress":99}}`, ), ); controller.close(); @@ -288,13 +288,13 @@ describe('deployment and event streaming', () => { start(controller) { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"processing","progress":75}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"processing","progress":75}}`, ), ); setTimeout(() => { controller.enqueue( encoder.encode( - `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","deployment_url":null,"event":{"step":"unzipping","progress":99},"error":{"code":30}}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${deploymentUuid}","event":{"step":"unzipping","progress":99},"error":{"code":30}}`, ), ); }, 10); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 51d949dc6a..bab1224f8c 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -78,7 +78,7 @@ const DeploymentStatusSchema = z.object({ progress: z.number(), }) .nullable(), - deployment_url: z.string().nullable(), + deployment_hostnames: z.array(z.string()).optional(), error: z .object({ code: z.number(), @@ -298,7 +298,7 @@ export const getDeploymentStatus = async ( const decoder = new TextDecoder(); let done = false; - let deploymentUrl: string | undefined; + let deploymentHostname: string | undefined; while (!done) { // eslint-disable-next-line no-await-in-loop @@ -334,8 +334,8 @@ export const getDeploymentStatus = async ( spinner.text = STEPS[data.event.step]; } - if (data.deployment_url) { - deploymentUrl = data.deployment_url; + if (data.deployment_hostnames && data.deployment_hostnames.length > 0) { + deploymentHostname = data.deployment_hostnames[0]; } }); } @@ -345,10 +345,10 @@ export const getDeploymentStatus = async ( spinner.success('Deployment completed successfully.'); - if (deploymentUrl) { - const url = deploymentUrl.startsWith('https://') ? deploymentUrl : `https://${deploymentUrl}`; - - consola.success(`View your deployment at: ${colorize('blue', url)}`); + if (deploymentHostname) { + consola.success( + `View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`, + ); } }; diff --git a/packages/catalyst/src/cli/commands/dev.spec.ts b/packages/catalyst/src/cli/commands/dev.spec.ts deleted file mode 100644 index 17ce28951d..0000000000 --- a/packages/catalyst/src/cli/commands/dev.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Command } from 'commander'; -import { execa } from 'execa'; -import { join } from 'node:path'; -import { expect, test, vi } from 'vitest'; - -import { program } from '../program'; - -import { dev } from './dev'; - -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), -})); - -vi.mock('execa', () => ({ - execa: vi.fn(() => Promise.resolve({ stdout: '' })), - __esModule: true, -})); - -test('properly configured Command instance', () => { - expect(dev).toBeInstanceOf(Command); - expect(dev.name()).toBe('dev'); - expect(dev.description()).toBe('Start the Catalyst development server.'); -}); - -test('calls execa with Next.js development server', async () => { - await program.parseAsync(['node', 'catalyst', 'dev', '-p', '3001']); - - expect(execa).toHaveBeenCalledWith( - join('node_modules', '.bin', 'next'), - ['dev', '-p', '3001'], - expect.objectContaining({ - stdio: 'inherit', - cwd: process.cwd(), - }), - ); -}); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index d8f417768f..136dce7c7d 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -296,7 +296,14 @@ describe('project list', () => { expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); expect(consola.log).toHaveBeenCalledWith('Project One (a23f5785-fd99-4a94-9fb3-945551623923)'); + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('https://project-one.catalyst-sandbox.store'), + ); + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('https://vanity.project-one.example.com'), + ); expect(consola.log).toHaveBeenCalledWith('Project Two (b23f5785-fd99-4a94-9fb3-945551623924)'); + expect(consola.log).toHaveBeenCalledWith(' (not deployed)'); expect(exitMock).toHaveBeenCalledWith(0); }); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 2df93cd37b..1079cd21bc 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -87,6 +87,16 @@ Example: const marker = p.uuid === linkedProjectUuid ? ` ${colorize('green', '[linked]')}` : ''; consola.log(`${p.name} (${p.uuid})${marker}`); + + if (p.deployment_hostnames.length === 0) { + consola.log(' (not deployed)'); + } else { + p.deployment_hostnames.forEach((hostname) => { + consola.log(` ${colorize('blue', `https://${hostname}`)}`); + }); + } + + consola.log(''); }); process.exit(0); diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts index 473e0d7e1f..09cca32154 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts @@ -85,7 +85,7 @@ describe('promptAndCreateCommerceHostingProject', () => { const result = await promptAndCreateCommerceHostingProject(API, []); - expect(result).toEqual({ uuid: 'u', name: 'my-project' }); + expect(result).toEqual({ uuid: 'u', name: 'my-project', deployment_hostnames: [] }); expect(createProjectSpy).toHaveBeenCalledTimes(1); expect(createProjectSpy).toHaveBeenCalledWith( 'my-project', @@ -118,7 +118,7 @@ describe('promptAndCreateCommerceHostingProject', () => { const result = await promptAndCreateCommerceHostingProject(API, []); - expect(result).toEqual({ uuid: 'u', name: 'good-name' }); + expect(result).toEqual({ uuid: 'u', name: 'good-name', deployment_hostnames: [] }); expect(inputMock).toHaveBeenCalledTimes(2); expect(createProjectSpy).toHaveBeenCalledTimes(2); }); @@ -136,7 +136,7 @@ describe('promptAndCreateCommerceHostingProject', () => { const result = await promptAndCreateCommerceHostingProject(API, []); - expect(result).toEqual({ uuid: 'u', name: 'good' }); + expect(result).toEqual({ uuid: 'u', name: 'good', deployment_hostnames: [] }); expect(inputMock).toHaveBeenCalledTimes(3); expect(createProjectSpy).toHaveBeenCalledTimes(3); }); @@ -234,7 +234,7 @@ describe('promptForCommerceHostingProject', () => { const result = await promptForCommerceHostingProject(API, 'fresh'); - expect(result).toEqual({ uuid: 'u', name: 'fresh' }); + expect(result).toEqual({ uuid: 'u', name: 'fresh', deployment_hostnames: [] }); expect(selectMock).not.toHaveBeenCalled(); expect(inputMock).not.toHaveBeenCalled(); expect(createProjectSpy).toHaveBeenCalledWith( @@ -247,14 +247,14 @@ describe('promptForCommerceHostingProject', () => { it('silently auto-creates with the supplied default name when other projects exist but none conflict', async () => { fetchProjectsSpy.mockResolvedValue([ - { uuid: 'aaa', name: 'unrelated-one' }, - { uuid: 'bbb', name: 'unrelated-two' }, + { uuid: 'aaa', name: 'unrelated-one', deployment_hostnames: [] }, + { uuid: 'bbb', name: 'unrelated-two', deployment_hostnames: [] }, ]); createProjectSpy.mockResolvedValueOnce(createdProject('new', 'my-store')); const result = await promptForCommerceHostingProject(API, 'my-store'); - expect(result).toEqual({ uuid: 'new', name: 'my-store' }); + expect(result).toEqual({ uuid: 'new', name: 'my-store', deployment_hostnames: [] }); expect(selectMock).not.toHaveBeenCalled(); expect(inputMock).not.toHaveBeenCalled(); expect(createProjectSpy).toHaveBeenCalledWith( @@ -267,26 +267,28 @@ describe('promptForCommerceHostingProject', () => { it('returns a selected existing project without calling create', async () => { const existing = [ - { uuid: 'aaa', name: 'first' }, - { uuid: 'bbb', name: 'second' }, + { uuid: 'aaa', name: 'first', deployment_hostnames: [] }, + { uuid: 'bbb', name: 'second', deployment_hostnames: [] }, ]; fetchProjectsSpy.mockResolvedValue(existing); selectMock .mockResolvedValueOnce('select-from-list') - .mockResolvedValueOnce({ uuid: 'bbb', name: 'second' }); + .mockResolvedValueOnce({ uuid: 'bbb', name: 'second', deployment_hostnames: [] }); const result = await promptForCommerceHostingProject(API, 'first'); - expect(result).toEqual({ uuid: 'bbb', name: 'second' }); + expect(result).toEqual({ uuid: 'bbb', name: 'second', deployment_hostnames: [] }); expect(createProjectSpy).not.toHaveBeenCalled(); expect(inputMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(2); }); it('routes to the create flow when the default name conflicts and the user chooses to create a new project', async () => { - fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'default-name' }]); + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'aaa', name: 'default-name', deployment_hostnames: [] }, + ]); createProjectSpy.mockResolvedValueOnce(createdProject('new', 'new-proj')); selectMock.mockResolvedValueOnce('create'); @@ -294,7 +296,7 @@ describe('promptForCommerceHostingProject', () => { const result = await promptForCommerceHostingProject(API, 'default-name'); - expect(result).toEqual({ uuid: 'new', name: 'new-proj' }); + expect(result).toEqual({ uuid: 'new', name: 'new-proj', deployment_hostnames: [] }); expect(createProjectSpy).toHaveBeenCalledWith( 'new-proj', API.storeHash, @@ -306,8 +308,8 @@ describe('promptForCommerceHostingProject', () => { it('shows the conflict-aware message and three choices when a conflict exists', async () => { fetchProjectsSpy.mockResolvedValue([ - { uuid: 'aaa', name: 'My-Store' }, - { uuid: 'bbb', name: 'other-project' }, + { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] }, + { uuid: 'bbb', name: 'other-project', deployment_hostnames: [] }, ]); selectMock.mockResolvedValueOnce('create'); @@ -327,9 +329,12 @@ describe('promptForCommerceHostingProject', () => { }); it('returns the conflicting project directly when the user picks Use ""', async () => { - const conflict = { uuid: 'aaa', name: 'My-Store' }; + const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] }; - fetchProjectsSpy.mockResolvedValue([conflict, { uuid: 'bbb', name: 'other-project' }]); + fetchProjectsSpy.mockResolvedValue([ + conflict, + { uuid: 'bbb', name: 'other-project', deployment_hostnames: [] }, + ]); selectMock.mockResolvedValueOnce('use-named'); @@ -342,8 +347,8 @@ describe('promptForCommerceHostingProject', () => { }); it('shows all projects (including the conflict) and a "Create a new project" option in the list', async () => { - const conflict = { uuid: 'aaa', name: 'My-Store' }; - const other = { uuid: 'bbb', name: 'other-project' }; + const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] }; + const other = { uuid: 'bbb', name: 'other-project', deployment_hostnames: [] }; fetchProjectsSpy.mockResolvedValue([conflict, other]); @@ -368,8 +373,8 @@ describe('promptForCommerceHostingProject', () => { }); it('routes to the create flow when the user picks "Create a new project" from the list', async () => { - const conflict = { uuid: 'aaa', name: 'My-Store' }; - const other = { uuid: 'bbb', name: 'other-project' }; + const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] }; + const other = { uuid: 'bbb', name: 'other-project', deployment_hostnames: [] }; fetchProjectsSpy.mockResolvedValue([conflict, other]); createProjectSpy.mockResolvedValueOnce(createdProject('new', 'fresh-name')); @@ -379,7 +384,7 @@ describe('promptForCommerceHostingProject', () => { const result = await promptForCommerceHostingProject(API, 'my-store'); - expect(result).toEqual({ uuid: 'new', name: 'fresh-name' }); + expect(result).toEqual({ uuid: 'new', name: 'fresh-name', deployment_hostnames: [] }); expect(createProjectSpy).toHaveBeenCalledWith( 'fresh-name', API.storeHash, @@ -389,7 +394,7 @@ describe('promptForCommerceHostingProject', () => { }); it('omits Select from my projects when the conflict is the only existing project', async () => { - const conflict = { uuid: 'aaa', name: 'My-Store' }; + const conflict = { uuid: 'aaa', name: 'My-Store', deployment_hostnames: [] }; fetchProjectsSpy.mockResolvedValue([conflict]); @@ -407,12 +412,14 @@ describe('promptForCommerceHostingProject', () => { }); it('skips all prompts and creates with the supplied name when autoUseDefaultName is true', async () => { - fetchProjectsSpy.mockResolvedValue([{ uuid: 'other', name: 'unrelated' }]); + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'other', name: 'unrelated', deployment_hostnames: [] }, + ]); createProjectSpy.mockResolvedValueOnce(createdProject('u', 'auto-name')); const result = await promptForCommerceHostingProject(API, 'auto-name', true); - expect(result).toEqual({ uuid: 'u', name: 'auto-name' }); + expect(result).toEqual({ uuid: 'u', name: 'auto-name', deployment_hostnames: [] }); expect(fetchProjectsSpy).toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); expect(inputMock).not.toHaveBeenCalled(); @@ -425,7 +432,7 @@ describe('promptForCommerceHostingProject', () => { }); it('returns the existing project when --project-name collides and the user picks Yes', async () => { - const existing = { uuid: 'aaa', name: 'taken-name' }; + const existing = { uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] }; fetchProjectsSpy.mockResolvedValue([existing]); @@ -447,7 +454,7 @@ describe('promptForCommerceHostingProject', () => { }); it('reuses the existing project without prompting when --use-existing is passed', async () => { - const existing = { uuid: 'aaa', name: 'taken-name' }; + const existing = { uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] }; fetchProjectsSpy.mockResolvedValue([existing]); @@ -459,7 +466,9 @@ describe('promptForCommerceHostingProject', () => { }); it('exits without prompting in non-interactive environments when --use-existing is not passed', async () => { - fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] }, + ]); const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${String(code)})`); @@ -480,7 +489,7 @@ describe('promptForCommerceHostingProject', () => { }); it('detects --project-name collision case-insensitively and reports the stored name', async () => { - const existing = { uuid: 'aaa', name: 'MyProject' }; + const existing = { uuid: 'aaa', name: 'MyProject', deployment_hostnames: [] }; fetchProjectsSpy.mockResolvedValue([existing]); @@ -502,7 +511,9 @@ describe('promptForCommerceHostingProject', () => { }); it('exits when --project-name collides and the user picks No', async () => { - fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'aaa', name: 'taken-name', deployment_hostnames: [] }, + ]); selectMock.mockResolvedValueOnce(false); diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.ts b/packages/catalyst/src/cli/lib/commerce-hosting.ts index 4457f276b7..afc44d2bc7 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.ts @@ -130,7 +130,8 @@ async function promptForNewProjectName(api: CommerceHostingApiContext): Promise< consola.success(`Project "${data.name}" created successfully.`); - return { uuid: data.uuid, name: data.name }; + // Newly created — provisioning is async, hostnames not registered yet. + return { uuid: data.uuid, name: data.name, deployment_hostnames: [] }; } // Generic "select an existing project, or create a new one" prompt — used by @@ -322,7 +323,7 @@ export async function promptAndCreateCommerceHostingProject( consola.success(`Commerce Hosting project "${created.name}" created successfully`); - return { uuid: created.uuid, name: created.name }; + return { uuid: created.uuid, name: created.name, deployment_hostnames: [] }; } catch (error) { if (error instanceof InfrastructureProjectValidationError) { consola.error(error.message); @@ -381,7 +382,7 @@ async function autoCreateCommerceHostingProject( consola.success(`Commerce Hosting project "${created.name}" created successfully`); - return { uuid: created.uuid, name: created.name }; + return { uuid: created.uuid, name: created.name, deployment_hostnames: [] }; } catch (error) { if (error instanceof InfrastructureProjectValidationError) { consola.error( diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index 52e99c37bc..cd6f55668a 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -14,6 +14,7 @@ const fetchProjectsSchema = z.object({ z.object({ uuid: z.string(), name: z.string(), + deployment_hostnames: z.array(z.string()), }), ), }); @@ -21,6 +22,7 @@ const fetchProjectsSchema = z.object({ export interface ProjectListItem { uuid: string; name: string; + deployment_hostnames: string[]; } function projectsUrl(storeHash: string, apiHost: string) { diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index 72c4004939..d2d685242d 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -29,8 +29,19 @@ export const handlers = [ http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => HttpResponse.json({ data: [ - { uuid: 'a23f5785-fd99-4a94-9fb3-945551623923', name: 'Project One' }, - { uuid: 'b23f5785-fd99-4a94-9fb3-945551623924', name: 'Project Two' }, + { + uuid: 'a23f5785-fd99-4a94-9fb3-945551623923', + name: 'Project One', + deployment_hostnames: [ + 'project-one.catalyst-sandbox.store', + 'vanity.project-one.example.com', + ], + }, + { + uuid: 'b23f5785-fd99-4a94-9fb3-945551623924', + name: 'Project Two', + deployment_hostnames: [], + }, ], }), ), @@ -44,14 +55,14 @@ export const handlers = [ controller.enqueue( encoder.encode( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"processing","progress":75},"deployment_url":null}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"processing","progress":75},"deployment_hostnames":[]}`, ), ); setTimeout(() => { controller.enqueue( encoder.encode( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"finalizing","progress":99},"deployment_url":null}`, + `data: {"deployment_status":"in_progress","deployment_uuid":"${params.deploymentUuid}","event":{"step":"finalizing","progress":99},"deployment_hostnames":[]}`, ), ); }, 10); @@ -59,7 +70,7 @@ export const handlers = [ controller.enqueue( encoder.encode( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `data: {"deployment_status":"completed","deployment_uuid":"${params.deploymentUuid}","event":null,"deployment_url":"https://example.com"}`, + `data: {"deployment_status":"completed","deployment_uuid":"${params.deploymentUuid}","event":null,"deployment_hostnames":["example.com"]}`, ), ); controller.close(); From 098a38acbaa08558ff887f7f17e96bbb338d9146 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 26 May 2026 11:16:37 -0500 Subject: [PATCH 61/90] feat(cli): add `channel update` command to update channel site url (#3001) --- .../catalyst/src/cli/commands/channel.spec.ts | 255 ++++++++++++++ packages/catalyst/src/cli/commands/channel.ts | 82 +++++ .../catalyst/src/cli/commands/deploy.spec.ts | 130 ++++++++ packages/catalyst/src/cli/commands/deploy.ts | 39 ++- packages/catalyst/src/cli/lib/auth.ts | 1 + .../src/cli/lib/channel-site-flow.spec.ts | 315 ++++++++++++++++++ .../catalyst/src/cli/lib/channel-site-flow.ts | 140 ++++++++ .../catalyst/src/cli/lib/channels.spec.ts | 107 ++++++ packages/catalyst/src/cli/lib/channels.ts | 51 +++ packages/catalyst/src/cli/program.ts | 2 + packages/catalyst/tests/mocks/handlers.ts | 20 ++ 11 files changed, 1140 insertions(+), 2 deletions(-) create mode 100644 packages/catalyst/src/cli/commands/channel.spec.ts create mode 100644 packages/catalyst/src/cli/commands/channel.ts create mode 100644 packages/catalyst/src/cli/lib/channel-site-flow.spec.ts create mode 100644 packages/catalyst/src/cli/lib/channel-site-flow.ts create mode 100644 packages/catalyst/src/cli/lib/channels.spec.ts diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts new file mode 100644 index 0000000000..1298a79adf --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -0,0 +1,255 @@ +import { Command } from 'commander'; +import Conf from 'conf'; +import { http, HttpResponse } from 'msw'; +import { afterAll, afterEach, beforeAll, describe, 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 { channel } from './channel'; + +let exitMock: MockInstance; + +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const { mockIdentify } = vi.hoisted(() => ({ + mockIdentify: vi.fn(), +})); + +const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + + vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +describe('channel', () => { + test('has the update subcommand', () => { + expect(channel).toBeInstanceOf(Command); + expect(channel.name()).toBe('channel'); + + const update = channel.commands.find((cmd) => cmd.name() === 'update'); + + expect(update).toBeDefined(); + expect(update?.description()).toContain('Update a BigCommerce channel'); + }); +}); + +describe('channel update', () => { + test('happy path: prompts for channel and hostname, then PUTs', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => { + config.set('projectUuid', linkedProjectUuid); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('vanity.project-one.example.com'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('https://vanity.project-one.example.com'), + ); + }); + + test('--channel-id and --hostname skip both prompts', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { id: 1, url: 'https://override.example', channel_id: 5 }, + }); + }, + ), + ); + + const promptMock = vi.spyOn(consola, 'prompt'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + '--channel-id', + '5', + '--hostname', + 'override.example', + ]); + + expect(promptMock).not.toHaveBeenCalled(); + expect(putChannelId).toBe('5'); + expect(putBody).toEqual({ url: 'https://override.example' }); + }); + + test('exits gracefully when no projects exist and user declines to create one', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // First prompt: "Would you like to create one?" — user says no + vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst project create')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('propagates errors from the channel-site PUT', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); +}); diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts new file mode 100644 index 0000000000..fe4f61c2d9 --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -0,0 +1,82 @@ +import { Command, Option } from 'commander'; + +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; +import { NoLinkedProjectError } from '../lib/commerce-hosting'; +import { consola } from '../lib/logger'; +import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; + +const update = new Command('update') + .configureHelp({ showGlobalOptions: true }) + .description( + "Update a BigCommerce channel's site URL to point at one of your project's deployment hostnames.", + ) + .addHelpText( + 'after', + ` +Examples: + # Pick a channel and hostname interactively + $ catalyst channel update + + # Skip both prompts + $ catalyst channel update --channel-id 123 --hostname my-storefront.example.com`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .addOption( + new Option( + '--channel-id ', + 'Skip the channel prompt and target this channel directly.', + ).argParser((value: string) => Number(value)), + ) + .addOption( + new Option( + '--hostname ', + "Skip the hostname prompt and use this hostname directly. Must be one of the project's deployment_hostnames.", + ), + ) + .action(async (options) => { + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await getTelemetry().identify(storeHash); + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost: options.apiHost, + projectUuid: options.projectUuid ?? config.get('projectUuid'), + channelId: options.channelId, + hostname: options.hostname, + }); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst channel update`.", + ); + process.exit(0); + + // Unreachable in production; prevents continuation when process.exit is mocked in tests. + return; + } + + throw error; + } + + process.exit(0); + }); + +export const channel = new Command('channel') + .configureHelp({ showGlobalOptions: true }) + .description('Manage BigCommerce channels.') + .addCommand(update); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 1a349660e5..fe763e231e 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -817,3 +817,133 @@ describe('transformation guard', () => { expect(installDependencies).not.toHaveBeenCalled(); }); }); + +describe('--update-site-url', () => { + function deployArgs(extra: string[] = []) { + return [ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ...extra, + ]; + } + + test('triggers the interactive flow and PUTs the chosen hostname after deploy', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + // Override the default consola.prompt stub (always-true in beforeEach) with + // the channel and hostname selections the interactive flow will ask for. + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('places the freshly-deployed hostname first in the hostname prompt', async () => { + let hostnameOptions: Array<{ label: string; value: string }> | undefined; + + // Project Two has no hostnames by default; use Project One whose handler + // already returns the two seeded hostnames. Inject the freshly-deployed + // hostname into Project One's list so preferHostname has something to match. + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [ + { + uuid: projectUuid, + name: 'Project One', + deployment_hostnames: [ + 'project-one.catalyst-sandbox.store', + 'example.com', // the just-deployed hostname (per the SSE default) + ], + }, + ], + }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') // channel + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('example.com'); + }); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(hostnameOptions?.[0]).toMatchObject({ value: 'example.com' }); + }); + + test('does not call the channel site API when the flag is omitted', async () => { + let putCalled = false; + + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => { + putCalled = true; + + return HttpResponse.json({}, { status: 200 }); + }), + ); + + await program.parseAsync(deployArgs()); + + expect(putCalled).toBe(false); + expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('Updated channel')); + }); + + test('soft-fails with a warning when the update API returns an error', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to update channel site URL'), + ); + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst auth login')); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index bab1224f8c..29c4c06091 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; import { NoLinkedProjectError, selectOrCreateInfrastructureProject, @@ -268,7 +269,7 @@ export const getDeploymentStatus = async ( storeHash: string, accessToken: string, apiHost: string, -) => { +): Promise => { consola.info('Fetching deployment status...'); const spinner = yoctoSpinner().start('Fetching...'); @@ -350,6 +351,8 @@ export const getDeploymentStatus = async ( `View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`, ); } + + return deploymentHostname; }; export const fetchProject = async ( @@ -394,6 +397,10 @@ Example: .addOption(accessTokenOption()) .addOption(apiHostOption()) .addOption(projectUuidOption()) + .option( + '--update-site-url', + "After a successful deploy, prompt to update a channel's site URL to the new hostname.", + ) .addOption( new Option( '--secret ', @@ -545,5 +552,33 @@ Example: environmentVariables, ); - await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost); + const deploymentHostname = await getDeploymentStatus( + deploymentUuid, + storeHash, + accessToken, + options.apiHost, + ); + + if (!options.updateSiteUrl) { + return; + } + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost: options.apiHost, + projectUuid, + preferHostname: deploymentHostname, + }); + } catch (error) { + // Soft-fail: the deploy already succeeded and the bundle is live. A + // non-zero exit here would be misleading. + consola.warn( + `Failed to update channel site URL: ${error instanceof Error ? error.message : String(error)}`, + ); + consola.info( + 'Update it manually in the control panel, or re-run `catalyst auth login` if the token is missing the store_channel_settings scope.', + ); + } }); diff --git a/packages/catalyst/src/cli/lib/auth.ts b/packages/catalyst/src/cli/lib/auth.ts index b59922b438..1aa1baac21 100644 --- a/packages/catalyst/src/cli/lib/auth.ts +++ b/packages/catalyst/src/cli/lib/auth.ts @@ -6,6 +6,7 @@ export const DEVICE_OAUTH_SCOPES = [ 'store_infrastructure_deployments_manage', 'store_infrastructure_logs_read_only', 'store_infrastructure_projects_manage', + 'store_channel_settings', ].join(' '); export const DEFAULT_LOGIN_URL = 'https://login.bigcommerce.com'; diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts new file mode 100644 index 0000000000..67ae9b3724 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -0,0 +1,315 @@ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { runChannelSiteUrlFlow } from './channel-site-flow'; +import { NoLinkedProjectError } from './commerce-hosting'; +import { consola } from './logger'; + +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; + +beforeAll(() => { + consola.mockTypes(() => vi.fn()); + + vi.mock('./telemetry', () => { + const instance = { + identify: vi.fn(), + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe('runChannelSiteUrlFlow', () => { + test('happy path: prompts for channel and hostname, then PUTs', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + // First prompt — channel select; resolve with channel id (as string) + .mockResolvedValueOnce('2') + // Second prompt — hostname select + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('--channel-id short-circuits the channel prompt', async () => { + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('vanity.project-one.example.com'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + channelId: 99, + }); + + // Only the hostname prompt fires + expect(promptMock).toHaveBeenCalledTimes(1); + expect(promptMock).toHaveBeenCalledWith( + 'Select the hostname to point the channel at.', + expect.any(Object), + ); + }); + + test('--hostname short-circuits the hostname prompt', async () => { + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + hostname: 'manual.example.com', + }); + + expect(promptMock).toHaveBeenCalledTimes(1); + expect(promptMock).toHaveBeenCalledWith('Select the channel to update.', expect.any(Object)); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('https://manual.example.com'), + ); + }); + + test('both overrides skip all prompts', async () => { + const promptMock = vi.spyOn(consola, 'prompt'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + channelId: 42, + hostname: 'auto.example.com', + }); + + expect(promptMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel 42 site URL to https://auto.example.com.'), + ); + }); + + test('preferHostname is placed first in the hostname options', async () => { + let hostnameOptions: Array<{ label: string; value: string }> | undefined; + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') // channel (the catalyst one) + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('vanity.project-one.example.com'); + }); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + preferHostname: 'vanity.project-one.example.com', + }); + + expect(hostnameOptions?.[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); + }); + + test('throws when no Catalyst channels are available', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + // Only non-Catalyst channels — filtered out, so the picker is empty. + data: [{ id: 1, name: 'Default Storefront', platform: 'bigcommerce' }], + }), + ), + ); + + await expect( + runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }), + ).rejects.toThrow('No Catalyst channels found on this store'); + }); + + test('filters non-Catalyst channels out of the picker', async () => { + let channelOptions: Array<{ label: string; value: string }> | undefined; + + vi.spyOn(consola, 'prompt') + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + channelOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('2'); + }) + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }); + + // The default handler returns one bigcommerce + one catalyst channel; only + // the catalyst one should appear in the picker. + expect(channelOptions).toHaveLength(1); + expect(channelOptions?.[0]).toMatchObject({ label: 'Catalyst Storefront', value: '2' }); + }); + + test('throws when the project has no deployment hostnames', async () => { + // Project Two in the default handler has deployment_hostnames: [] + const projectTwo = 'b23f5785-fd99-4a94-9fb3-945551623924'; + + vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + + await expect( + runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: projectTwo, + }), + ).rejects.toThrow('has no deployment hostnames yet'); + }); + + test('falls back to selectOrCreateInfrastructureProject when projectUuid is unset', async () => { + let getProjectsCalls = 0; + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => { + getProjectsCalls += 1; + + return HttpResponse.json({ + data: [ + { + uuid: linkedProjectUuid, + name: 'Project One', + deployment_hostnames: ['project-one.catalyst-sandbox.store'], + }, + ], + }); + }), + ); + + vi.spyOn(consola, 'prompt') + // selectOrCreateInfrastructureProject prompt — pick the only project + .mockResolvedValueOnce(linkedProjectUuid) + // channel + .mockResolvedValueOnce('2') + // hostname + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost }); + + expect(getProjectsCalls).toBeGreaterThanOrEqual(1); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('throws NoLinkedProjectError when no projects exist and user declines to create', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // The "create a new project?" confirm prompt — user says no + vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + + await expect(runChannelSiteUrlFlow({ storeHash, accessToken, apiHost })).rejects.toBeInstanceOf( + NoLinkedProjectError, + ); + }); + + test('warns and re-prompts when the linked project no longer exists', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [ + { + uuid: 'different-uuid', + name: 'Some Other Project', + deployment_hostnames: ['other.example.com'], + }, + ], + }), + ), + ); + + vi.spyOn(consola, 'prompt') + // project picker + .mockResolvedValueOnce('different-uuid') + // channel + .mockResolvedValueOnce('1') + // hostname + .mockResolvedValueOnce('other.example.com'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: 'a-uuid-that-does-not-exist', + }); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('not found on this store')); + }); +}); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts new file mode 100644 index 0000000000..01fb552973 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -0,0 +1,140 @@ +import { type Channel, fetchAvailableChannels, updateChannelSiteUrl } from './channels'; +import { selectOrCreateInfrastructureProject } from './commerce-hosting'; +import { consola } from './logger'; +import { fetchProjects, type ProjectListItem } from './project'; + +export interface ChannelSiteFlowOptions { + storeHash: string; + accessToken: string; + apiHost: string; + // Linked project UUID (from --project-uuid or .bigcommerce/project.json). When + // present and resolvable, skips the project picker; otherwise falls back to + // `selectOrCreateInfrastructureProject`, which may throw NoLinkedProjectError + // (caller decides how to surface it). + projectUuid?: string; + // Non-interactive overrides. When supplied, the corresponding prompt is + // skipped. + channelId?: number; + hostname?: string; + // When set, this hostname is pre-selected in the hostname prompt. Used by + // `catalyst deploy --update-site-url` to default to the freshly-deployed + // hostname. + preferHostname?: string; +} + +async function resolveProject(options: ChannelSiteFlowOptions): Promise { + const api = { + storeHash: options.storeHash, + accessToken: options.accessToken, + apiHost: options.apiHost, + }; + + if (options.projectUuid) { + const projects = await fetchProjects(api.storeHash, api.accessToken, api.apiHost); + const matched = projects.find((p) => p.uuid === options.projectUuid); + + if (matched) return matched; + + consola.warn( + `Project ${options.projectUuid} not found on this store. Pick another to continue.`, + ); + } + + return selectOrCreateInfrastructureProject(api, options.projectUuid); +} + +async function resolveChannel( + options: ChannelSiteFlowOptions, +): Promise<{ id: number; name?: string }> { + if (options.channelId !== undefined) { + return { id: options.channelId }; + } + + consola.start('Fetching channels...'); + + const channels = await fetchAvailableChannels( + options.storeHash, + options.accessToken, + options.apiHost, + ); + + consola.success('Channels fetched.'); + + // Only Catalyst-platform channels can meaningfully be pointed at a Catalyst + // deployment hostname; other storefront platforms (Stencil, etc.) are + // filtered out so the picker stays focused. + const catalystChannels = channels.filter((c: Channel) => c.platform === 'catalyst'); + + if (catalystChannels.length === 0) { + throw new Error( + 'No Catalyst channels found on this store. Create one with `catalyst create` and try again.', + ); + } + + const selectedId = await consola.prompt('Select the channel to update.', { + type: 'select', + options: catalystChannels.map((c: Channel) => ({ + label: c.name, + value: String(c.id), + hint: `id: ${c.id}`, + })), + cancel: 'reject', + }); + + const id = Number(selectedId); + const matched = catalystChannels.find((c) => c.id === id); + + return { id, name: matched?.name }; +} + +async function resolveHostname( + project: ProjectListItem, + options: ChannelSiteFlowOptions, +): Promise { + if (options.hostname) { + return options.hostname; + } + + if (project.deployment_hostnames.length === 0) { + throw new Error( + `Project "${project.name}" has no deployment hostnames yet. Run \`catalyst deploy\` first to create one.`, + ); + } + + // When the caller knows which hostname they want surfaced first (e.g. the + // freshly-deployed one from `catalyst deploy --update-site-url`), order it to + // the top of the list so it's the default selection. + const ordered = options.preferHostname + ? [ + ...project.deployment_hostnames.filter((h) => h === options.preferHostname), + ...project.deployment_hostnames.filter((h) => h !== options.preferHostname), + ] + : project.deployment_hostnames; + + const selected = await consola.prompt('Select the hostname to point the channel at.', { + type: 'select', + options: ordered.map((h) => ({ label: h, value: h })), + cancel: 'reject', + }); + + return String(selected); +} + +export async function runChannelSiteUrlFlow(options: ChannelSiteFlowOptions): Promise { + const project = await resolveProject(options); + const channel = await resolveChannel(options); + const hostname = await resolveHostname(project, options); + const siteUrl = hostname.startsWith('https://') ? hostname : `https://${hostname}`; + + await updateChannelSiteUrl( + channel.id, + siteUrl, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + const channelLabel = channel.name ? `"${channel.name}" (${channel.id})` : String(channel.id); + + consola.success(`Updated channel ${channelLabel} site URL to ${siteUrl}.`); +} diff --git a/packages/catalyst/src/cli/lib/channels.spec.ts b/packages/catalyst/src/cli/lib/channels.spec.ts new file mode 100644 index 0000000000..474b330731 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.spec.ts @@ -0,0 +1,107 @@ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { updateChannelSiteUrl } from './channels'; + +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const channelId = 1; + +beforeAll(() => { + vi.mock('./telemetry', () => { + const instance = { + identify: vi.fn(), + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe('updateChannelSiteUrl', () => { + test('PUTs the URL and returns parsed channel site', async () => { + let receivedBody: unknown; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request }) => { + receivedBody = await request.json(); + + return HttpResponse.json({ + data: { + id: 42, + url: 'https://new.example.com', + channel_id: channelId, + }, + }); + }, + ), + ); + + const result = await updateChannelSiteUrl( + channelId, + 'https://new.example.com', + storeHash, + accessToken, + apiHost, + ); + + expect(receivedBody).toEqual({ url: 'https://new.example.com' }); + expect(result).toEqual({ id: 42, url: 'https://new.example.com', channelId }); + }); + + test('throws with re-auth hint on 401', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); + + test('throws with re-auth hint on 403', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); + + test('throws with status on other errors', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 500 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Failed to update channel site: 500'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts index 81e3cd0178..91e6322470 100644 --- a/packages/catalyst/src/cli/lib/channels.ts +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -58,6 +58,14 @@ const eligibilityResponseSchema = z.object({ }), }); +const channelSiteSchema = z.object({ + data: z.object({ + id: z.number(), + url: z.string(), + channel_id: z.number(), + }), +}); + export interface ChannelInit { storefrontToken: string; envVars: Record; @@ -173,3 +181,46 @@ export async function fetchAvailableChannels( return channelsResponseSchema.parse(await response.json()).data; } + +export interface ChannelSite { + id: number; + url: string; + channelId: number; +} + +export async function updateChannelSiteUrl( + channelId: number, + siteUrl: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/channels/${channelId}/site`, + { + method: 'PUT', + headers: { + 'X-Auth-Token': accessToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, + }, + body: JSON.stringify({ url: siteUrl }), + }, + ); + + if (response.status === 401 || response.status === 403) { + throw new Error( + `Failed to update channel site (${response.status}). Re-run \`catalyst auth login\` to refresh your access token with the store_channel_settings scope.`, + ); + } + + if (!response.ok) { + throw new Error(`Failed to update channel site: ${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const { data } = channelSiteSchema.parse(res); + + return { id: data.id, url: data.url, channelId: data.channel_id }; +} diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 52a6bc411a..fa7dc0b2e6 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -9,6 +9,7 @@ import PACKAGE_INFO from '../../package.json'; import { auth } from './commands/auth'; import { build } from './commands/build'; +import { channel } from './commands/channel'; import { create } from './commands/create'; import { deploy } from './commands/deploy'; import { logs } from './commands/logs'; @@ -86,6 +87,7 @@ program .addCommand(deploy) .addCommand(logs) .addCommand(project) + .addCommand(channel) .addCommand(auth) .addCommand(telemetry) .hook('preAction', telemetryPreHook) diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index d2d685242d..69a8833bab 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -155,4 +155,24 @@ export const handlers = [ 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', () => new HttpResponse(null, { status: 204 }), ), + + // Default handler for fetchAvailableChannels — returns two storefront + // channels so the picker has something to render. Tests that need an + // empty list or different channel shapes should override with `server.use(...)`. + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + data: [ + { id: 1, name: 'Default Storefront', platform: 'bigcommerce' }, + { id: 2, name: 'Catalyst Storefront', platform: 'catalyst' }, + ], + }), + ), + + // Default handler for updateChannelSiteUrl — succeeds with a generic + // payload. Tests that need to assert error handling should override. + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({ + data: { id: 1, url: 'https://example.com', channel_id: 1 }, + }), + ), ]; From 92db8442034603a04b5aab07b5a94de7a8923a2d Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 26 May 2026 13:18:18 -0500 Subject: [PATCH 62/90] fix(cli): remove implicit secret detection from deploy (#3025) The deploy command used to silently scan process.env for an allowlist of keys (BIGCOMMERCE_STORE_HASH, BIGCOMMERCE_CHANNEL_ID, BIGCOMMERCE_STOREFRONT_TOKEN, BIGCOMMERCE_API_HOST, BIGCOMMERCE_GRAPHQL_API_DOMAIN, AUTH_SECRET) and ship any matches as deployment secrets. A developer with .env.local populated for local dev could push those dev values to production without noticing. Require explicit --secret KEY=VALUE declarations instead, matching the explicit-only model other deploy CLIs (Vercel, Netlify, Wrangler) follow. Co-authored-by: Claude --- .../catalyst/src/cli/commands/deploy.spec.ts | 86 ------------------- packages/catalyst/src/cli/commands/deploy.ts | 34 +------- 2 files changed, 1 insertion(+), 119 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index fe763e231e..af1f13cb03 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -27,7 +27,6 @@ import { program } from '../program'; import { buildCatalystProject } from './build'; import { - autoDetectSecrets, createDeployment, deploy, generateBundleZip, @@ -555,91 +554,6 @@ test('reads from env options', () => { ); }); -describe('autoDetectSecrets', () => { - test('auto-detects env vars from process.env', () => { - vi.stubEnv('BIGCOMMERCE_STORE_HASH', 'hash123'); - vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', '1'); - vi.stubEnv('BIGCOMMERCE_STOREFRONT_TOKEN', 'token456'); - vi.stubEnv('BIGCOMMERCE_API_HOST', 'api.bigcommerce.com'); - vi.stubEnv('BIGCOMMERCE_GRAPHQL_API_DOMAIN', 'graphql.bigcommerce.com'); - vi.stubEnv('AUTH_SECRET', 'secret789'); - - const result = autoDetectSecrets([]); - - expect(result).toEqual([ - { type: 'secret', key: 'BIGCOMMERCE_STORE_HASH', value: 'hash123' }, - { type: 'secret', key: 'BIGCOMMERCE_CHANNEL_ID', value: '1' }, - { type: 'secret', key: 'BIGCOMMERCE_STOREFRONT_TOKEN', value: 'token456' }, - { type: 'secret', key: 'BIGCOMMERCE_API_HOST', value: 'api.bigcommerce.com' }, - { type: 'secret', key: 'BIGCOMMERCE_GRAPHQL_API_DOMAIN', value: 'graphql.bigcommerce.com' }, - { type: 'secret', key: 'AUTH_SECRET', value: 'secret789' }, - ]); - - vi.unstubAllEnvs(); - }); - - test('skips env vars already provided by user', () => { - vi.stubEnv('BIGCOMMERCE_STORE_HASH', 'env-hash'); - vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', '99'); - - const result = autoDetectSecrets([ - { type: 'secret', key: 'BIGCOMMERCE_STORE_HASH', value: 'user-hash' }, - ]); - - expect(result).toContainEqual({ - type: 'secret', - key: 'BIGCOMMERCE_STORE_HASH', - value: 'user-hash', - }); - expect(result).not.toContainEqual( - expect.objectContaining({ key: 'BIGCOMMERCE_STORE_HASH', value: 'env-hash' }), - ); - expect(result).toContainEqual({ - type: 'secret', - key: 'BIGCOMMERCE_CHANNEL_ID', - value: '99', - }); - - vi.unstubAllEnvs(); - }); - - test('warns for required missing env vars', () => { - vi.stubEnv('BIGCOMMERCE_STORE_HASH', ''); - vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', ''); - vi.stubEnv('BIGCOMMERCE_STOREFRONT_TOKEN', ''); - - autoDetectSecrets([]); - - expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('BIGCOMMERCE_STORE_HASH')); - expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('BIGCOMMERCE_CHANNEL_ID')); - expect(consola.warn).toHaveBeenCalledWith( - expect.stringContaining('BIGCOMMERCE_STOREFRONT_TOKEN'), - ); - - vi.unstubAllEnvs(); - }); - - test('silently skips optional missing env vars', () => { - vi.stubEnv('BIGCOMMERCE_API_HOST', ''); - vi.stubEnv('BIGCOMMERCE_GRAPHQL_API_DOMAIN', ''); - - autoDetectSecrets([]); - - const warnCalls = vi.mocked(consola.warn).mock.calls.flat().join(' '); - - expect(warnCalls).not.toContain('BIGCOMMERCE_API_HOST'); - expect(warnCalls).not.toContain('BIGCOMMERCE_GRAPHQL_API_DOMAIN'); - - vi.unstubAllEnvs(); - }); - - test('returns empty array when no env vars and no input', () => { - const result = autoDetectSecrets(undefined); - - expect(result).toEqual(expect.arrayContaining([])); - }); -}); - describe('--prebuilt flag', () => { test('skips build step when --prebuilt is passed', async () => { await program.parseAsync([ diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 29c4c06091..cf9e707c8b 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -192,38 +192,6 @@ export const parseEnvironmentVariables = (secretOption?: string[]) => { }); }; -const AUTO_DETECT_SECRETS = [ - { key: 'BIGCOMMERCE_STORE_HASH', warnIfMissing: true }, - { key: 'BIGCOMMERCE_CHANNEL_ID', warnIfMissing: true }, - { key: 'BIGCOMMERCE_STOREFRONT_TOKEN', warnIfMissing: true }, - { key: 'BIGCOMMERCE_API_HOST', warnIfMissing: false }, - { key: 'BIGCOMMERCE_GRAPHQL_API_DOMAIN', warnIfMissing: false }, - { key: 'AUTH_SECRET', warnIfMissing: false }, -]; - -export const autoDetectSecrets = ( - environmentVariables?: Array<{ type: 'secret' | 'plain_text'; key: string; value: string }>, -) => { - const secrets = environmentVariables ?? []; - const existingKeys = new Set(secrets.map((s) => s.key)); - - AUTO_DETECT_SECRETS.forEach(({ key, warnIfMissing }) => { - if (existingKeys.has(key)) { - return; - } - - const value = process.env[key]; - - if (value) { - secrets.push({ type: 'secret', key, value }); - } else if (warnIfMissing) { - consola.warn(`${key} is not set in the environment and was not provided via --secret.`); - } - }); - - return secrets; -}; - export const createDeployment = async ( projectUuid: string, uploadUuid: string, @@ -541,7 +509,7 @@ Example: await uploadBundleZip(uploadSignature.upload_url); - const environmentVariables = autoDetectSecrets(parseEnvironmentVariables(options.secret)); + const environmentVariables = parseEnvironmentVariables(options.secret); const { deployment_uuid: deploymentUuid } = await createDeployment( projectUuid, From bbabe004dee15917b069be062ceaa592c11f4eaa Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 26 May 2026 14:18:24 -0500 Subject: [PATCH 63/90] feat(cli): make catalyst auth login and project create onboarding interactive (#3026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * LTRAC-613: feat(cli) - Make auth login and project create onboarding interactive First-time users hit `failed to request device code: 404 Not Found` when running `catalyst auth login` before `catalyst project create`, with no obvious way to recover. Make the login flow self-sufficient by adding a manual-credentials fallback: if the device-code endpoint fails, the CLI warns with the underlying reason, asks whether to fall back to entering a store hash + access token directly, and validates the manual creds against the store profile API before persisting them. `catalyst project create` no longer fails fast when credentials are missing — it kicks off the same interactive login flow inline, so users don't need to know the ordering. `catalyst create` (scaffolding) picks up the manual fallback automatically since it shares the orchestrator. Refs LTRAC-613 Co-Authored-By: Claude * LTRAC-613: chore(cli) - Drop changeset for interactive auth onboarding Refs LTRAC-613 Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../catalyst/src/cli/commands/auth.spec.ts | 131 +++++++++++++++++- packages/catalyst/src/cli/commands/auth.ts | 55 +++----- .../catalyst/src/cli/commands/create.spec.ts | 50 ++++++- packages/catalyst/src/cli/commands/create.ts | 23 ++- .../catalyst/src/cli/commands/project.spec.ts | 98 ++++++++++++- packages/catalyst/src/cli/commands/project.ts | 38 ++++- packages/catalyst/src/cli/lib/login.ts | 115 ++++++++++++++- .../catalyst/src/cli/lib/shared-options.ts | 7 + 8 files changed, 463 insertions(+), 54 deletions(-) diff --git a/packages/catalyst/src/cli/commands/auth.spec.ts b/packages/catalyst/src/cli/commands/auth.spec.ts index 7a9b40126a..d6dbbe9592 100644 --- a/packages/catalyst/src/cli/commands/auth.spec.ts +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -1,3 +1,4 @@ +import { password } from '@inquirer/prompts'; import { Command } from 'commander'; import { http, HttpResponse } from 'msw'; import { realpath } from 'node:fs/promises'; @@ -25,6 +26,11 @@ import { auth } from './auth'; // eslint-disable-next-line import/dynamic-import-chunkname vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@inquirer/prompts', () => ({ + password: vi.fn(), +})); + +const passwordMock = vi.mocked(password); let exitMock: MockInstance; let tmpDir: string; @@ -149,18 +155,137 @@ describe('login', () => { expect(exitMock).toHaveBeenCalledWith(0); }); - test('handles API error during device code request', async () => { + test('prompts to fall back to manual login when device code request fails', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + passwordMock.mockResolvedValueOnce('manual-access-token'); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + expect(message).toContain('Try logging in manually'); + expect(opts).toMatchObject({ type: 'confirm' }); + + return Promise.resolve(true); + }) + .mockImplementationOnce(async (message, opts) => { + expect(message).toBe('Store hash:'); + expect(opts).toMatchObject({ type: 'text' }); + + return Promise.resolve('manual-store-hash'); + }); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work")); + expect(consola.success).toHaveBeenCalledWith('Logged in to store manual-store-hash.'); + expect(exitMock).toHaveBeenCalledWith(0); + + const config = getProjectConfig(); + + expect(config.get('storeHash')).toBe('manual-store-hash'); + expect(config.get('accessToken')).toBe('manual-access-token'); + + promptMock.mockRestore(); + }); + + test('exits cleanly when user declines manual login fallback', async () => { server.use( http.post( 'https://login.bigcommerce.com/device/token', - () => new HttpResponse(null, { status: 500, statusText: 'Internal Server Error' }), + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), ), ); + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); - expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Login failed')); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work")); + expect(consola.info).toHaveBeenCalledWith( + 'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + + promptMock.mockRestore(); + }); + + test('fails when manual credentials cannot be validated', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + http.get( + 'https://:apiHost/stores/:storeHash/v3/settings/store/profile', + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + + passwordMock.mockResolvedValueOnce('bad-token'); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce('manual-store-hash'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('Could not validate credentials'), + ); expect(exitMock).toHaveBeenCalledWith(1); + + promptMock.mockRestore(); + }); + + test('rejects empty store hash during manual login', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(' '); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Store hash is required')); + expect(exitMock).toHaveBeenCalledWith(1); + + promptMock.mockRestore(); + }); + + test('rejects empty access token during manual login', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + passwordMock.mockResolvedValueOnce(' '); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce('manual-store-hash'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Access token is required')); + expect(exitMock).toHaveBeenCalledWith(1); + + promptMock.mockRestore(); }); test('handles browser open failure gracefully', async () => { diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts index 6c9e9f4b0f..91a68202db 100644 --- a/packages/catalyst/src/cli/commands/auth.ts +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -1,13 +1,11 @@ import { Command, Option } from 'commander'; -import { colorize } from 'consola/utils'; -import open from 'open'; -import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; -import { DEFAULT_LOGIN_URL, requestDeviceCode, waitForDeviceToken } from '../lib/auth'; import { consola } from '../lib/logger'; +import { LoginAbortedError, login as runInteractiveLogin } from '../lib/login'; import { fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; +import { loginUrlOption } from '../lib/shared-options'; const StoreProfileSchema = z.object({ data: z.object({ @@ -122,16 +120,16 @@ Example: const login = new Command('login') .configureHelp({ showGlobalOptions: true }) .description( - 'Authenticate via browser using the OAuth device code flow. If already logged in, displays current credentials and suggests running `catalyst auth logout` to re-authenticate.', + 'Authenticate via browser using the OAuth device code flow. Falls back to an interactive store hash + access token prompt if the browser flow is unavailable. If already logged in, displays current credentials and suggests running `catalyst auth logout` to re-authenticate.', ) .addHelpText( 'after', ` Examples: - # Login via browser (recommended) + # Login interactively (browser, with manual fallback) $ catalyst auth login - # Login with existing credentials (skips browser flow) + # Login with existing credentials (skips interactive flow) $ catalyst auth login --store-hash --access-token `, ) .addOption( @@ -147,11 +145,12 @@ Examples: ).env('CATALYST_ACCESS_TOKEN'), ) .addOption( - new Option('--login-url ', 'BigCommerce login URL.') - .env('BIGCOMMERCE_LOGIN_URL') - .default(DEFAULT_LOGIN_URL) + new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') + .env('BIGCOMMERCE_API_HOST') + .default('api.bigcommerce.com') .hideHelp(), ) + .addOption(loginUrlOption()) .action(async (options) => { try { const config = getProjectConfig(); @@ -167,37 +166,23 @@ Examples: return; } - const deviceCode = await requestDeviceCode(options.loginUrl); + const credentials = await runInteractiveLogin(options.loginUrl, options.apiHost); - consola.info( - `${colorize('yellow', 'Your one-time code:')} ${colorize('bold', deviceCode.user_code)}`, - ); + config.set('storeHash', credentials.storeHash); + config.set('accessToken', credentials.accessToken); - try { - await open(deviceCode.verification_uri); - consola.info(`Opened ${deviceCode.verification_uri} in your browser.`); - } catch { + consola.success(`Logged in to store ${credentials.storeHash}.`); + process.exit(0); + } catch (error) { + if (error instanceof LoginAbortedError) { consola.info( - `Open ${deviceCode.verification_uri} in your browser and enter the code above.`, + 'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.', ); - } - - const spinner = yoctoSpinner().start('Waiting for authentication...'); - - const credentials = await waitForDeviceToken( - options.loginUrl, - deviceCode.device_code, - deviceCode.interval, - ); - - spinner.success('Authentication complete.'); + process.exit(0); - config.set('storeHash', credentials.store_hash); - config.set('accessToken', credentials.access_token); + return; + } - consola.success(`Logged in to store ${credentials.store_hash}.`); - process.exit(0); - } catch (error) { const message = error instanceof Error ? error.message : String(error); consola.error(`Login failed: ${message}`); diff --git a/packages/catalyst/src/cli/commands/create.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts index 11818b45c0..5f0cba5445 100644 --- a/packages/catalyst/src/cli/commands/create.spec.ts +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -8,7 +8,7 @@ import { cloneCatalyst } from '../lib/clone-catalyst'; import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; -import { login } from '../lib/login'; +import { login, LoginAbortedError } from '../lib/login'; import { mkTempDir } from '../lib/mk-temp-dir'; import { hasProjectsAccess } from '../lib/project'; import { setupCoreProject } from '../lib/setup-core-project'; @@ -26,11 +26,21 @@ vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), })); +const { MockLoginAbortedError } = vi.hoisted(() => ({ + MockLoginAbortedError: class extends Error { + constructor() { + super('Login aborted by user.'); + this.name = 'LoginAbortedError'; + } + }, +})); + vi.mock('../lib/login', () => ({ login: vi.fn().mockResolvedValue({ storeHash: 'login-store-hash', accessToken: 'login-access-token', }), + LoginAbortedError: MockLoginAbortedError, })); vi.mock('../lib/clone-catalyst', () => ({ cloneCatalyst: vi.fn() })); @@ -428,6 +438,44 @@ describe('failure handling', () => { expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('partial state')); }); + + test('exits cleanly when the user aborts the interactive login', async () => { + vi.mocked(login).mockRejectedValueOnce(new LoginAbortedError()); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + ]); + + expect(consola.info).toHaveBeenCalledWith( + 'Login aborted. Re-run `catalyst create` when you have your credentials ready.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + expect(cloneCatalyst).not.toHaveBeenCalled(); + }); + + test('propagates non-LoginAbortedError login failures', async () => { + vi.mocked(login).mockRejectedValueOnce(new Error('network down')); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + ]), + ).rejects.toThrow('network down'); + + expect(cloneCatalyst).not.toHaveBeenCalled(); + }); }); describe('--hosting commerce preconditions', () => { diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts index 05e4db8e24..cd015a2ad4 100644 --- a/packages/catalyst/src/cli/commands/create.ts +++ b/packages/catalyst/src/cli/commands/create.ts @@ -19,7 +19,7 @@ import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/co import { installDependencies } from '../lib/install-dependencies'; import { getAvailableLocales } from '../lib/localization'; import { consola } from '../lib/logger'; -import { login } from '../lib/login'; +import { login, LoginAbortedError } from '../lib/login'; import { hasProjectsAccess, type ProjectListItem } from '../lib/project'; import { setupCoreProject } from '../lib/setup-core-project'; import { accessTokenOption, storeHashOption } from '../lib/shared-options'; @@ -333,10 +333,25 @@ Examples: // access token. Device login covers the missing pieces; the user picks the // store during the OAuth flow regardless of any partial flags they passed. if (!storeHash || !accessToken) { - const credentials = await login(options.loginUrl); + const apiHost = `api.${options.bigcommerceHostname}`; + + try { + const credentials = await login(options.loginUrl, apiHost); + + storeHash = credentials.storeHash; + accessToken = credentials.accessToken; + } catch (error) { + if (error instanceof LoginAbortedError) { + consola.info( + 'Login aborted. Re-run `catalyst create` when you have your credentials ready.', + ); + process.exit(0); - storeHash = credentials.storeHash; - accessToken = credentials.accessToken; + return; + } + + throw error; + } } const useCommerceHosting = options.hosting === 'commerce'; diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 136dce7c7d..11ed2b7198 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -1,3 +1,4 @@ +import { password } from '@inquirer/prompts'; import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; @@ -24,6 +25,13 @@ import { program } from '../program'; import { link, project } from './project'; +// eslint-disable-next-line import/dynamic-import-chunkname +vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@inquirer/prompts', () => ({ + password: vi.fn(), +})); + vi.mock('../lib/project-state', () => ({ getProjectState: vi.fn(), })); @@ -32,6 +40,8 @@ vi.mock('../lib/install-dependencies', () => ({ installDependencies: vi.fn().mockResolvedValue(undefined), })); +const passwordMock = vi.mocked(password); + vi.mock('../lib/commerce-hosting', async (importOriginal) => { const actual = await importOriginal(); @@ -204,26 +214,100 @@ describe('project create', () => { consolaPromptMock.mockRestore(); }); - test('with insufficient credentials exits with error', async () => { - // Unset env so Commander doesn't pick up CATALYST_* and trigger the create flow (which would prompt for name) + test('runs interactive login when no credentials are provided', async () => { 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; - await expect(program.parseAsync(['node', 'catalyst', 'project', 'create'])).rejects.toThrow( - 'Missing credentials', + const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('My New Project'); + + await program.parseAsync(['node', 'catalyst', 'project', 'create']); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.info).toHaveBeenCalledWith( + "You're not logged in yet. Let's get you authenticated before creating a project.", + ); + expect(consola.success).toHaveBeenCalledWith('Logged in to store mock-store-hash.'); + expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('storeHash')).toBe('mock-store-hash'); + expect(config.get('accessToken')).toBe('mock-access-token'); + expect(config.get('projectUuid')).toBe(projectUuid3); + + consolaPromptMock.mockRestore(); + }); + + test('falls back to manual login when the device flow fails', async () => { + 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; + + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + passwordMock.mockResolvedValueOnce(accessToken); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(storeHash) + .mockResolvedValueOnce('My New Project'); + + await program.parseAsync(['node', 'catalyst', 'project', 'create']); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work")); + expect(consola.success).toHaveBeenCalledWith(`Logged in to store ${storeHash}.`); + expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('storeHash')).toBe(storeHash); + expect(config.get('accessToken')).toBe(accessToken); + + consolaPromptMock.mockRestore(); + }); + + test('exits cleanly when the user aborts the manual login fallback', async () => { + 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; + + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), ); + const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + + await program.parseAsync(['node', 'catalyst', 'project', 'create']); + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; - expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); expect(consola.info).toHaveBeenCalledWith( - 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', + 'Login aborted. Re-run `catalyst project create` when you have your credentials ready.', ); - expect(exitMock).toHaveBeenCalledWith(1); + expect(exitMock).toHaveBeenCalledWith(0); + expect(config.get('projectUuid')).toBeUndefined(); + + consolaPromptMock.mockRestore(); }); test('propagates create project API errors', async () => { diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 1079cd21bc..0054456287 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -9,6 +9,7 @@ import { } from '../lib/commerce-hosting'; import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; +import { LoginAbortedError, login as runInteractiveLogin } from '../lib/login'; import { createProject, deleteProject, fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; import { getProjectState } from '../lib/project-state'; @@ -16,6 +17,7 @@ import { resolveCredentials } from '../lib/resolve-credentials'; import { accessTokenOption, apiHostOption, + loginUrlOption, projectUuidOption, storeHashOption, } from '../lib/shared-options'; @@ -105,7 +107,7 @@ Example: const create = new Command('create') .configureHelp({ showGlobalOptions: true }) .description( - 'Create a new BigCommerce infrastructure project and link it to your local Catalyst project.', + 'Create a new BigCommerce infrastructure project and link it to your local Catalyst project. Walks you through logging in if you are not authenticated yet.', ) .addHelpText( 'after', @@ -116,9 +118,41 @@ Example: .addOption(storeHashOption()) .addOption(accessTokenOption()) .addOption(apiHostOption()) + .addOption(loginUrlOption()) .action(async (options) => { const config = getProjectConfig(); - const { storeHash, accessToken } = resolveCredentials(options, config); + + let storeHash = options.storeHash ?? config.get('storeHash'); + let accessToken = options.accessToken ?? config.get('accessToken'); + + if (!storeHash || !accessToken) { + consola.info( + "You're not logged in yet. Let's get you authenticated before creating a project.", + ); + + try { + const credentials = await runInteractiveLogin(options.loginUrl, options.apiHost); + + storeHash = credentials.storeHash; + accessToken = credentials.accessToken; + + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + consola.success(`Logged in to store ${storeHash}.`); + } catch (error) { + if (error instanceof LoginAbortedError) { + consola.info( + 'Login aborted. Re-run `catalyst project create` when you have your credentials ready.', + ); + process.exit(0); + + return; + } + + throw error; + } + } await getTelemetry().identify(storeHash); diff --git a/packages/catalyst/src/cli/lib/login.ts b/packages/catalyst/src/cli/lib/login.ts index 49cb4344fe..4891111e7b 100644 --- a/packages/catalyst/src/cli/lib/login.ts +++ b/packages/catalyst/src/cli/lib/login.ts @@ -1,8 +1,10 @@ +import { password } from '@inquirer/prompts'; import { colorize } from 'consola/utils'; import open from 'open'; import yoctoSpinner from 'yocto-spinner'; +import { z } from 'zod'; -import { requestDeviceCode, waitForDeviceToken } from './auth'; +import { DEVICE_OAUTH_SCOPES, requestDeviceCode, waitForDeviceToken } from './auth'; import { consola } from './logger'; export interface LoginResult { @@ -10,7 +12,46 @@ export interface LoginResult { accessToken: string; } -export async function login(loginUrl: string): Promise { +// Thrown when the user declines the manual-login fallback after the browser +// (device-code) flow has failed. Callers should treat this as a clean exit, +// not an error — the user explicitly chose to abort. +export class LoginAbortedError extends Error { + constructor() { + super('Login aborted by user.'); + this.name = 'LoginAbortedError'; + } +} + +const StoreProfileSchema = z.object({ + data: z.object({ + store_name: z.string(), + }), +}); + +async function fetchStoreProfile(storeHash: string, accessToken: string, apiHost: string) { + const response = await fetch(`https://${apiHost}/stores/${storeHash}/v3/settings/store/profile`, { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const result = StoreProfileSchema.safeParse(res); + + if (!result.success) { + throw new Error('Unexpected response from store profile API'); + } + + return result.data.data; +} + +async function deviceCodeLogin(loginUrl: string): Promise { const deviceCode = await requestDeviceCode(loginUrl); consola.info( @@ -39,3 +80,73 @@ export async function login(loginUrl: string): Promise { accessToken: credentials.access_token, }; } + +async function manualLogin(apiHost: string): Promise { + consola.info( + 'Create a store-level API account from your BigCommerce Control Panel:\n' + + ' Settings → API → Store-level API accounts → Create API account\n' + + `Grant these OAuth scopes: ${DEVICE_OAUTH_SCOPES}`, + ); + + const storeHashInput = await consola.prompt('Store hash:', { type: 'text' }); + const storeHash = String(storeHashInput).trim(); + + if (!storeHash) { + throw new Error('Store hash is required.'); + } + + const accessTokenInput = await password({ message: 'Access token:', mask: true }); + const accessToken = accessTokenInput.trim(); + + if (!accessToken) { + throw new Error('Access token is required.'); + } + + const spinner = yoctoSpinner().start('Validating credentials...'); + + try { + const profile = await fetchStoreProfile(storeHash, accessToken, apiHost); + + spinner.success(`Validated credentials for ${profile.store_name} (${storeHash}).`); + } catch (error) { + spinner.error('Failed to validate credentials.'); + + const message = error instanceof Error ? error.message : String(error); + + throw new Error( + `Could not validate credentials (${message}). Double-check your store hash and access token, then try again.`, + ); + } + + return { storeHash, accessToken }; +} + +// Interactive login orchestrator. Used by `catalyst create`, `catalyst auth +// login`, and `catalyst project create` to gather credentials when none are +// supplied via flags/env/config. +// +// Happy path: opens the browser device-code flow. When that fails (e.g. the +// device-code endpoint returns 404 because the OAuth client isn't yet +// provisioned for the user's environment), we ask the user if they'd like to +// fall back to pasting a store-level API account's credentials instead, then +// validate them via the store profile API before returning. +export async function login(loginUrl: string, apiHost: string): Promise { + try { + return await deviceCodeLogin(loginUrl); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + consola.warn(`Browser login didn't work (${message}).`); + + const shouldFallback = await consola.prompt( + 'Try logging in manually with a store hash and access token instead?', + { type: 'confirm', initial: true }, + ); + + if (!shouldFallback) { + throw new LoginAbortedError(); + } + + return manualLogin(apiHost); + } +} diff --git a/packages/catalyst/src/cli/lib/shared-options.ts b/packages/catalyst/src/cli/lib/shared-options.ts index 7c0d0b517f..dcbb91b3eb 100644 --- a/packages/catalyst/src/cli/lib/shared-options.ts +++ b/packages/catalyst/src/cli/lib/shared-options.ts @@ -1,5 +1,6 @@ import { Option } from 'commander'; +import { DEFAULT_LOGIN_URL } from './auth'; import { getProjectConfig } from './project-config'; export const storeHashOption = () => @@ -20,6 +21,12 @@ export const apiHostOption = () => .default('api.bigcommerce.com') .hideHelp(); +export const loginUrlOption = () => + new Option('--login-url ', 'BigCommerce login URL.') + .env('BIGCOMMERCE_LOGIN_URL') + .default(DEFAULT_LOGIN_URL) + .hideHelp(); + export const projectUuidOption = () => new Option( '--project-uuid ', From b883cd8ee03402c5d8f9725af9e58818ad62b9cd Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 27 May 2026 10:13:54 -0500 Subject: [PATCH 64/90] LTRAC-440: fix(cli) - Autoreconnect log stream when API proxy stalls (#3027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the API proxy half-closes the socket — bytes stop arriving but no FIN or error is surfaced — `reader.read()` blocks forever, so the 1-minute connection TTL check below it never runs and the stream goes silent until the user restarts `catalyst logs tail`. Race `reader.read()` against the remaining TTL so the TTL acts as an absolute deadline. When it fires we cancel the reader, break out of the inner loop, and the outer reconnect loop opens a fresh stream. Warn the user only when no data arrived during the window — healthy rotations stay silent to match existing behavior. Refs LTRAC-440 Co-authored-by: Claude --- .../catalyst/src/cli/commands/logs.spec.ts | 44 ++++++++++++++++ packages/catalyst/src/cli/commands/logs.ts | 52 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts index 7dae33324a..4b4bdef670 100644 --- a/packages/catalyst/src/cli/commands/logs.spec.ts +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -453,6 +453,50 @@ describe('retry and reconnect', () => { expect(consola.warn).toHaveBeenCalledWith('Log stream closed by server, reconnecting...'); }); + test( + 'reconnects when the stream stalls without emitting any data', + { timeout: 3000 }, + async () => { + let requestCount = 0; + const ttlMs = 200; + + // Stream that stays open but never enqueues — simulates an API proxy + // half-closing the socket: bytes stop arriving but no FIN or error is + // surfaced, so reader.read() would otherwise block forever. + const createStalledStream = () => + new ReadableStream({ + start() { + // intentionally empty + }, + }); + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + if (requestCount <= 2) { + return new HttpResponse(createStalledStream(), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + }, + ), + ); + + await expect( + tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default', ttlMs), + ).rejects.toThrow('Failed to open log stream: 404 Not Found'); + + expect(requestCount).toBe(3); + expect(consola.warn).toHaveBeenCalledWith('Log stream idle, reconnecting...'); + }, + ); + test('reconnects when connection TTL is reached', { timeout: 3000 }, async () => { let requestCount = 0; const ttlMs = 200; diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index 702efadb41..d345d6656f 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -127,6 +127,32 @@ const processLogEvent = (event: string, format: LogFormat) => { } }; +type TimeoutRaceResult = { kind: 'value'; value: T } | { kind: 'timeout' }; + +// Races a promise against a timer so a hung `reader.read()` doesn't block the +// reconnect loop. Without this, the connection TTL check below the read() call +// never runs when the API proxy half-closes the socket — bytes stop arriving +// but no FIN/error surfaces, so the read promise stays pending forever. +const raceWithTimeout = async ( + promise: Promise, + timeoutMs: number, +): Promise> => { + let timeoutHandle: ReturnType | undefined; + + const timeoutPromise = new Promise>((resolve) => { + timeoutHandle = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); + }); + + try { + return await Promise.race([ + promise.then((value): TimeoutRaceResult => ({ kind: 'value', value })), + timeoutPromise, + ]); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } +}; + const openLogStream = async ( projectUuid: string, storeHash: string, @@ -181,15 +207,37 @@ export const tailLogs = async ( const decoder = new TextDecoder(); const connectTime = Date.now(); let buffer = ''; + let receivedData = false; retries = 0; // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition while (true) { + const remainingTtlMs = connectionTtlMs - (Date.now() - connectTime); + + if (remainingTtlMs <= 0) { + void reader.cancel(); + break; + } + // eslint-disable-next-line no-await-in-loop - const { value, done: streamDone } = await reader.read(); + const readResult = await raceWithTimeout(reader.read(), remainingTtlMs); + + if (readResult.kind === 'timeout') { + // No bytes for the whole TTL window — proxy likely half-closed the + // socket. Warn the user so they see the reconnect happen. + if (!receivedData) { + consola.warn('Log stream idle, reconnecting...'); + } + + void reader.cancel(); + break; + } + + const { value, done: streamDone } = readResult.value; if (value) { + receivedData = true; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); @@ -203,7 +251,7 @@ export const tailLogs = async ( .forEach((event) => processLogEvent(event, format)); } - if (streamDone || Date.now() - connectTime >= connectionTtlMs) { + if (streamDone) { void reader.cancel(); break; } From 0c9515371fc306015ae50424b22d843e6d82233b Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 28 May 2026 14:52:15 -0500 Subject: [PATCH 65/90] fix(cli): clean dist directory before each build (#3030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrangler's `--outdir` writes the deployment bundle alongside whatever is already in `.bigcommerce/dist` rather than replacing the directory. When Wrangler is upgraded between builds — or any prior artifact lingers — old files end up in the zip uploaded to the server. In one observed case the dist contained both the current Wrangler 4.x plain wasm names and `?module`-suffixed copies from an older Wrangler version. The `?` in the filenames confused the server-side multipart upload to Cloudflare and the deploy failed with: Uncaught Error: No such module "-resvg.wasm". imported from "worker.js" Wipe the dist directory at the start of `buildCatalystProject` so each build starts from a clean slate. Refs LTRAC-814 Co-authored-by: Claude --- packages/catalyst/src/cli/commands/build.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index a8da79513f..8d26534f4c 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -1,6 +1,6 @@ import { Command, Option } from 'commander'; import { execa } from 'execa'; -import { copyFile, cp, writeFile } from 'node:fs/promises'; +import { copyFile, cp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { getModuleCliPath } from '../lib/get-module-cli-path'; @@ -18,6 +18,11 @@ export async function buildCatalystProject(projectUuid: string): Promise { const wranglerConfig = getWranglerConfig(projectUuid); + // Wrangler's --outdir writes alongside existing files instead of replacing + // the directory. Stale artifacts (e.g. wasm modules named by an older + // Wrangler version) end up in the bundle and break the Cloudflare upload. + await rm(bigcommerceDistDir, { recursive: true, force: true }); + consola.start('Copying templates...'); await copyFile( From 0e396d761ed202f93d4572d59993bc17d4c61d02 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 29 May 2026 09:33:29 -0500 Subject: [PATCH 66/90] LTRAC-440: ref(cli) - Extract pumpUntilRotation from tailLogs (#3029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the SSE reader's connection lifecycle from its read pump. The outer loop in `tailLogs` now owns opening streams, retrying transient errors, and emitting user-facing reconnect messages. The inner loop becomes a standalone `pumpUntilRotation` that reads bytes from a single connection and returns a `Rotation` value ('ttl' | 'idle-timeout' | 'stream-done') describing why it stopped, instead of using break + side-effecting warnings. No behavior change — every rotation reason and error path maps to the same user-facing output and retry semantics as before, and the existing test suite passes unmodified. Refs LTRAC-440 Co-authored-by: Claude --- packages/catalyst/src/cli/commands/logs.ts | 119 ++++++++++++--------- 1 file changed, 69 insertions(+), 50 deletions(-) diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index d345d6656f..9d92b5c6cc 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -130,9 +130,9 @@ const processLogEvent = (event: string, format: LogFormat) => { type TimeoutRaceResult = { kind: 'value'; value: T } | { kind: 'timeout' }; // Races a promise against a timer so a hung `reader.read()` doesn't block the -// reconnect loop. Without this, the connection TTL check below the read() call -// never runs when the API proxy half-closes the socket — bytes stop arriving -// but no FIN/error surfaces, so the read promise stays pending forever. +// read pump. Without this, the connection TTL check never runs when the API +// proxy half-closes the socket — bytes stop arriving but no FIN/error +// surfaces, so the read promise stays pending forever. const raceWithTimeout = async ( promise: Promise, timeoutMs: number, @@ -187,6 +187,67 @@ const openLogStream = async ( return reader; }; +// Reasons the read pump stops on its own (vs. throwing). The outer reconnect +// loop maps each to a user-facing message — or silence — and opens a fresh +// stream. +type Rotation = 'ttl' | 'idle-timeout' | 'stream-done'; + +const pumpUntilRotation = async ( + reader: ReadableStreamDefaultReader, + format: LogFormat, + connectionTtlMs: number, +): Promise => { + const decoder = new TextDecoder(); + const connectTime = Date.now(); + let buffer = ''; + let receivedData = false; + + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + const remainingTtlMs = connectionTtlMs - (Date.now() - connectTime); + + if (remainingTtlMs <= 0) { + void reader.cancel(); + + return 'ttl'; + } + + // eslint-disable-next-line no-await-in-loop + const readResult = await raceWithTimeout(reader.read(), remainingTtlMs); + + if (readResult.kind === 'timeout') { + void reader.cancel(); + + // No data for the whole window: proxy likely half-closed the socket. + // If data flowed earlier, treat it as a normal TTL boundary instead. + return receivedData ? 'ttl' : 'idle-timeout'; + } + + const { value, done: streamDone } = readResult.value; + + if (value) { + receivedData = true; + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + + // Last element is either empty (complete event) or a partial chunk to carry over + buffer = parts.pop() ?? ''; + + parts + .map((raw) => parseSSEEvent(raw)) + .filter((event): event is string => event !== null) + .forEach((event) => processLogEvent(event, format)); + } + + if (streamDone) { + void reader.cancel(); + + return 'stream-done'; + } + } +}; + export const tailLogs = async ( projectUuid: string, storeHash: string, @@ -204,58 +265,16 @@ export const tailLogs = async ( try { // eslint-disable-next-line no-await-in-loop const reader = await openLogStream(projectUuid, storeHash, accessToken, apiHost); - const decoder = new TextDecoder(); - const connectTime = Date.now(); - let buffer = ''; - let receivedData = false; retries = 0; - // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition - while (true) { - const remainingTtlMs = connectionTtlMs - (Date.now() - connectTime); - - if (remainingTtlMs <= 0) { - void reader.cancel(); - break; - } - - // eslint-disable-next-line no-await-in-loop - const readResult = await raceWithTimeout(reader.read(), remainingTtlMs); - - if (readResult.kind === 'timeout') { - // No bytes for the whole TTL window — proxy likely half-closed the - // socket. Warn the user so they see the reconnect happen. - if (!receivedData) { - consola.warn('Log stream idle, reconnecting...'); - } - - void reader.cancel(); - break; - } - - const { value, done: streamDone } = readResult.value; - - if (value) { - receivedData = true; - buffer += decoder.decode(value, { stream: true }); - - const parts = buffer.split('\n\n'); - - // Last element is either empty (complete event) or a partial chunk to carry over - buffer = parts.pop() ?? ''; - - parts - .map((raw) => parseSSEEvent(raw)) - .filter((event): event is string => event !== null) - .forEach((event) => processLogEvent(event, format)); - } + // eslint-disable-next-line no-await-in-loop + const rotation = await pumpUntilRotation(reader, format, connectionTtlMs); - if (streamDone) { - void reader.cancel(); - break; - } + if (rotation === 'idle-timeout') { + consola.warn('Log stream idle, reconnecting...'); } + // 'ttl' and 'stream-done' are healthy rotations — reconnect silently. } catch (error) { if (error instanceof StreamError && error.fatal) { throw error; From 16513d352fee891c7a7c8cb56595e8e475317f4e Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 1 Jun 2026 10:41:26 -0500 Subject: [PATCH 67/90] fix(cli): strip @vercel/otel hook in Commerce Hosting setup (#3028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * LTRAC-807: fix(cli) - Strip @vercel/otel hook in Commerce Hosting setup `core/instrumentation.ts` registers `@vercel/otel`. OpenNext bundles the storefront as a Node-style worker, so the bundler resolves `@vercel/otel` through its `node` export condition and pulls `@opentelemetry/sdk-node` into the worker chunk. At cold start, workerd evaluates Node-only side effects that `nodejs_compat` doesn't cover and throws — Next.js's instrumentation loader surfaces this as "Failed to prepare server" on every cold start in `catalyst logs tail`. No code in `/core` actually consumes the tracer, so we drop the file (and the `@vercel/otel` dependency) as part of `setupCommerceHosting`, next to the existing `convertProxyToMiddleware` step. The change is scoped to the Commerce Hosting opt-in path; self-hosted / Vercel deploys keep the file. Fixes LTRAC-807 Co-Authored-By: Claude * LTRAC-807: fix(cli) - Run OTel cleanup on already-transformed projects The first patch only removed `core/instrumentation.ts` and `@vercel/otel` as part of `setupCommerceHosting`, which is gated by `!isTransformed` in both `offerCommerceHostingSetup` and `deploy`. Existing Commerce Hosting users — those already transformed — never hit that code path on re-link or re-deploy, so they kept seeing the cold-start error. Extract the cleanup into `cleanupCloudflareIncompatibilities` and call it from the transformed branches of both flows (and from `setupCommerceHosting` itself, for fresh users). Idempotent and silent unless it actually removed something — in which case it logs a one-line `consola.info` so the user sees what changed. Refs LTRAC-807 Co-Authored-By: Claude * LTRAC-807: fix(cli) - Prompt before removing instrumentation hook Vercel users who customized `core/instrumentation.ts` would have their work silently wiped when running `catalyst project link` or `catalyst deploy`, because the cleanup helper unconditionally deleted the file and dropped `@vercel/otel`. Make the cleanup interactive instead: in a TTY, prompt before doing anything; in non-TTY (CI), warn and skip so headless deploys preserve customizations. The dep removal is tied to the file decision — if the file stays, the dep stays (the customized file probably imports it). `setupCommerceHosting` is now async (it awaits the cleanup). All callers (`offerCommerceHostingSetup`, `deploy`, `create`, `runCommerceHostingSetup`) are updated to await it. Refs LTRAC-807 Co-Authored-By: Claude * LTRAC-807: style(cli) - Add blank line after instrumentation prompt The "Removed core/instrumentation.ts ..." (or "Leaving ...") info log landed directly under the user's Yes/No answer with no spacing, making the prompt and the result visually run together. Insert a `consola.log('')` right after the prompt resolves so the answer and the result are separated by a blank line. Refs LTRAC-807 Co-Authored-By: Claude --------- Co-authored-by: Claude --- .changeset/cli-strip-instrumentation-hook.md | 5 + packages/catalyst/src/cli/commands/create.ts | 2 +- packages/catalyst/src/cli/commands/deploy.ts | 9 +- packages/catalyst/src/cli/commands/project.ts | 17 +- .../src/cli/lib/commerce-hosting.spec.ts | 249 +++++++++++++++--- .../catalyst/src/cli/lib/commerce-hosting.ts | 67 ++++- 6 files changed, 311 insertions(+), 38 deletions(-) create mode 100644 .changeset/cli-strip-instrumentation-hook.md diff --git a/.changeset/cli-strip-instrumentation-hook.md b/.changeset/cli-strip-instrumentation-hook.md new file mode 100644 index 0000000000..e5191b0c42 --- /dev/null +++ b/.changeset/cli-strip-instrumentation-hook.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Remove `core/instrumentation.ts` and the `@vercel/otel` dependency during Commerce Hosting setup. The hook isn't compatible with the OpenNext + Cloudflare Workers bundling path and caused a "Failed to prepare server" error on every cold start in `catalyst logs tail`. Self-hosted (non-Commerce Hosting) deployments are unaffected. diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts index cd015a2ad4..42dc62e91b 100644 --- a/packages/catalyst/src/cli/commands/create.ts +++ b/packages/catalyst/src/cli/commands/create.ts @@ -478,7 +478,7 @@ Examples: setupCoreProject(projectDir); if (useCommerceHosting && commerceHostingProject && storeHash && accessToken) { - setupCommerceHosting({ + await setupCommerceHosting({ projectDir, projectUuid: commerceHostingProject.uuid, storeHash, diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index cf9e707c8b..b7dab19f42 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; import { + cleanupCloudflareIncompatibilities, NoLinkedProjectError, selectOrCreateInfrastructureProject, setupCommerceHosting, @@ -463,10 +464,16 @@ Example: const projectDir = dirname(process.cwd()); - setupCommerceHosting({ projectDir, projectUuid, storeHash, accessToken }); + await setupCommerceHosting({ projectDir, projectUuid, storeHash, accessToken }); consola.success('Commerce Hosting setup complete.'); await installDependencies(projectDir); + } else { + // Existing Commerce Hosting users may carry artifacts incompatible with + // the Cloudflare worker bundle from earlier Catalyst versions + // (`core/instrumentation.ts`, `@vercel/otel`). Sweep them on every deploy + // so the fix lands without forcing a re-link. + await cleanupCloudflareIncompatibilities(dirname(process.cwd())); } if (options.prebuilt) { diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 0054456287..7942d88fe7 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -3,6 +3,7 @@ import { colorize } from 'consola/utils'; import { dirname } from 'node:path'; import { + cleanupCloudflareIncompatibilities, NoLinkedProjectError, selectOrCreateInfrastructureProject, setupCommerceHosting, @@ -29,7 +30,17 @@ async function offerCommerceHostingSetup( projectUuid: string, credentials?: { storeHash: string; accessToken: string }, ) { - if (getProjectState().isTransformed) return; + const projectDir = dirname(process.cwd()); + + // Already-transformed projects skip the setup prompt below — but they may + // still carry artifacts incompatible with the Cloudflare worker bundle + // (e.g. `core/instrumentation.ts`, `@vercel/otel`). Run cleanup so existing + // Commerce Hosting users pick up these fixes on re-link without prompting. + if (getProjectState().isTransformed) { + await cleanupCloudflareIncompatibilities(projectDir); + + return; + } const shouldSetup = await consola.prompt( 'Your project has been linked, but is not fully set up for Commerce Hosting deployments yet. Would you like to run the setup now?', @@ -38,9 +49,7 @@ async function offerCommerceHostingSetup( if (!shouldSetup) return; - const projectDir = dirname(process.cwd()); - - setupCommerceHosting({ + await setupCommerceHosting({ projectDir, projectUuid, storeHash: credentials?.storeHash, diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts index 09cca32154..0a0c85324d 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts @@ -15,10 +15,12 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } fr import { z } from 'zod'; import { + cleanupCloudflareIncompatibilities, promptAndCreateCommerceHostingProject, promptForCommerceHostingProject, setupCommerceHosting, } from './commerce-hosting'; +import { consola } from './logger'; import * as projectLib from './project'; import { InfrastructureProjectValidationError } from './project'; @@ -575,13 +577,20 @@ describe('setupCommerceHosting', () => { }); let projectDir: string; + let restoreTty: () => void; + let consolaPromptSpy: MockInstance; beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'catalyst-create-test-')); + restoreTty = withInteractiveTty(); + consolaPromptSpy = vi.spyOn(consola, 'prompt'); + consolaPromptSpy.mockResolvedValue(true); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); + restoreTty(); + consolaPromptSpy.mockRestore(); }); function writeCorePackageJson(contents: unknown) { @@ -598,6 +607,13 @@ describe('setupCommerceHosting', () => { writeFileSync(join(coreDir, 'proxy.ts'), contents); } + function writeCoreInstrumentationFile(contents: string) { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'instrumentation.ts'), contents); + } + function readCorePackageJson() { return packageJsonSchema.parse( JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), @@ -610,13 +626,13 @@ describe('setupCommerceHosting', () => { ); } - it('adds the OpenNext Cloudflare dep while preserving existing dependencies', () => { + it('adds the OpenNext Cloudflare dep while preserving existing dependencies', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '^15.0.0', react: '^18.0.0' }, }); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); const pkg = readCorePackageJson(); @@ -624,7 +640,46 @@ describe('setupCommerceHosting', () => { expect(pkg.dependencies).toHaveProperty('@opennextjs/cloudflare'); }); - it('does not modify package.json scripts (handled by setupCoreProject)', () => { + it('drops @vercel/otel from dependencies when the user accepts the instrumentation removal prompt', async () => { + writeCorePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { + next: '^15.0.0', + react: '^18.0.0', + '@vercel/otel': '^2.1.0', + '@opentelemetry/api': '^1.9.0', + }, + }); + writeCoreInstrumentationFile('export function register() {}\n'); + + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const pkg = readCorePackageJson(); + + expect(pkg.dependencies).not.toHaveProperty('@vercel/otel'); + expect(pkg.dependencies).toMatchObject({ + next: '^15.0.0', + react: '^18.0.0', + '@opentelemetry/api': '^1.9.0', + }); + expect(pkg.dependencies).toHaveProperty('@opennextjs/cloudflare'); + }); + + it('keeps @vercel/otel in place when no instrumentation.ts file exists', async () => { + writeCorePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' }, + }); + + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const pkg = readCorePackageJson(); + + expect(pkg.dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); + expect(consolaPromptSpy).not.toHaveBeenCalled(); + }); + + it('does not modify package.json scripts (handled by setupCoreProject)', async () => { writeCorePackageJson({ scripts: { dev: 'npm run generate && next dev', @@ -633,7 +688,7 @@ describe('setupCommerceHosting', () => { }, }); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); expect(readCorePackageJson().scripts).toEqual({ dev: 'npm run generate && next dev', @@ -642,7 +697,7 @@ describe('setupCommerceHosting', () => { }); }); - it('preserves unrelated top-level package.json fields', () => { + it('preserves unrelated top-level package.json fields', async () => { writeCorePackageJson({ name: '@bigcommerce/catalyst-core', description: 'test description', @@ -652,7 +707,7 @@ describe('setupCommerceHosting', () => { devDependencies: { jest: '^29.0.0' }, }); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); const pkg = readCorePackageJson(); @@ -663,18 +718,18 @@ describe('setupCommerceHosting', () => { expect(pkg.devDependencies).toEqual({ jest: '^29.0.0' }); }); - it('writes core/.bigcommerce/project.json with the correct shape', () => { + it('writes core/.bigcommerce/project.json with the correct shape', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); - setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); + await setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); expect(readProjectJson()).toEqual({ projectUuid: 'uuid-xyz', framework: 'catalyst' }); }); - it('includes storeHash and accessToken in project.json when provided', () => { + it('includes storeHash and accessToken in project.json when provided', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); - setupCommerceHosting({ + await setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz', storeHash: 'abc123', @@ -689,10 +744,10 @@ describe('setupCommerceHosting', () => { }); }); - it('omits storeHash and accessToken when not provided', () => { + it('omits storeHash and accessToken when not provided', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); - setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); + await setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); const projectJson = readProjectJson(); @@ -700,10 +755,10 @@ describe('setupCommerceHosting', () => { expect(projectJson.accessToken).toBeUndefined(); }); - it('includes only the credentials that are provided', () => { + it('includes only the credentials that are provided', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); - setupCommerceHosting({ + await setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz', storeHash: 'abc123', @@ -715,21 +770,21 @@ describe('setupCommerceHosting', () => { expect(projectJson.accessToken).toBeUndefined(); }); - it('throws when core/package.json is missing', () => { - expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).toThrow(); + it('throws when core/package.json is missing', async () => { + await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).rejects.toThrow(); }); - it('throws when core/package.json has an invalid shape', () => { + it('throws when core/package.json has an invalid shape', async () => { writeCorePackageJson({ dependencies: { next: 42 } }); - expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).toThrow(); + await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).rejects.toThrow(); }); describe('core/.env.local symlink', () => { - it('creates a symlink at core/.env.local pointing to ../.env.local', () => { + it('creates a symlink at core/.env.local pointing to ../.env.local', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); const coreEnvPath = join(projectDir, 'core', '.env.local'); @@ -737,11 +792,11 @@ describe('setupCommerceHosting', () => { expect(readlinkSync(coreEnvPath)).toBe(join('..', '.env.local')); }); - it('keeps both files in sync via the symlink target', () => { + it('keeps both files in sync via the symlink target', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); writeFileSync(join(projectDir, '.env.local'), 'FOO=bar\n'); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); expect(readFileSync(join(projectDir, 'core', '.env.local'), 'utf-8')).toBe('FOO=bar\n'); @@ -750,12 +805,12 @@ describe('setupCommerceHosting', () => { expect(readFileSync(join(projectDir, '.env.local'), 'utf-8')).toBe('FOO=baz\n'); }); - it('does not clobber an existing core/.env.local file', () => { + it('does not clobber an existing core/.env.local file', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); mkdirSync(join(projectDir, 'core'), { recursive: true }); writeFileSync(join(projectDir, 'core', '.env.local'), 'PRESERVE=me\n'); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); const coreEnvPath = join(projectDir, 'core', '.env.local'); @@ -776,11 +831,11 @@ describe('setupCommerceHosting', () => { '', ].join('\n'); - it('renames proxy.ts to middleware.ts, renames the export, and injects the edge runtime', () => { + it('renames proxy.ts to middleware.ts, renames the export, and injects the edge runtime', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); writeCoreProxyFile(proxyFixture); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); const middlewarePath = join(projectDir, 'core', 'middleware.ts'); const proxyPath = join(projectDir, 'core', 'proxy.ts'); @@ -795,11 +850,11 @@ describe('setupCommerceHosting', () => { expect(middleware).toContain("runtime: 'experimental-edge'"); }); - it('preserves the rest of the file contents', () => { + it('preserves the rest of the file contents', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); writeCoreProxyFile(proxyFixture); - setupCommerceHosting({ projectDir, projectUuid: 'u' }); + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); const middleware = readFileSync(join(projectDir, 'core', 'middleware.ts'), 'utf-8'); @@ -807,11 +862,145 @@ describe('setupCommerceHosting', () => { expect(middleware).toContain("matcher: ['/((?!api).*)']"); }); - it('is a no-op when proxy.ts does not exist', () => { + it('is a no-op when proxy.ts does not exist', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); - expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).not.toThrow(); + await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).resolves.not.toThrow(); expect(existsSync(join(projectDir, 'core', 'middleware.ts'))).toBe(false); }); }); + + describe('instrumentation.ts removal', () => { + it('deletes core/instrumentation.ts when the user accepts the prompt', async () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + writeCoreInstrumentationFile('export function register() {}\n'); + + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(false); + }); + + it('preserves core/instrumentation.ts when the user declines the prompt', async () => { + consolaPromptSpy.mockResolvedValueOnce(false); + + writeCorePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { '@vercel/otel': '^2.1.0' }, + }); + writeCoreInstrumentationFile('export function register() {}\n'); + + await setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); + expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); + }); + + it('does not prompt when instrumentation.ts does not exist', async () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).resolves.not.toThrow(); + expect(consolaPromptSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe('cleanupCloudflareIncompatibilities', () => { + const packageJsonSchema = z.record(z.string(), z.unknown()); + + let projectDir: string; + let restoreTty: () => void; + let consolaPromptSpy: MockInstance; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'catalyst-cleanup-test-')); + restoreTty = withInteractiveTty(); + consolaPromptSpy = vi.spyOn(consola, 'prompt'); + consolaPromptSpy.mockResolvedValue(true); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + restoreTty(); + consolaPromptSpy.mockRestore(); + }); + + function writeCorePackageJson(contents: unknown) { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); + } + + function writeCoreInstrumentationFile(contents: string) { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'instrumentation.ts'), contents); + } + + function readCorePackageJson() { + return packageJsonSchema.parse( + JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), + ); + } + + it('removes core/instrumentation.ts and drops @vercel/otel when the user accepts (TTY)', async () => { + writeCorePackageJson({ + dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' }, + }); + writeCoreInstrumentationFile('export function register() {}\n'); + + await cleanupCloudflareIncompatibilities(projectDir); + + expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(false); + expect(readCorePackageJson().dependencies).not.toHaveProperty('@vercel/otel'); + expect(readCorePackageJson().dependencies).toMatchObject({ next: '^15.0.0' }); + }); + + it('leaves the file and the dep alone when the user declines (TTY)', async () => { + consolaPromptSpy.mockResolvedValueOnce(false); + + writeCorePackageJson({ + dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' }, + }); + writeCoreInstrumentationFile('// customized\nexport function register() {}\n'); + + await cleanupCloudflareIncompatibilities(projectDir); + + expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); + expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); + }); + + it('skips the cleanup and never prompts in non-interactive contexts', async () => { + restoreTty(); + restoreTty = withNonInteractiveTty(); + + writeCorePackageJson({ + dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' }, + }); + writeCoreInstrumentationFile('// customized\nexport function register() {}\n'); + + await cleanupCloudflareIncompatibilities(projectDir); + + expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); + expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); + }); + + it('is a silent no-op when instrumentation.ts does not exist', async () => { + writeCorePackageJson({ dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' } }); + + await cleanupCloudflareIncompatibilities(projectDir); + + expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); + }); + + it('removes the instrumentation file even when no package.json is present', async () => { + writeCoreInstrumentationFile('export function register() {}\n'); + + await cleanupCloudflareIncompatibilities(projectDir); + + expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(false); + }); }); diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.ts b/packages/catalyst/src/cli/lib/commerce-hosting.ts index afc44d2bc7..8dac968c37 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.ts @@ -64,7 +64,68 @@ const convertProxyToMiddleware = (projectDir: string) => { unlinkSync(proxyPath); }; -export const setupCommerceHosting = ({ +// The default `core/instrumentation.ts` registers `@vercel/otel`, whose node +// build OpenNext bundles into the worker chunk; workerd then throws on cold +// start with "Failed to prepare server". A Vercel-deploying user may have +// customized the hook though, so we prompt before removing it (and only drop +// `@vercel/otel` if the user agrees — their customization probably imports it). +// In non-TTY contexts (CI deploys, scripts), skip the cleanup with a warning so +// no customization is silently wiped. Exported because callers run this on +// already-transformed projects too, where `setupCommerceHosting` would +// short-circuit. +export const cleanupCloudflareIncompatibilities = async (projectDir: string) => { + const instrumentationPath = join(projectDir, 'core', 'instrumentation.ts'); + + if (!existsSync(instrumentationPath)) return; + + if (!process.stdin.isTTY) { + consola.warn( + 'core/instrumentation.ts is present and may be incompatible with the Cloudflare Workers ' + + 'bundle (the default @vercel/otel scaffolding throws at cold start under workerd). ' + + 'Skipping automatic cleanup in non-interactive mode — re-run interactively to remove it, ' + + 'or delete/gate it manually.', + ); + + return; + } + + const shouldRemove = await consola.prompt( + 'Catalyst found core/instrumentation.ts, which is incompatible with the Cloudflare Workers ' + + 'bundle when it uses @vercel/otel (causes "Failed to prepare server" at cold start). ' + + 'Remove it and drop @vercel/otel from core/package.json?', + { type: 'confirm', initial: true }, + ); + + consola.log(''); + + if (!shouldRemove) { + consola.info( + 'Leaving core/instrumentation.ts in place. The Cloudflare worker will continue to log ' + + '"Failed to prepare server" at cold start until this is resolved manually.', + ); + + return; + } + + unlinkSync(instrumentationPath); + consola.info('Removed core/instrumentation.ts (incompatible with Cloudflare Workers).'); + + const corePackageJsonPath = join(projectDir, 'core', 'package.json'); + + if (existsSync(corePackageJsonPath)) { + const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); + + if (pkg.dependencies && '@vercel/otel' in pkg.dependencies) { + const { '@vercel/otel': _removedVercelOtel, ...preservedDeps } = pkg.dependencies; + + pkg.dependencies = preservedDeps; + writeJson(corePackageJsonPath, sortPackageJsonFields(pkg)); + consola.info('Dropped @vercel/otel from core/package.json.'); + } + } +}; + +export const setupCommerceHosting = async ({ projectDir, projectUuid, storeHash, @@ -75,6 +136,8 @@ export const setupCommerceHosting = ({ storeHash?: string; accessToken?: string; }) => { + await cleanupCloudflareIncompatibilities(projectDir); + const corePackageJsonPath = join(projectDir, 'core', 'package.json'); const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); @@ -417,7 +480,7 @@ export async function runCommerceHostingSetup({ useExistingOnCollision, ); - setupCommerceHosting({ + await setupCommerceHosting({ projectDir, projectUuid: project.uuid, storeHash: api.storeHash, From 41b22cecdc4f1ecf766397abafa924aea14fca68 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Tue, 2 Jun 2026 19:31:36 -0500 Subject: [PATCH 68/90] LTRAC-808: fix(cli) - Suppress OpenNext IMAGES binding warning in logs tail (#3038) * LTRAC-808: fix(cli) - Suppress OpenNext IMAGES binding warning in logs tail OpenNext's Cloudflare image handler logs `env.IMAGES binding is not defined` on every `/_next/image` request when no IMAGES binding is configured, then falls back to serving the original bytes. Native Hosting intentionally runs without that binding, so the warning is expected noise that flooded testers watching `catalyst logs tail`. Filter the warning out of the human-readable log formats and drop events that contain nothing but suppressed noise. Raw `--format json` output is left untouched so piped consumers still see the full stream. Fixes LTRAC-808 Co-Authored-By: Claude * LTRAC-808: chore(cli) - Drop changeset (not needed on alpha branch) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../catalyst/src/cli/commands/logs.spec.ts | 69 +++++++++++++++++++ packages/catalyst/src/cli/commands/logs.ts | 27 +++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts index 4b4bdef670..7748a5b565 100644 --- a/packages/catalyst/src/cli/commands/logs.spec.ts +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -290,6 +290,75 @@ describe('log event processing', () => { }); }); +describe('suppressed log noise', () => { + const imagesWarning = 'env.IMAGES binding is not defined'; + + test('drops the OpenNext IMAGES binding warning from default format', async () => { + const event = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); + }); + + test('keeps other log entries when only the IMAGES warning is suppressed', async () => { + const event = { + ...validLogEvent, + logs: [ + { ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }, + { ...validLogEvent.logs[0], messages: ['hello world'] }, + ], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); + + test('emits nothing for an event that is only suppressed noise', async () => { + const event = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).not.toHaveBeenCalled(); + expect(consola.error).not.toHaveBeenCalled(); + }); + + test('still emits exceptions even when all log entries are suppressed', async () => { + const event = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], + exceptions: [{ message: 'something broke' }], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('EXCEPTION'), + expect.objectContaining({ message: 'something broke' }), + ); + }); + + test('does not filter the IMAGES warning out of raw json output', async () => { + const event = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], + }; + + await callTailLogs('json', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(stdoutWriteMock).toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); + }); +}); + describe('error handling', () => { test('silently ignores heartbeat events', async () => { await callTailLogs('default', [`data: \n\ndata: ${JSON.stringify(validLogEvent)}\n\n`]); diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index 9d92b5c6cc..5e7f33d5a4 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -63,6 +63,17 @@ class StreamError extends Error { const formatMessages = (messages: unknown[]) => messages.map((m) => (typeof m === 'string' ? m : JSON.stringify(m))).join(' '); +// Known framework noise to drop from the human-readable log formats. OpenNext's +// Cloudflare image handler logs `env.IMAGES binding is not defined` on every +// `/_next/image` request when no IMAGES binding is configured, then falls back +// to serving the original bytes. Native Hosting intentionally runs without that +// binding (see LTRAC-808), so the warning is expected — suppress it so testers +// aren't flooded with a benign message on every image request. +const SUPPRESSED_LOG_PATTERNS = [/env\.IMAGES binding is not defined/]; + +const isSuppressedMessage = (message: string) => + SUPPRESSED_LOG_PATTERNS.some((pattern) => pattern.test(message)); + const formatLogEvent = ( event: z.infer, format: 'default' | 'short' | 'request', @@ -117,10 +128,22 @@ const processLogEvent = (event: string, format: LogFormat) => { const parsed: unknown = JSON.parse(event); const logEvent = LogEventSchema.parse(parsed); + const visibleLogs = logEvent.logs.filter( + (entry) => !isSuppressedMessage(formatMessages(entry.messages)), + ); + + // Skip events that contained nothing but suppressed noise so we don't emit + // an empty request envelope. + if (visibleLogs.length === 0 && logEvent.exceptions.length === 0) { + return; + } + + const filteredEvent = { ...logEvent, logs: visibleLogs }; + if (format === 'pretty') { - consola.log(JSON.stringify(logEvent, null, 2)); + consola.log(JSON.stringify(filteredEvent, null, 2)); } else { - formatLogEvent(logEvent, format); + formatLogEvent(filteredEvent, format); } } catch { consola.warn(`Failed to parse log event: ${event}`); From 11449ad062c8834f2da9ae49154e172882d83b0b Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 3 Jun 2026 11:02:52 -0500 Subject: [PATCH 69/90] LTRAC-442: feat(cli) - Persist deployment env vars via `catalyst env` (#3039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing `--secret KEY=VALUE` for every env var on every `catalyst deploy` is cumbersome and error-prone, and got worse after implicit secret detection was removed (LTRAC-640): nothing reaches a deployment unless every secret is re-typed. Add a `catalyst env` command group (`add`, `remove`, `list`) that stores deployment secrets in `.bigcommerce/project.json` (gitignored, alongside the already-persisted access token). `catalyst deploy` now sends these automatically, merging them with any inline `--secret` flags — inline flags win on conflict so a stored value can still be overridden per-run. Stored vars are deploy-only (not injected into the local build) and are always sent as `secret`. Values are masked in all command output. A shared `parseEnvAssignment` splits on the first `=`, so values containing `=` (e.g. base64/tokens) survive intact — a fix over deploy's prior `split('=')`. Refs LTRAC-442 Co-authored-by: Claude --- .../catalyst/src/cli/commands/deploy.spec.ts | 89 ++++++++++++- packages/catalyst/src/cli/commands/deploy.ts | 30 +++-- .../catalyst/src/cli/commands/env.spec.ts | 115 +++++++++++++++++ packages/catalyst/src/cli/commands/env.ts | 118 ++++++++++++++++++ .../catalyst/src/cli/lib/env-config.spec.ts | 70 +++++++++++ packages/catalyst/src/cli/lib/env-config.ts | 66 ++++++++++ .../src/cli/lib/project-config.spec.ts | 10 ++ .../catalyst/src/cli/lib/project-config.ts | 10 ++ packages/catalyst/src/cli/program.ts | 2 + 9 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 packages/catalyst/src/cli/commands/env.spec.ts create mode 100644 packages/catalyst/src/cli/commands/env.ts create mode 100644 packages/catalyst/src/cli/lib/env-config.spec.ts create mode 100644 packages/catalyst/src/cli/lib/env-config.ts diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index af1f13cb03..431fe9c30b 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -550,10 +550,97 @@ test('reads from env options', () => { ]); expect(() => parseEnvironmentVariables(['foo_bar'])).toThrow( - 'Invalid secret format: foo_bar. Expected format: KEY=VALUE', + 'Invalid env var format: foo_bar. Expected format: KEY=VALUE', ); }); +test('splits secrets on the first = so values containing = survive', () => { + const envVariables = parseEnvironmentVariables(['TOKEN=abc=def==']); + + expect(envVariables).toEqual([{ type: 'secret', key: 'TOKEN', value: 'abc=def==' }]); +}); + +describe('persisted env vars', () => { + interface DeploymentBody { + environment_variables?: Array<{ type: string; key: string; value: string }>; + } + + test('sends stored env vars and lets inline --secret override the same key', async () => { + const config = getProjectConfig(); + + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + config.set('env', { PERSISTED_ONLY: 'keep', SHARED: 'stored' }); + + let body: DeploymentBody | undefined; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/deployments', + async ({ request }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + body = (await request.json()) as DeploymentBody; + + return HttpResponse.json({ data: { deployment_uuid: deploymentUuid } }); + }, + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--api-host', + apiHost, + '--prebuilt', + '--secret', + 'SHARED=override', + '--secret', + 'FLAG_ONLY=flag', + ]); + + expect(body?.environment_variables).toEqual( + expect.arrayContaining([ + { type: 'secret', key: 'PERSISTED_ONLY', value: 'keep' }, + { type: 'secret', key: 'SHARED', value: 'override' }, + { type: 'secret', key: 'FLAG_ONLY', value: 'flag' }, + ]), + ); + // The shared key is sent once, with the inline flag value winning. + expect(body?.environment_variables?.filter((e) => e.key === 'SHARED')).toHaveLength(1); + + config.delete('env'); + }); + + test('omits environment_variables when nothing is stored or passed', async () => { + const config = getProjectConfig(); + + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + config.delete('env'); + + let body: DeploymentBody | undefined; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/deployments', + async ({ request }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + body = (await request.json()) as DeploymentBody; + + return HttpResponse.json({ data: { deployment_uuid: deploymentUuid } }); + }, + ), + ); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--api-host', apiHost, '--prebuilt']); + + expect(body?.environment_variables).toBeUndefined(); + }); +}); + describe('--prebuilt flag', () => { test('skips build step when --prebuilt is passed', async () => { await program.parseAsync([ diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index b7dab19f42..dc6d785706 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -14,6 +14,12 @@ import { setupCommerceHosting, } from '../lib/commerce-hosting'; import { getDeploymentErrorMessage } from '../lib/deployment-errors'; +import { + getStoredEnv, + mergeDeploymentSecrets, + parseEnvAssignment, + toDeploymentSecrets, +} from '../lib/env-config'; import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; @@ -179,16 +185,12 @@ export const uploadBundleZip = async (uploadUrl: string) => { export const parseEnvironmentVariables = (secretOption?: string[]) => { return secretOption?.map((envVar) => { - const [key, value] = envVar.split('='); - - if (!key || !value) { - throw new Error(`Invalid secret format: ${envVar}. Expected format: KEY=VALUE`); - } + const { key, value } = parseEnvAssignment(envVar); return { type: 'secret' as const, - key: key.trim(), - value: value.trim(), + key, + value, }; }); }; @@ -359,6 +361,9 @@ export const deploy = new Command('deploy') .addHelpText( 'after', ` +Environment variables saved with \`catalyst env add\` are sent automatically on every deploy. +Use \`--secret\` to set or override a variable for a single run. + Example: $ catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN=`, ) @@ -373,7 +378,7 @@ Example: .addOption( new Option( '--secret ', - 'Secret to set for the deployment (repeatable). Format: --secret KEY=VALUE', + 'Secret to set for this deployment (repeatable). Overrides a stored value for the same key. Format: --secret KEY=VALUE', ).argParser((value: string, previous: string[] = []) => { return previous.concat([value]); }), @@ -516,7 +521,14 @@ Example: await uploadBundleZip(uploadSignature.upload_url); - const environmentVariables = parseEnvironmentVariables(options.secret); + // Merge persisted env vars (`catalyst env add`) with any inline `--secret` + // flags. Inline flags win on conflict, letting users override a stored + // value for a single run. Send `undefined` when there's nothing to set so + // we preserve the prior payload shape. + const flagSecrets = parseEnvironmentVariables(options.secret) ?? []; + const persistedSecrets = toDeploymentSecrets(getStoredEnv(config)); + const mergedSecrets = mergeDeploymentSecrets(persistedSecrets, flagSecrets); + const environmentVariables = mergedSecrets.length > 0 ? mergedSecrets : undefined; const { deployment_uuid: deploymentUuid } = await createDeployment( projectUuid, diff --git a/packages/catalyst/src/cli/commands/env.spec.ts b/packages/catalyst/src/cli/commands/env.spec.ts new file mode 100644 index 0000000000..75a1c9433f --- /dev/null +++ b/packages/catalyst/src/cli/commands/env.spec.ts @@ -0,0 +1,115 @@ +import { Command } from 'commander'; +import { afterAll, afterEach, beforeAll, beforeEach, expect, MockInstance, test, vi } from 'vitest'; + +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig } from '../lib/project-config'; +import { program } from '../program'; + +import { env } from './env'; + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; + +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(); +}); + +beforeEach(() => { + process.chdir(tmpDir); +}); + +afterEach(() => { + getProjectConfig().delete('env'); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await cleanup(); +}); + +test('properly configured Command instance', () => { + expect(env).toBeInstanceOf(Command); + expect(env.name()).toBe('env'); + expect(env.commands.map((c) => c.name()).sort()).toEqual(['add', 'list', 'remove']); +}); + +test('add stores variables in .bigcommerce/project.json', async () => { + await program.parseAsync(['node', 'catalyst', 'env', 'add', 'FOO=bar', 'BAZ=qux']); + + expect(getProjectConfig().get('env')).toEqual({ FOO: 'bar', BAZ: 'qux' }); + expect(exitMock).toHaveBeenCalledWith(0); +}); + +test('add merges with and overwrites existing variables', async () => { + const config = getProjectConfig(); + + config.set('env', { FOO: 'old', KEEP: 'me' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'add', 'FOO=new']); + + expect(config.get('env')).toEqual({ FOO: 'new', KEEP: 'me' }); +}); + +test('add never prints the raw value', async () => { + await program.parseAsync(['node', 'catalyst', 'env', 'add', 'SECRET=supersecret']); + + const logged = vi.mocked(consola.log).mock.calls.flat().join('\n'); + + expect(logged).toContain('SECRET='); + expect(logged).not.toContain('supersecret'); +}); + +test('add rejects an invalid assignment without partially writing', async () => { + const config = getProjectConfig(); + + config.set('env', { EXISTING: 'value' }); + + await expect( + program.parseAsync(['node', 'catalyst', 'env', 'add', 'GOOD=ok', 'bad-entry']), + ).rejects.toThrow('Invalid env var format: bad-entry'); + + // GOOD must not have been persisted since one entry was invalid. + expect(config.get('env')).toEqual({ EXISTING: 'value' }); +}); + +test('remove deletes stored variables', async () => { + const config = getProjectConfig(); + + config.set('env', { FOO: 'bar', BAZ: 'qux' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'remove', 'FOO']); + + expect(config.get('env')).toEqual({ BAZ: 'qux' }); + expect(exitMock).toHaveBeenCalledWith(0); +}); + +test('remove warns for keys that are not stored', async () => { + getProjectConfig().set('env', { FOO: 'bar' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'remove', 'MISSING']); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('MISSING')); +}); + +test('list shows stored keys with masked values', async () => { + getProjectConfig().set('env', { FOO: 'bar' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'list']); + + const logged = vi.mocked(consola.log).mock.calls.flat().join('\n'); + + expect(logged).toContain('FOO='); + expect(logged).not.toContain('bar'); +}); + +test('list reports when nothing is stored', async () => { + await program.parseAsync(['node', 'catalyst', 'env', 'list']); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('No environment variables')); +}); diff --git a/packages/catalyst/src/cli/commands/env.ts b/packages/catalyst/src/cli/commands/env.ts new file mode 100644 index 0000000000..e17c19eea2 --- /dev/null +++ b/packages/catalyst/src/cli/commands/env.ts @@ -0,0 +1,118 @@ +import { Command } from 'commander'; + +import { getStoredEnv, parseEnvAssignment } from '../lib/env-config'; +import { consola } from '../lib/logger'; +import { getProjectConfig } from '../lib/project-config'; + +// Values are secrets, so we never print them back. A fixed-width mask avoids +// leaking the length of the stored value. +const MASK = '••••••'; + +const add = new Command('add') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Add or update one or more deployment environment variables. Stored in .bigcommerce/project.json and sent as secrets on every `catalyst deploy`.', + ) + .argument('', 'One or more environment variables in KEY=VALUE format.') + .addHelpText( + 'after', + ` +Example: + $ catalyst env add BIGCOMMERCE_STORE_HASH=abc123 BIGCOMMERCE_STOREFRONT_TOKEN=ey...`, + ) + .action((vars: string[]) => { + const config = getProjectConfig(); + const stored = { ...getStoredEnv(config) }; + + // Parse everything before writing so a single invalid entry doesn't leave a + // partial update behind. + const parsed = vars.map((entry) => parseEnvAssignment(entry)); + + parsed.forEach(({ key, value }) => { + stored[key] = value; + }); + + config.set('env', stored); + + const keys = parsed.map(({ key }) => key); + + consola.success( + `Saved ${keys.length} environment variable${keys.length === 1 ? '' : 's'} to .bigcommerce/project.json:`, + ); + keys.forEach((key) => consola.log(` ${key}=${MASK}`)); + + process.exit(0); + }); + +const remove = new Command('remove') + .configureHelp({ showGlobalOptions: true }) + .description('Remove one or more stored deployment environment variables.') + .argument('', 'One or more environment variable names to remove.') + .addHelpText( + 'after', + ` +Example: + $ catalyst env remove BIGCOMMERCE_STORE_HASH BIGCOMMERCE_STOREFRONT_TOKEN`, + ) + .action((keys: string[]) => { + const config = getProjectConfig(); + const stored = getStoredEnv(config); + const toRemove = new Set(); + + keys.forEach((key) => { + if (key in stored) { + toRemove.add(key); + } else { + consola.warn(`No stored environment variable named "${key}". Skipping.`); + } + }); + + const next = Object.fromEntries(Object.entries(stored).filter(([key]) => !toRemove.has(key))); + + config.set('env', next); + + const removed = Array.from(toRemove); + + if (removed.length > 0) { + consola.success( + `Removed ${removed.length} environment variable${removed.length === 1 ? '' : 's'}: ${removed.join(', ')}.`, + ); + } + + process.exit(0); + }); + +const list = new Command('list') + .configureHelp({ showGlobalOptions: true }) + .description('List stored deployment environment variables (values are masked).') + .addHelpText( + 'after', + ` +Example: + $ catalyst env list`, + ) + .action(() => { + const config = getProjectConfig(); + const stored = getStoredEnv(config); + const keys = Object.keys(stored).sort(); + + if (keys.length === 0) { + consola.info('No environment variables stored. Add one with `catalyst env add KEY=VALUE`.'); + process.exit(0); + + return; + } + + keys.forEach((key) => consola.log(`${key}=${MASK}`)); + + process.exit(0); + }); + +export const env = new Command('env') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Manage persistent deployment environment variables. These are sent as secrets on every `catalyst deploy`, so you no longer need to pass `--secret` each time.', + ) + .addCommand(add) + .addCommand(remove) + .addCommand(list); diff --git a/packages/catalyst/src/cli/lib/env-config.spec.ts b/packages/catalyst/src/cli/lib/env-config.spec.ts new file mode 100644 index 0000000000..a41680e956 --- /dev/null +++ b/packages/catalyst/src/cli/lib/env-config.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest'; + +import { mergeDeploymentSecrets, parseEnvAssignment, toDeploymentSecrets } from './env-config'; + +describe('parseEnvAssignment', () => { + test('parses a basic KEY=VALUE assignment', () => { + expect(parseEnvAssignment('FOO=bar')).toEqual({ key: 'FOO', value: 'bar' }); + }); + + test('splits on the first = so values containing = survive', () => { + expect(parseEnvAssignment('TOKEN=abc=def==')).toEqual({ key: 'TOKEN', value: 'abc=def==' }); + }); + + test('trims surrounding whitespace', () => { + expect(parseEnvAssignment(' FOO = bar ')).toEqual({ key: 'FOO', value: 'bar' }); + }); + + test('throws when there is no =', () => { + expect(() => parseEnvAssignment('FOO')).toThrow( + 'Invalid env var format: FOO. Expected format: KEY=VALUE', + ); + }); + + test('throws when the value is empty', () => { + expect(() => parseEnvAssignment('FOO=')).toThrow( + 'Invalid env var format: FOO=. Expected format: KEY=VALUE', + ); + }); + + test('throws when the key is not a valid env var name', () => { + expect(() => parseEnvAssignment('1FOO=bar')).toThrow('Invalid env var name: 1FOO'); + expect(() => parseEnvAssignment('FOO-BAR=baz')).toThrow('Invalid env var name: FOO-BAR'); + }); +}); + +describe('toDeploymentSecrets', () => { + test('maps an env map into secret payload entries', () => { + expect(toDeploymentSecrets({ FOO: 'bar', BAZ: 'qux' })).toEqual([ + { type: 'secret', key: 'FOO', value: 'bar' }, + { type: 'secret', key: 'BAZ', value: 'qux' }, + ]); + }); + + test('returns an empty array for an empty map', () => { + expect(toDeploymentSecrets({})).toEqual([]); + }); +}); + +describe('mergeDeploymentSecrets', () => { + test('merges both sets with flag secrets overriding persisted on conflict', () => { + const persisted = toDeploymentSecrets({ PERSISTED_ONLY: 'keep', SHARED: 'stored' }); + const flagSecrets = toDeploymentSecrets({ SHARED: 'override', FLAG_ONLY: 'flag' }); + + const merged = mergeDeploymentSecrets(persisted, flagSecrets); + + expect(merged).toEqual([ + { type: 'secret', key: 'PERSISTED_ONLY', value: 'keep' }, + { type: 'secret', key: 'SHARED', value: 'override' }, + { type: 'secret', key: 'FLAG_ONLY', value: 'flag' }, + ]); + }); + + test('returns persisted secrets when there are no flag secrets', () => { + const persisted = toDeploymentSecrets({ FOO: 'bar' }); + + expect(mergeDeploymentSecrets(persisted, [])).toEqual([ + { type: 'secret', key: 'FOO', value: 'bar' }, + ]); + }); +}); diff --git a/packages/catalyst/src/cli/lib/env-config.ts b/packages/catalyst/src/cli/lib/env-config.ts new file mode 100644 index 0000000000..62f5cd53bd --- /dev/null +++ b/packages/catalyst/src/cli/lib/env-config.ts @@ -0,0 +1,66 @@ +import type Conf from 'conf'; + +import { ProjectConfigSchema } from './project-config'; + +// A single deployment environment variable as the infrastructure API expects +// it. We only persist/send `secret` vars today (see ProjectConfigSchema.env). +export interface DeploymentSecret { + type: 'secret'; + key: string; + value: string; +} + +// Matches POSIX-ish env var names: leading letter/underscore, then +// letters/digits/underscores. Keeps the stored config (and the deployment +// payload) free of keys that wouldn't be valid env vars at runtime. +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +// Parse a single `KEY=VALUE` assignment. Splits on the *first* `=` only, so +// values containing `=` (e.g. base64 or tokens) survive intact. Validates the +// key shape and rejects empty values. +export const parseEnvAssignment = (input: string): { key: string; value: string } => { + const separatorIndex = input.indexOf('='); + + if (separatorIndex === -1) { + throw new Error(`Invalid env var format: ${input}. Expected format: KEY=VALUE`); + } + + const key = input.slice(0, separatorIndex).trim(); + const value = input.slice(separatorIndex + 1).trim(); + + if (!key || !value) { + throw new Error(`Invalid env var format: ${input}. Expected format: KEY=VALUE`); + } + + if (!ENV_KEY_PATTERN.test(key)) { + throw new Error( + `Invalid env var name: ${key}. Names must start with a letter or underscore and contain only letters, numbers, and underscores.`, + ); + } + + return { key, value }; +}; + +// Read the persisted env var map (KEY -> VALUE), defaulting to empty. +export const getStoredEnv = (config: Conf): Record => { + return config.get('env') ?? {}; +}; + +// Convert a persisted env var map into the deployment `secret` payload shape. +export const toDeploymentSecrets = (envVars: Record): DeploymentSecret[] => { + return Object.entries(envVars).map(([key, value]) => ({ type: 'secret', key, value })); +}; + +// Merge persisted secrets with inline `--secret` flag secrets, keyed by name. +// Inline flags win on conflict so users can override a stored value per-run. +export const mergeDeploymentSecrets = ( + persisted: DeploymentSecret[], + flagSecrets: DeploymentSecret[], +): DeploymentSecret[] => { + const byKey = new Map(); + + persisted.forEach((secret) => byKey.set(secret.key, secret)); + flagSecrets.forEach((secret) => byKey.set(secret.key, secret)); + + return Array.from(byKey.values()); +}; diff --git a/packages/catalyst/src/cli/lib/project-config.spec.ts b/packages/catalyst/src/cli/lib/project-config.spec.ts index 2dcdb8691c..5f50c2f245 100644 --- a/packages/catalyst/src/cli/lib/project-config.spec.ts +++ b/packages/catalyst/src/cli/lib/project-config.spec.ts @@ -50,3 +50,13 @@ test('writes and reads field from .bigcommerce/project.json', async () => { expect(config.get('storeHash')).toBe('abc123'); expect(config.get('accessToken')).toBe('secret-token'); }); + +test('env defaults to an empty object and round-trips a map', () => { + expect(config.get('env')).toEqual({}); + + config.set('env', { FOO: 'bar', BAZ: 'qux' }); + + expect(config.get('env')).toEqual({ FOO: 'bar', BAZ: 'qux' }); + + config.delete('env'); +}); diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 07eddd7506..fb050e0c19 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -7,6 +7,11 @@ export interface ProjectConfigSchema { framework: 'catalyst'; storeHash?: string; accessToken?: string; + // Persistent deployment environment variables (KEY -> VALUE). Every entry is + // sent to the deployment as a `secret` by `catalyst deploy`. Managed via the + // `catalyst env` commands. Lives here (gitignored .bigcommerce/project.json) + // so users don't have to re-pass `--secret` on every deploy. + env?: Record; } export function getProjectConfig() { @@ -23,6 +28,11 @@ export function getProjectConfig() { }, storeHash: { type: 'string' }, accessToken: { type: 'string' }, + env: { + type: 'object', + additionalProperties: { type: 'string' }, + default: {}, + }, }, }); } diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index fa7dc0b2e6..0103375bc6 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -12,6 +12,7 @@ import { build } from './commands/build'; import { channel } from './commands/channel'; import { create } from './commands/create'; import { deploy } from './commands/deploy'; +import { env } from './commands/env'; import { logs } from './commands/logs'; import { project } from './commands/project'; import { start } from './commands/start'; @@ -87,6 +88,7 @@ program .addCommand(deploy) .addCommand(logs) .addCommand(project) + .addCommand(env) .addCommand(channel) .addCommand(auth) .addCommand(telemetry) From ac86ebd14abe02590fa68cece7e26a15c7ede555 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 4 Jun 2026 10:04:30 -0500 Subject: [PATCH 70/90] LTRAC-441: fix(cli) - Surface clear re-auth error on expired token (#3040) Expired device-code access tokens caused `project list`, `deploy`, `logs tail`, and `auth whoami` to fail with a generic "Unauthorized" message, leaving users unsure they needed to re-authenticate. The CLI stores no expiry/refresh metadata, so silent renewal isn't possible. Add a shared `SessionExpiredError` plus `assertAuthorized(response)` and call it at every authenticated fetch site so a 401 consistently directs the user to run `catalyst auth login`. 403 (e.g. "Infrastructure Projects API not enabled") is intentionally left unchanged since it is overloaded for scope/feature-flag errors. Refs LTRAC-441 Co-authored-by: Claude Opus 4.8 (1M context) --- .../catalyst/src/cli/commands/auth.spec.ts | 4 +-- packages/catalyst/src/cli/commands/auth.ts | 9 +++++- packages/catalyst/src/cli/commands/deploy.ts | 9 ++++++ .../catalyst/src/cli/commands/logs.spec.ts | 4 +-- packages/catalyst/src/cli/commands/logs.ts | 7 +++++ .../catalyst/src/cli/commands/project.spec.ts | 21 +++++++++++++ packages/catalyst/src/cli/index.ts | 8 +++++ .../catalyst/src/cli/lib/auth-errors.spec.ts | 24 ++++++++++++++ packages/catalyst/src/cli/lib/auth-errors.ts | 31 +++++++++++++++++++ packages/catalyst/src/cli/lib/channels.ts | 3 ++ packages/catalyst/src/cli/lib/localization.ts | 3 ++ packages/catalyst/src/cli/lib/project.ts | 9 ++++++ 12 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 packages/catalyst/src/cli/lib/auth-errors.spec.ts create mode 100644 packages/catalyst/src/cli/lib/auth-errors.ts diff --git a/packages/catalyst/src/cli/commands/auth.spec.ts b/packages/catalyst/src/cli/commands/auth.spec.ts index d6dbbe9592..5ec3ff2784 100644 --- a/packages/catalyst/src/cli/commands/auth.spec.ts +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -103,7 +103,7 @@ describe('whoami', () => { expect(exitMock).toHaveBeenCalledWith(1); }); - test('reports invalid credentials on 401', async () => { + test('reports an invalid or expired token on 401', async () => { const config = getProjectConfig(); config.set('storeHash', 'test-store'); @@ -119,7 +119,7 @@ describe('whoami', () => { await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); expect(consola.error).toHaveBeenCalledWith( - expect.stringContaining('Not logged in: invalid credentials'), + 'Not logged in: your access token is invalid or has expired. Run `catalyst auth login`.', ); expect(exitMock).toHaveBeenCalledWith(1); }); diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts index 91a68202db..acf7f2e6e0 100644 --- a/packages/catalyst/src/cli/commands/auth.ts +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -1,6 +1,7 @@ import { Command, Option } from 'commander'; import { z } from 'zod'; +import { assertAuthorized, UnauthorizedError } from '../lib/auth-errors'; import { consola } from '../lib/logger'; import { LoginAbortedError, login as runInteractiveLogin } from '../lib/login'; import { fetchProjects } from '../lib/project'; @@ -22,6 +23,8 @@ async function fetchStoreProfile(storeHash: string, accessToken: string, apiHost }, }); + assertAuthorized(response); + if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } @@ -107,7 +110,11 @@ Example: } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (message.includes('401') || message.includes('403')) { + if (error instanceof UnauthorizedError) { + consola.error( + 'Not logged in: your access token is invalid or has expired. Run `catalyst auth login`.', + ); + } else if (message.includes('401') || message.includes('403')) { consola.error(`Not logged in: invalid credentials (${message})`); } else { consola.error(`Failed to verify credentials: ${message}`); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index dc6d785706..7cc6a2668c 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { assertAuthorized } from '../lib/auth-errors'; import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; import { cleanupCloudflareIncompatibilities, @@ -146,6 +147,8 @@ export const generateUploadSignature = async ( }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`Failed to fetch upload signature: ${response.status} ${response.statusText}`); } @@ -223,6 +226,8 @@ export const createDeployment = async ( }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`Failed to create deployment: ${response.status} ${response.statusText}`); } @@ -258,6 +263,8 @@ export const getDeploymentStatus = async ( }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`Failed to open event stream: ${response.status} ${response.statusText}`); } @@ -345,6 +352,8 @@ export const fetchProject = async ( }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`Failed to fetch projects: ${response.status} ${response.statusText}`); } diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts index 7748a5b565..07ea7f4abe 100644 --- a/packages/catalyst/src/cli/commands/logs.spec.ts +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -392,7 +392,7 @@ describe('error handling', () => { ); }); - test('throws on fatal 401 unauthorized', async () => { + test('throws a re-auth error on fatal 401 unauthorized', async () => { server.use( http.get( 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', @@ -401,7 +401,7 @@ describe('error handling', () => { ); await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( - 'Failed to open log stream: 401 Unauthorized', + 'catalyst auth login', ); }); }); diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index 5e7f33d5a4..bfa6dfe12a 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -2,6 +2,7 @@ import { Command, Option } from 'commander'; import { colorize } from 'consola/utils'; import { z } from 'zod'; +import { UnauthorizedError } from '../lib/auth-errors'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; import { resolveCredentials } from '../lib/resolve-credentials'; @@ -194,6 +195,12 @@ const openLogStream = async ( }, ); + // An invalid/expired token won't recover by reconnecting — surface the + // re-auth guidance and stop the loop (fatal) rather than burning retries. + if (response.status === 401) { + throw new StreamError(new UnauthorizedError().message, true); + } + if (!response.ok) { throw new StreamError( `Failed to open log stream: ${response.status} ${response.statusText}`, diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 11ed2b7198..3822261cf8 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -451,6 +451,27 @@ describe('project list', () => { ); expect(exitMock).toHaveBeenCalledWith(1); }); + + test('surfaces an expired-session error on 401', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'list', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow('catalyst auth login'); + }); }); describe('project link', () => { diff --git a/packages/catalyst/src/cli/index.ts b/packages/catalyst/src/cli/index.ts index 47d98191bb..0fca6fe93d 100644 --- a/packages/catalyst/src/cli/index.ts +++ b/packages/catalyst/src/cli/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { UnauthorizedError } from './lib/auth-errors'; import { consola } from './lib/logger'; import { getTelemetry } from './lib/telemetry'; import { program } from './program'; @@ -21,6 +22,13 @@ const handleFatalError = async (error: unknown) => { // Don't mask the original error } + // An invalid/expired token is user-actionable, not a bug to report — print the + // re-auth guidance without the "share your Correlation ID with support" noise. + if (error instanceof UnauthorizedError) { + consola.error(errorMessage); + process.exit(1); + } + consola.error(errorMessage); if (telemetry.isEnabled()) { diff --git a/packages/catalyst/src/cli/lib/auth-errors.spec.ts b/packages/catalyst/src/cli/lib/auth-errors.spec.ts new file mode 100644 index 0000000000..084eb86b44 --- /dev/null +++ b/packages/catalyst/src/cli/lib/auth-errors.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; + +import { assertAuthorized, UnauthorizedError } from './auth-errors'; + +const responseWith = (status: number) => new Response(null, { status }); + +describe('assertAuthorized', () => { + test('throws UnauthorizedError on 401', () => { + expect(() => assertAuthorized(responseWith(401))).toThrow(UnauthorizedError); + }); + + test.each([200, 403, 404, 500])('does not throw on %i', (status) => { + expect(() => assertAuthorized(responseWith(status))).not.toThrow(); + }); +}); + +describe('UnauthorizedError', () => { + test('carries an actionable re-auth message', () => { + const error = new UnauthorizedError(); + + expect(error.name).toBe('UnauthorizedError'); + expect(error.message).toContain('catalyst auth login'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/auth-errors.ts b/packages/catalyst/src/cli/lib/auth-errors.ts new file mode 100644 index 0000000000..27434c73dc --- /dev/null +++ b/packages/catalyst/src/cli/lib/auth-errors.ts @@ -0,0 +1,31 @@ +// Thrown when an authenticated API call comes back 401 Unauthorized — i.e. the +// access token was sent but rejected. This covers an expired/revoked token as +// well as a token that was never valid (e.g. a typo'd `--access-token` flag or +// a stale `CATALYST_ACCESS_TOKEN` env var). It does NOT cover the "no +// credentials at all" case — that's caught earlier by `resolveCredentials` +// (and `auth whoami`'s own guard) before any request is made. +// +// The CLI can't refresh the token silently (the device-code flow returns no +// refresh token), so the only recovery is to re-authenticate. Callers should +// let this propagate to the top-level handler in `index.ts`, which prints the +// message without the generic "share your Correlation ID with support" +// bug-report framing. +export class UnauthorizedError extends Error { + constructor() { + super( + 'Your access token is invalid or has expired.\n' + + 'Run `catalyst auth login` to re-authenticate.', + ); + this.name = 'UnauthorizedError'; + } +} + +// Call immediately after an authenticated `fetch`, before any other status +// checks. Only 401 means "invalid/expired token" — 403 is overloaded elsewhere +// to mean "API not enabled" (scope/feature flag), so it is intentionally not +// treated as an auth failure here. +export function assertAuthorized(response: Response): void { + if (response.status === 401) { + throw new UnauthorizedError(); + } +} diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts index 91e6322470..8d9edb2fa6 100644 --- a/packages/catalyst/src/cli/lib/channels.ts +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { assertAuthorized } from './auth-errors'; import { getTelemetry } from './telemetry'; // `origin` is the CLI-API gateway (configured via `--cli-api-origin`, default @@ -175,6 +176,8 @@ export async function fetchAvailableChannels( }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`GET /v3/channels failed: ${response.status} ${response.statusText}`); } diff --git a/packages/catalyst/src/cli/lib/localization.ts b/packages/catalyst/src/cli/lib/localization.ts index 1f85b1a10f..96b675cec7 100644 --- a/packages/catalyst/src/cli/lib/localization.ts +++ b/packages/catalyst/src/cli/lib/localization.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { assertAuthorized } from './auth-errors'; import { getTelemetry } from './telemetry'; const allowedLocales = [ @@ -52,6 +53,8 @@ export const getAvailableLocales = async ( }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error( `GET /v3/settings/store/available-locales failed: ${response.status} ${response.statusText}`, diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index cd6f55668a..f0a813715b 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { assertAuthorized } from './auth-errors'; import { getTelemetry } from './telemetry'; export class InfrastructureProjectValidationError extends Error { @@ -46,6 +47,8 @@ export async function hasProjectsAccess( headers: authHeaders(accessToken), }); + assertAuthorized(response); + if (response.status === 200) return true; if (response.status === 403) return false; @@ -64,6 +67,8 @@ export async function fetchProjects( headers: authHeaders(accessToken), }); + assertAuthorized(response); + if (response.status === 403) { throw new Error( 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', @@ -133,6 +138,8 @@ export async function createProject( body: JSON.stringify({ name }), }); + assertAuthorized(response); + if (response.status === 400 || response.status === 422) { const body: unknown = await response.json().catch(() => null); const fallback = @@ -177,6 +184,8 @@ export async function deleteProject( headers: authHeaders(accessToken), }); + assertAuthorized(response); + if (response.status === 403) { throw new Error( 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', From f17d3db803531e0e2d72c7cb1fe2727f7136773f Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 4 Jun 2026 10:42:37 -0500 Subject: [PATCH 71/90] LTRAC-808: ref(cli) - Remove IMAGES warning suppression (moved to tail-worker) (#3041) The OpenNext `env.IMAGES binding is not defined` warning is a platform fact, not a per-client preference: Commerce Hosting intentionally runs without that binding, so the warning is always noise for every consumer. Filtering it in the CLI (#3038) only helped users on a new enough CLI and did nothing for the copies forwarded into Sentry. The suppression now lives in the ignition-tail-worker, where it applies to all CLI versions and consumers at once and is also dropped from Sentry. Remove the now-redundant CLI-side filter and its tests so there is a single source of truth. Must merge AFTER the tail-worker change is deployed; otherwise newer-CLI users briefly see the warning again until the worker filter is live. Refs LTRAC-808 Co-authored-by: Claude --- .../catalyst/src/cli/commands/logs.spec.ts | 69 ------------------- packages/catalyst/src/cli/commands/logs.ts | 27 +------- 2 files changed, 2 insertions(+), 94 deletions(-) diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts index 07ea7f4abe..67757b0c7f 100644 --- a/packages/catalyst/src/cli/commands/logs.spec.ts +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -290,75 +290,6 @@ describe('log event processing', () => { }); }); -describe('suppressed log noise', () => { - const imagesWarning = 'env.IMAGES binding is not defined'; - - test('drops the OpenNext IMAGES binding warning from default format', async () => { - const event = { - ...validLogEvent, - logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], - }; - - await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); - - expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); - }); - - test('keeps other log entries when only the IMAGES warning is suppressed', async () => { - const event = { - ...validLogEvent, - logs: [ - { ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }, - { ...validLogEvent.logs[0], messages: ['hello world'] }, - ], - }; - - await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); - - expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); - expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); - }); - - test('emits nothing for an event that is only suppressed noise', async () => { - const event = { - ...validLogEvent, - logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], - }; - - await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); - - expect(consola.log).not.toHaveBeenCalled(); - expect(consola.error).not.toHaveBeenCalled(); - }); - - test('still emits exceptions even when all log entries are suppressed', async () => { - const event = { - ...validLogEvent, - logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], - exceptions: [{ message: 'something broke' }], - }; - - await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); - - expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); - expect(consola.error).toHaveBeenCalledWith( - expect.stringContaining('EXCEPTION'), - expect.objectContaining({ message: 'something broke' }), - ); - }); - - test('does not filter the IMAGES warning out of raw json output', async () => { - const event = { - ...validLogEvent, - logs: [{ ...validLogEvent.logs[0], level: 'warn', messages: [imagesWarning] }], - }; - - await callTailLogs('json', [`data: ${JSON.stringify(event)}\n\n`]); - - expect(stdoutWriteMock).toHaveBeenCalledWith(expect.stringContaining(imagesWarning)); - }); -}); - describe('error handling', () => { test('silently ignores heartbeat events', async () => { await callTailLogs('default', [`data: \n\ndata: ${JSON.stringify(validLogEvent)}\n\n`]); diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index bfa6dfe12a..9e8b8252d5 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -64,17 +64,6 @@ class StreamError extends Error { const formatMessages = (messages: unknown[]) => messages.map((m) => (typeof m === 'string' ? m : JSON.stringify(m))).join(' '); -// Known framework noise to drop from the human-readable log formats. OpenNext's -// Cloudflare image handler logs `env.IMAGES binding is not defined` on every -// `/_next/image` request when no IMAGES binding is configured, then falls back -// to serving the original bytes. Native Hosting intentionally runs without that -// binding (see LTRAC-808), so the warning is expected — suppress it so testers -// aren't flooded with a benign message on every image request. -const SUPPRESSED_LOG_PATTERNS = [/env\.IMAGES binding is not defined/]; - -const isSuppressedMessage = (message: string) => - SUPPRESSED_LOG_PATTERNS.some((pattern) => pattern.test(message)); - const formatLogEvent = ( event: z.infer, format: 'default' | 'short' | 'request', @@ -129,22 +118,10 @@ const processLogEvent = (event: string, format: LogFormat) => { const parsed: unknown = JSON.parse(event); const logEvent = LogEventSchema.parse(parsed); - const visibleLogs = logEvent.logs.filter( - (entry) => !isSuppressedMessage(formatMessages(entry.messages)), - ); - - // Skip events that contained nothing but suppressed noise so we don't emit - // an empty request envelope. - if (visibleLogs.length === 0 && logEvent.exceptions.length === 0) { - return; - } - - const filteredEvent = { ...logEvent, logs: visibleLogs }; - if (format === 'pretty') { - consola.log(JSON.stringify(filteredEvent, null, 2)); + consola.log(JSON.stringify(logEvent, null, 2)); } else { - formatLogEvent(filteredEvent, format); + formatLogEvent(logEvent, format); } } catch { consola.warn(`Failed to parse log event: ${event}`); From 084486140625279276039728f19d05c2b3bfeef3 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 5 Jun 2026 09:40:20 -0500 Subject: [PATCH 72/90] chore: version @bigcommerce/catalyst@1.0.0-alpha.5 Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/pre.json | 2 + packages/catalyst/CHANGELOG.md | 10 + packages/catalyst/package.json | 2 +- pnpm-lock.yaml | 1007 ++++++++++++++++---------------- 4 files changed, 516 insertions(+), 505 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index b7ec98f24e..f891793b33 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -13,7 +13,9 @@ "auto-detect-deploy-secrets", "cli-built-in-help-text", "cli-show-env-path-in-help", + "cli-strip-instrumentation-hook", "fix-env-var-names", + "project-list-deployed-url", "wise-worms-hear" ] } diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md index 7882428b6e..67961f8391 100644 --- a/packages/catalyst/CHANGELOG.md +++ b/packages/catalyst/CHANGELOG.md @@ -1,5 +1,15 @@ # @bigcommerce/catalyst +## 1.0.0-alpha.5 + +### Minor Changes + +- [#2988](https://github.com/bigcommerce/catalyst/pull/2988) [`24f35a4`](https://github.com/bigcommerce/catalyst/commit/24f35a4cc60d73036c264a896e816b98aa47bfba) Thanks [@jorgemoya](https://github.com/jorgemoya)! - 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. + +### Patch Changes + +- [#3028](https://github.com/bigcommerce/catalyst/pull/3028) [`bdc6e0b`](https://github.com/bigcommerce/catalyst/commit/bdc6e0bf055262e1440bcc1ebcc55597256b424a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove `core/instrumentation.ts` and the `@vercel/otel` dependency during Commerce Hosting setup. The hook isn't compatible with the OpenNext + Cloudflare Workers bundling path and caused a "Failed to prepare server" error on every cold start in `catalyst logs tail`. Self-hosted (non-Commerce Hosting) deployments are unaffected. + ## 1.0.0-alpha.4 ### Patch Changes diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index e4b17d1b1c..f9e001aaf5 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/catalyst", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.5", "type": "module", "bin": { "catalyst": "dist/cli.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a33ccc3c6..b92b7f7bba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,16 +43,16 @@ importers: version: link:../packages/client '@c15t/nextjs': specifier: ^1.8.2 - version: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) + version: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@conform-to/react': specifier: ^1.6.1 - version: 1.6.1(react@19.1.5) + version: 1.6.1(react@19.1.7) '@conform-to/zod': specifier: ^1.6.1 version: 1.6.1(zod@3.25.51) '@icons-pack/react-simple-icons': specifier: ^11.2.0 - version: 11.2.0(react@19.1.5) + version: 11.2.0(react@19.1.7) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -67,46 +67,46 @@ importers: version: 0.208.0(@opentelemetry/api@1.9.0) '@radix-ui/react-accordion': specifier: ^1.2.11 - version: 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-checkbox': specifier: ^1.3.2 - version: 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-dialog': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 - version: 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-label': specifier: ^2.1.7 - version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-navigation-menu': specifier: ^1.2.13 - version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-popover': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-portal': specifier: ^1.1.9 - version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-radio-group': specifier: ^1.3.7 - version: 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-select': specifier: ^2.2.5 - version: 2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-switch': specifier: ^1.2.5 - version: 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-toggle': specifier: ^1.1.9 - version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-toggle-group': specifier: ^1.1.10 - version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@radix-ui/react-tooltip': specifier: ^1.2.7 - version: 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) '@t3-oss/env-core': specifier: ^0.13.6 version: 0.13.6(typescript@5.8.3)(zod@3.25.51) @@ -115,7 +115,7 @@ importers: version: 1.35.0 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + version: 1.5.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) '@vercel/functions': specifier: ^2.2.12 version: 2.2.12(@aws-sdk/credential-provider-web-identity@3.972.24) @@ -124,7 +124,7 @@ importers: version: 2.1.0(@opentelemetry/api-logs@0.208.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)) '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) + version: 1.2.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -148,7 +148,7 @@ importers: version: 9.0.0-rc01(embla-carousel@9.0.0-rc01) embla-carousel-react: specifier: 9.0.0-rc01 - version: 9.0.0-rc01(react@19.1.5) + version: 9.0.0-rc01(react@19.1.7) gql.tada: specifier: ^1.8.10 version: 1.8.10(graphql@16.11.0)(typescript@5.8.3) @@ -166,37 +166,37 @@ importers: version: 11.1.0 lucide-react: specifier: ^0.474.0 - version: 0.474.0(react@19.1.5) + version: 0.474.0(react@19.1.7) next: - specifier: ~16.1.6 - version: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + specifier: ~16.2.6 + version: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) next-auth: specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) + version: 5.0.0-beta.30(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7) next-intl: specifier: ^4.6.1 - version: 4.8.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3) + version: 4.8.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(typescript@5.8.3) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5) + version: 2.4.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7) p-lazy: specifier: ^5.0.0 version: 5.0.0 react: - specifier: 19.1.5 - version: 19.1.5 + specifier: 19.1.7 + version: 19.1.7 react-day-picker: specifier: ^9.7.0 - version: 9.7.0(react@19.1.5) + version: 9.7.0(react@19.1.7) react-dom: - specifier: 19.1.5 - version: 19.1.5(react@19.1.5) + specifier: 19.1.7 + version: 19.1.7(react@19.1.7) react-google-recaptcha: specifier: ^3.1.0 - version: 3.1.0(react@19.1.5) + version: 3.1.0(react@19.1.7) react-headroom: specifier: ^3.2.1 - version: 3.2.1(react@19.1.5) + version: 3.2.1(react@19.1.7) schema-dts: specifier: ^1.1.5 version: 1.1.5 @@ -205,7 +205,7 @@ importers: version: 0.0.1 sonner: specifier: ^1.7.4 - version: 1.7.4(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + version: 1.7.4(react-dom@19.1.7(react@19.1.7))(react@19.1.7) tailwindcss-radix: specifier: ^3.0.5 version: 3.0.5(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3))) @@ -314,7 +314,7 @@ importers: version: 7.5.3(@types/node@22.15.30) '@opennextjs/cloudflare': specifier: 1.17.3 - version: 1.17.3(encoding@0.1.13)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(wrangler@4.31.0) + version: 1.17.3(encoding@0.1.13)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(wrangler@4.31.0) '@segment/analytics-node': specifier: ^2.2.1 version: 2.2.1(encoding@0.1.13) @@ -2441,8 +2441,8 @@ packages: '@next/bundle-analyzer@16.1.6': resolution: {integrity: sha512-ee2kagdTaeEWPlotgdTOqFHYcD3e2m2bbE3I9Rq2i6ABYi5OgopmtEUe8NM23viaYxLV2tDH/2nd5+qKoEr6cw==} - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} '@next/eslint-plugin-next@15.3.3': resolution: {integrity: sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q==} @@ -2450,50 +2450,50 @@ packages: '@next/eslint-plugin-next@15.5.10': resolution: {integrity: sha512-fDpxcy6G7Il4lQVVsaJD0fdC2/+SmuBGTF+edRLlsR4ZFOE3W2VyzrrGYdg/pHW8TydeAdSVM+mIzITGtZ3yWA==} - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -7690,8 +7690,8 @@ packages: typescript: optional: true - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -8479,7 +8479,6 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: @@ -8534,10 +8533,10 @@ packages: peerDependencies: react: '>=16.8.0' - react-dom@19.1.5: - resolution: {integrity: sha512-tvMijysf97vcHla1PNI/aU2apv7f4r0ct0OBk3i3QOBfsVhZzHEuPBLemClkfuw8LroE4FH6kXcQOftf2ntPHQ==} + react-dom@19.1.7: + resolution: {integrity: sha512-hE2iTlqZDmmCBRpmXbzHJLngNeHfLtdMA1qp4eDreMgdtbCgFAWGJhvD2u1KF0UQe5W5y2cQkpjAYTGhXbil4A==} peerDependencies: - react: ^19.1.5 + react: ^19.1.7 react-google-recaptcha@3.1.0: resolution: {integrity: sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==} @@ -8585,8 +8584,8 @@ packages: '@types/react': optional: true - react@19.1.5: - resolution: {integrity: sha512-lCX00zqONdNfcnJYEL91LuNYzyWFU70vKhApUR08Y1Fi/Y5FGw6l6hAWtlkq+k/vnx463XLm/5dyQp5HAJCw6Q==} + react@19.1.7: + resolution: {integrity: sha512-sExZFfembjCLTr9ran4JS8W2Z9m3d0lbrOAuFreAR8krpw76YnK+lnzlkO4OvFjEuHzKc8rw94h0EAVSh/Gn+w==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -9202,8 +9201,8 @@ packages: third-party-web@0.26.6: resolution: {integrity: sha512-GsjP92xycMK8qLTcQCacgzvffYzEqe29wyz3zdKVXlfRD5Kz1NatCTOZEeDaSd6uCZXvGd2CNVtQ89RNIhJWvA==} - third-party-web@0.29.0: - resolution: {integrity: sha512-nBDSJw5B7Sl1YfsATG2XkW5qgUPODbJhXw++BKygi9w6O/NKS98/uY/nR/DxDq2axEjL6halHW1v+jhm/j1DBQ==} + third-party-web@0.29.2: + resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -11104,13 +11103,13 @@ snapshots: neverthrow: 8.2.0 picocolors: 1.1.1 - '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': + '@c15t/nextjs@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': dependencies: - '@c15t/react': 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) + '@c15t/react': 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@c15t/translations': 1.8.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -11152,16 +11151,16 @@ snapshots: - use-sync-external-store - ws - '@c15t/react@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': + '@c15t/react@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2)': dependencies: - '@radix-ui/react-accordion': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-switch': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - c15t: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) + '@radix-ui/react-accordion': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-switch': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + c15t: 1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) clsx: 2.1.1 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) - zustand: 5.0.8(@types/react@19.2.7)(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) + zustand: 5.0.8(@types/react@19.2.7)(react@19.1.7) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -11404,10 +11403,10 @@ snapshots: '@conform-to/dom@1.6.1': {} - '@conform-to/react@1.6.1(react@19.1.5)': + '@conform-to/react@1.6.1(react@19.1.7)': dependencies: '@conform-to/dom': 1.6.1 - react: 19.1.5 + react: 19.1.7 '@conform-to/zod@1.6.1(zod@3.25.51)': dependencies: @@ -11913,11 +11912,11 @@ snapshots: '@floating-ui/core': 1.7.1 '@floating-ui/utils': 0.2.9 - '@floating-ui/react-dom@2.1.3(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@floating-ui/react-dom@2.1.3(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@floating-ui/dom': 1.7.1 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) '@floating-ui/utils@0.2.9': {} @@ -12018,9 +12017,9 @@ snapshots: '@iarna/toml@2.2.5': {} - '@icons-pack/react-simple-icons@11.2.0(react@19.1.5)': + '@icons-pack/react-simple-icons@11.2.0(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 '@img/colour@1.1.0': optional: true @@ -12628,7 +12627,7 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@16.1.6': {} + '@next/env@16.2.6': {} '@next/eslint-plugin-next@15.3.3': dependencies: @@ -12638,28 +12637,28 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.1.6': + '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@16.1.6': + '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@16.1.6': + '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@16.1.6': + '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@16.1.6': + '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@16.1.6': + '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@16.1.6': + '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@16.1.6': + '@next/swc-win32-x64-msvc@16.2.6': optional: true '@noble/ciphers@1.3.0': {} @@ -12708,7 +12707,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opennextjs/aws@3.9.16(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))': + '@opennextjs/aws@3.9.16(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))': dependencies: '@ast-grep/napi': 0.40.5 '@aws-sdk/client-cloudfront': 3.984.0 @@ -12724,7 +12723,7 @@ snapshots: cookie: 1.0.2 esbuild: 0.25.4 express: 5.2.1 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) path-to-regexp: 6.3.0 urlpattern-polyfill: 10.1.0 yaml: 2.8.1 @@ -12732,16 +12731,16 @@ snapshots: - aws-crt - supports-color - '@opennextjs/cloudflare@1.17.3(encoding@0.1.13)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(wrangler@4.31.0)': + '@opennextjs/cloudflare@1.17.3(encoding@0.1.13)(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(wrangler@4.31.0)': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 3.9.16(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)) + '@opennextjs/aws': 3.9.16(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)) cloudflare: 4.5.0(encoding@0.1.13) comment-json: 4.6.2 enquirer: 2.4.1 glob: 12.0.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) ts-tqdm: 0.8.6 wrangler: 4.31.0 yargs: 18.0.0 @@ -13237,7 +13236,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.0 + third-party-web: 0.29.2 '@pkgjs/parseargs@0.11.0': optional: true @@ -13322,579 +13321,579 @@ snapshots: '@radix-ui/primitive@1.1.2': {} - '@radix-ui/react-accordion@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-accordion@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) aria-hidden: 1.2.6 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.7)(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-menu@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) aria-hidden: 1.2.6 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-navigation-menu@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-navigation-menu@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-popover@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-popover@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) aria-hidden: 1.2.6 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-popper@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': - dependencies: - '@floating-ui/react-dom': 2.1.3(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) + '@radix-ui/react-popper@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': + dependencies: + '@floating-ui/react-dom': 2.1.3(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) '@radix-ui/rect': 1.1.1 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-select@2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-select@2.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) aria-hidden: 1.2.6 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) - react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-slot@1.2.0(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-slot@1.2.0(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-switch@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-switch@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-toggle-group@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-toggle-group@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-toggle': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-toggle': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-toggle@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-toggle@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.1.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.1.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.1.5)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.1.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.5) - react: 19.1.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.7) + react: 19.1.7 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) @@ -15050,10 +15049,10 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@1.5.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/analytics@1.5.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -15078,10 +15077,10 @@ snapshots: '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@vercel/speed-insights@1.2.0(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': + '@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(svelte@5.1.15)(vue@3.5.16(typescript@5.8.3))': optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 svelte: 5.1.15 vue: 3.5.16(typescript@5.8.3) @@ -15639,13 +15638,13 @@ snapshots: optionalDependencies: magicast: 0.3.5 - c15t@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2): + c15t@1.8.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/react@19.2.7)(@upstash/redis@1.35.0)(crossws@0.3.5)(react@19.1.7)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2): dependencies: '@c15t/backend': 1.8.0(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@upstash/redis@1.35.0)(crossws@0.3.5)(typeorm@0.3.27(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(ws@8.18.2) '@c15t/translations': 1.8.0 '@orpc/client': 1.8.1(@opentelemetry/api@1.9.0) '@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(crossws@0.3.5)(ws@8.18.2) - zustand: 5.0.8(@types/react@19.2.7)(react@19.1.5) + zustand: 5.0.8(@types/react@19.2.7)(react@19.1.7) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -16316,11 +16315,11 @@ snapshots: dependencies: embla-carousel: 9.0.0-rc01 - embla-carousel-react@9.0.0-rc01(react@19.1.5): + embla-carousel-react@9.0.0-rc01(react@19.1.7): dependencies: embla-carousel: 9.0.0-rc01 embla-carousel-reactive-utils: 9.0.0-rc01(embla-carousel@9.0.0-rc01) - react: 19.1.5 + react: 19.1.7 embla-carousel-reactive-utils@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: @@ -18655,9 +18654,9 @@ snapshots: lru_map@0.3.3: {} - lucide-react@0.474.0(react@19.1.5): + lucide-react@0.474.0(react@19.1.7): dependencies: - react: 19.1.5 + react: 19.1.7 magic-string@0.30.17: dependencies: @@ -18680,7 +18679,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.4 make-error@1.3.6: optional: true @@ -18855,50 +18854,50 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.44.2 - next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): + next-auth@5.0.0-beta.30(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7): dependencies: '@auth/core': 0.41.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) - react: 19.1.5 + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) + react: 19.1.7 next-intl-swc-plugin-extractor@4.8.3: {} - next-intl@4.8.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5)(typescript@5.8.3): + next-intl@4.8.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7)(typescript@5.8.3): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@parcel/watcher': 2.5.1 '@swc/core': 1.15.18 icu-minify: 4.8.3 negotiator: 1.0.0 - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) next-intl-swc-plugin-extractor: 4.8.3 po-parser: 2.1.1 - react: 19.1.5 - use-intl: 4.8.3(react@19.1.5) + react: 19.1.7 + use-intl: 4.8.3(react@19.1.7) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: - '@swc/helpers' - next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5): + next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.2.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.7 caniuse-lite: 1.0.30001721 postcss: 8.4.31 - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) - styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) + styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.7) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.52.0 sharp: 0.34.5 @@ -18949,12 +18948,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.4.3(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(react@19.1.5): + nuqs@2.4.3(next@16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7))(react@19.1.7): dependencies: mitt: 3.0.1 - react: 19.1.5 + react: 19.1.7 optionalDependencies: - next: 16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5) + next: 16.2.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.7(react@19.1.7))(react@19.1.7) nwsapi@2.2.20: optional: true @@ -19724,69 +19723,69 @@ snapshots: defu: 6.1.4 destr: 2.0.5 - react-async-script@1.2.0(react@19.1.5): + react-async-script@1.2.0(react@19.1.7): dependencies: hoist-non-react-statics: 3.3.2 prop-types: 15.8.1 - react: 19.1.5 + react: 19.1.7 - react-day-picker@9.7.0(react@19.1.5): + react-day-picker@9.7.0(react@19.1.7): dependencies: '@date-fns/tz': 1.2.0 date-fns: 4.1.0 date-fns-jalali: 4.1.0-0 - react: 19.1.5 + react: 19.1.7 - react-dom@19.1.5(react@19.1.5): + react-dom@19.1.7(react@19.1.7): dependencies: - react: 19.1.5 + react: 19.1.7 scheduler: 0.26.0 - react-google-recaptcha@3.1.0(react@19.1.5): + react-google-recaptcha@3.1.0(react@19.1.7): dependencies: prop-types: 15.8.1 - react: 19.1.5 - react-async-script: 1.2.0(react@19.1.5) + react: 19.1.7 + react-async-script: 1.2.0(react@19.1.7) - react-headroom@3.2.1(react@19.1.5): + react-headroom@3.2.1(react@19.1.7): dependencies: prop-types: 15.8.1 raf: 3.4.1 - react: 19.1.5 + react: 19.1.7 shallowequal: 1.1.0 react-is@16.13.1: {} react-is@18.3.1: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.1.5): + react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.1.7): dependencies: - react: 19.1.5 - react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.5) + react: 19.1.7 + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.7) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.1.5): + react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.1.7): dependencies: - react: 19.1.5 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.1.5) - react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.5) + react: 19.1.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.1.7) + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.1.7) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.1.5) - use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.1.5) + use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.1.7) + use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.1.7) optionalDependencies: '@types/react': 19.2.7 - react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.5): + react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.7): dependencies: get-nonce: 1.0.1 - react: 19.1.5 + react: 19.1.7 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react@19.1.5: {} + react@19.1.7: {} read-cache@1.0.0: dependencies: @@ -20217,10 +20216,10 @@ snapshots: ip-address: 9.0.5 smart-buffer: 4.2.0 - sonner@1.7.4(react-dom@19.1.5(react@19.1.5))(react@19.1.5): + sonner@1.7.4(react-dom@19.1.7(react@19.1.7))(react@19.1.7): dependencies: - react: 19.1.5 - react-dom: 19.1.5(react@19.1.5) + react: 19.1.7 + react-dom: 19.1.7(react@19.1.7) source-map-js@1.2.1: {} @@ -20404,10 +20403,10 @@ snapshots: stubborn-fs@1.2.5: {} - styled-jsx@5.1.6(@babel/core@7.27.4)(react@19.1.5): + styled-jsx@5.1.6(@babel/core@7.27.4)(react@19.1.7): dependencies: client-only: 0.0.1 - react: 19.1.5 + react: 19.1.7 optionalDependencies: '@babel/core': 7.27.4 @@ -20569,7 +20568,7 @@ snapshots: third-party-web@0.26.6: {} - third-party-web@0.29.0: {} + third-party-web@0.29.2: {} through@2.3.8: {} @@ -21038,25 +21037,25 @@ snapshots: urlpattern-polyfill@10.1.0: {} - use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.1.5): + use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.1.7): dependencies: - react: 19.1.5 + react: 19.1.7 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - use-intl@4.8.3(react@19.1.5): + use-intl@4.8.3(react@19.1.7): dependencies: '@formatjs/fast-memoize': 3.1.0 '@schummar/icu-type-parser': 1.21.5 icu-minify: 4.8.3 intl-messageformat: 11.1.2 - react: 19.1.5 + react: 19.1.7 - use-sidecar@1.1.3(@types/react@19.2.7)(react@19.1.5): + use-sidecar@1.1.3(@types/react@19.2.7)(react@19.1.7): dependencies: detect-node-es: 1.1.0 - react: 19.1.5 + react: 19.1.7 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 @@ -21448,7 +21447,7 @@ snapshots: zod@4.1.12: {} - zustand@5.0.8(@types/react@19.2.7)(react@19.1.5): + zustand@5.0.8(@types/react@19.2.7)(react@19.1.7): optionalDependencies: '@types/react': 19.2.7 - react: 19.1.5 + react: 19.1.7 From a9c5da3e27cd26d53fc4bd274c28aebc03fd6539 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 11 Jun 2026 15:30:19 -0500 Subject: [PATCH 73/90] LTRAC-873: fix(cli) - Derive Wrangler compatibility_date dynamically (#3045) The build pinned compatibility_date to 2025-09-15 while the deployment service stamps a current date at deploy time, so workers ran under newer Cloudflare runtime semantics than they were built against. Compute the date as current UTC date minus one month, matching the same offset the deployment service applies, so build and deploy semantics stay aligned. Refs LTRAC-873 Co-authored-by: Claude --- .changeset/cli-dynamic-compat-date.md | 5 +++++ .../src/cli/lib/wrangler-config.spec.ts | 16 ++++++++++++++- .../catalyst/src/cli/lib/wrangler-config.ts | 20 ++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-dynamic-compat-date.md diff --git a/.changeset/cli-dynamic-compat-date.md b/.changeset/cli-dynamic-compat-date.md new file mode 100644 index 0000000000..3f328efd69 --- /dev/null +++ b/.changeset/cli-dynamic-compat-date.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": patch +--- + +`catalyst build` now derives the Cloudflare Workers `compatibility_date` dynamically (current date minus one month) instead of using a pinned date, keeping the build-time runtime semantics aligned with what the deployment service applies at deploy time. diff --git a/packages/catalyst/src/cli/lib/wrangler-config.spec.ts b/packages/catalyst/src/cli/lib/wrangler-config.spec.ts index 93a70c01ab..daa854696a 100644 --- a/packages/catalyst/src/cli/lib/wrangler-config.spec.ts +++ b/packages/catalyst/src/cli/lib/wrangler-config.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { getWranglerConfig } from './wrangler-config'; +import { getCompatibilityDate, getWranglerConfig } from './wrangler-config'; test('returns a config with name identical to worker self reference service', () => { const config = getWranglerConfig('uuid'); @@ -10,3 +10,17 @@ test('returns a config with name identical to worker self reference service', () config.services.find((service) => service.binding === 'WORKER_SELF_REFERENCE')?.service, ).toBe(`project-uuid`); }); + +test('compatibility date is one month before the given date', () => { + expect(getCompatibilityDate(new Date('2026-06-11T15:00:00Z'))).toBe('2026-05-11'); +}); + +test('compatibility date handles month-end normalization', () => { + // May 31 minus one month lands in early May (no April 31), still a valid date. + expect(getCompatibilityDate(new Date('2026-05-31T12:00:00Z'))).toBe('2026-05-01'); + expect(getCompatibilityDate(new Date('2026-01-15T00:00:00Z'))).toBe('2025-12-15'); +}); + +test('config uses a YYYY-MM-DD compatibility date', () => { + expect(getWranglerConfig('uuid').compatibility_date).toMatch(/^\d{4}-\d{2}-\d{2}$/u); +}); diff --git a/packages/catalyst/src/cli/lib/wrangler-config.ts b/packages/catalyst/src/cli/lib/wrangler-config.ts index 3760d9799e..ca12b3d46e 100644 --- a/packages/catalyst/src/cli/lib/wrangler-config.ts +++ b/packages/catalyst/src/cli/lib/wrangler-config.ts @@ -1,9 +1,27 @@ +// The compatibility date, compatibility flags, Durable Object classes, and +// migration tag below are mirrored in Ignition, which builds its own Worker +// metadata at deploy time (pkg/cloudflare/upload/metadata.go and +// pkg/cloudflare/upload/migrations/migrations.go). Keep both sides in sync +// when changing any of them. +export function getCompatibilityDate(now = new Date()): string { + const date = new Date(now); + + // One month behind the current date: recent enough to track Cloudflare + // runtime behavior (per Cloudflare guidance), buffered enough to avoid + // brand-new compatibility-date-gated changes. Ignition applies the same + // offset at deploy time, so the bundle is never built against newer + // semantics than it runs under. + date.setUTCMonth(date.getUTCMonth() - 1); + + return date.toISOString().slice(0, 10); +} + export function getWranglerConfig(projectUuid: string) { return { $schema: 'node_modules/wrangler/config-schema.json', main: '../.open-next/worker.js', name: `project-${projectUuid}`, - compatibility_date: '2025-09-15', + compatibility_date: getCompatibilityDate(), compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public'], observability: { enabled: true, From eb701b697226e369d86da27257460dc042fe317e Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 22 Jun 2026 11:52:58 -0500 Subject: [PATCH 74/90] LTRAC-908: feat(cli) - Add `catalyst channel link` command (#3051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude --- .../catalyst/src/cli/commands/channel.spec.ts | 173 +++++++++++++++++ packages/catalyst/src/cli/commands/channel.ts | 175 +++++++++++++++++- packages/catalyst/src/cli/commands/create.ts | 31 +--- .../catalyst/src/cli/lib/channels.spec.ts | 28 ++- packages/catalyst/src/cli/lib/channels.ts | 26 +++ 5 files changed, 406 insertions(+), 27 deletions(-) diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts index 1298a79adf..664ad0b3cf 100644 --- a/packages/catalyst/src/cli/commands/channel.spec.ts +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -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'; @@ -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; @@ -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', () => { @@ -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'); + }); +}); diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts index fe4f61c2d9..a1fc9e2c76 100644 --- a/packages/catalyst/src/cli/commands/channel.ts +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -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, +): 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( @@ -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 --access-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 ', 'Link this channel directly, skipping the picker.').argParser( + parseChannelId, + ), + ) + .option( + '--env ', + 'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).', + ) + .addOption( + new Option('--cli-api-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 = { ...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); diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts index 42dc62e91b..297591df66 100644 --- a/packages/catalyst/src/cli/commands/create.ts +++ b/packages/catalyst/src/cli/commands/create.ts @@ -9,10 +9,12 @@ import { join } from 'path'; import { DEFAULT_LOGIN_URL } from '../lib/auth'; import { buildWorkspacePackages } from '../lib/build-workspace-packages'; import { + channelPlatformLabel, checkChannelEligibility, createChannel, fetchAvailableChannels, getChannelInit, + sortChannelsByPlatform, } from '../lib/channels'; import { cloneCatalyst } from '../lib/clone-catalyst'; import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; @@ -144,34 +146,15 @@ async function handleChannelCreation( } async function handleChannelSelection(storeHash: string, accessToken: string, apiHost: string) { - const channelSortOrder = ['catalyst', 'next', 'bigcommerce']; const channels = await fetchAvailableChannels(storeHash, accessToken, apiHost); const existingChannel = await select({ message: 'Which channel would you like to use?', - choices: channels - .sort((a, b) => { - const aIndex = channelSortOrder.indexOf(a.platform); - const bIndex = channelSortOrder.indexOf(b.platform); - - if (aIndex === -1 && bIndex === -1) { - return 0; - } - - if (aIndex === -1) return 1; - if (bIndex === -1) return -1; - - return aIndex - bIndex; - }) - .map((ch) => ({ - name: ch.name, - value: ch, - description: `Channel Platform: ${ - ch.platform === 'bigcommerce' - ? 'Stencil' - : ch.platform.charAt(0).toUpperCase() + ch.platform.slice(1) - }`, - })), + choices: sortChannelsByPlatform(channels).map((ch) => ({ + name: ch.name, + value: ch, + description: `Channel Platform: ${channelPlatformLabel(ch.platform)}`, + })), }); return existingChannel.id; diff --git a/packages/catalyst/src/cli/lib/channels.spec.ts b/packages/catalyst/src/cli/lib/channels.spec.ts index 474b330731..bf2b198545 100644 --- a/packages/catalyst/src/cli/lib/channels.spec.ts +++ b/packages/catalyst/src/cli/lib/channels.spec.ts @@ -3,7 +3,12 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import { server } from '../../../tests/mocks/node'; -import { updateChannelSiteUrl } from './channels'; +import { + type Channel, + channelPlatformLabel, + sortChannelsByPlatform, + updateChannelSiteUrl, +} from './channels'; const storeHash = 'test-store'; const accessToken = 'test-token'; @@ -105,3 +110,24 @@ describe('updateChannelSiteUrl', () => { ).rejects.toThrow('Failed to update channel site: 500'); }); }); + +describe('sortChannelsByPlatform', () => { + const ch = (id: number, platform: string): Channel => ({ id, name: `ch-${id}`, platform }); + + test('orders catalyst → next → bigcommerce → unknown, without mutating the input', () => { + const input = [ch(1, 'wordpress'), ch(2, 'bigcommerce'), ch(3, 'next'), ch(4, 'catalyst')]; + const sorted = sortChannelsByPlatform(input); + + expect(sorted.map((c) => c.platform)).toEqual(['catalyst', 'next', 'bigcommerce', 'wordpress']); + // Original array is untouched. + expect(input.map((c) => c.platform)).toEqual(['wordpress', 'bigcommerce', 'next', 'catalyst']); + }); +}); + +describe('channelPlatformLabel', () => { + test('maps bigcommerce to Stencil and title-cases the rest', () => { + expect(channelPlatformLabel('bigcommerce')).toBe('Stencil'); + expect(channelPlatformLabel('catalyst')).toBe('Catalyst'); + expect(channelPlatformLabel('next')).toBe('Next'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts index 8d9edb2fa6..0ea354326a 100644 --- a/packages/catalyst/src/cli/lib/channels.ts +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -37,6 +37,32 @@ const channelsResponseSchema = z.object({ data: z.array(channelSchema), }); +// Channels are surfaced Catalyst-first, then Next, then Stencil +// (`bigcommerce`), then anything else — the order the `create` and +// `channel link` pickers both present. Returns a sorted copy. +const CHANNEL_PLATFORM_ORDER = ['catalyst', 'next', 'bigcommerce']; + +export function sortChannelsByPlatform(channels: Channel[]): Channel[] { + return [...channels].sort((a, b) => { + const aIndex = CHANNEL_PLATFORM_ORDER.indexOf(a.platform); + const bIndex = CHANNEL_PLATFORM_ORDER.indexOf(b.platform); + + if (aIndex === -1 && bIndex === -1) return 0; + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + + return aIndex - bIndex; + }); +} + +// Human-friendly platform name for channel pickers. `bigcommerce` is the +// Stencil storefront platform; everything else is title-cased. +export function channelPlatformLabel(platform: string): string { + return platform === 'bigcommerce' + ? 'Stencil' + : platform.charAt(0).toUpperCase() + platform.slice(1); +} + const initResponseSchema = z.object({ data: z.object({ storefront_api_token: z.string(), From 8dfd9c762cfea249e22fdbf943fe1a78bc1a081e Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 22 Jun 2026 14:38:33 -0500 Subject: [PATCH 75/90] LTRAC-911: ref(cli) - Standardize interactive prompts on `@inquirer/prompts` (#3054) * LTRAC-911: ref(cli) - Standardize interactive prompts on @inquirer/prompts Replace all `consola.prompt` usages with `@inquirer/prompts` (confirm/input/select/ checkbox) so the CLI uses one prompt library; consola is retained for logging only. Converts create's locale multiselect to an inquirer `checkbox` with a `validate` (drops the consola cast + recursion hack), and migrates the prompts in login, channel link/update, project, deploy, channel-site-flow, and commerce-hosting. Specs updated to mock `@inquirer/prompts`. Chosen over consola because consola.prompt has no masked input (login's access token uses `password({ mask: true })`), plus inquirer's validate/theming. Stacked on #3051 (channel link). Refs LTRAC-911 Co-Authored-By: Claude * LTRAC-911: style(cli) - Standardize spacing on prompts and command output Stray blank lines came from leading/trailing newlines and `consola.log('')` calls, which the fancy reporter timestamps as their own log line. Collapse multi-line "Next steps" output into a single `consola.log` call (one timestamp, predictable spacing) and drop the manual blank-line separators after prompts. `project list` now joins its project blocks into one log call so each blank separator isn't timestamped. Refs LTRAC-911 Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../catalyst/src/cli/commands/auth.spec.ts | 59 +-- .../catalyst/src/cli/commands/channel.spec.ts | 37 +- packages/catalyst/src/cli/commands/channel.ts | 17 +- .../catalyst/src/cli/commands/create.spec.ts | 1 + packages/catalyst/src/cli/commands/create.ts | 46 +- .../catalyst/src/cli/commands/deploy.spec.ts | 98 ++-- packages/catalyst/src/cli/commands/deploy.ts | 10 +- .../catalyst/src/cli/commands/project.spec.ts | 434 ++++++++---------- packages/catalyst/src/cli/commands/project.ts | 78 ++-- .../src/cli/lib/channel-site-flow.spec.ts | 115 ++--- .../catalyst/src/cli/lib/channel-site-flow.ts | 25 +- .../src/cli/lib/commerce-hosting.spec.ts | 28 +- .../catalyst/src/cli/lib/commerce-hosting.ts | 51 +- packages/catalyst/src/cli/lib/login.ts | 14 +- 14 files changed, 452 insertions(+), 561 deletions(-) diff --git a/packages/catalyst/src/cli/commands/auth.spec.ts b/packages/catalyst/src/cli/commands/auth.spec.ts index 5ec3ff2784..314dfc4f27 100644 --- a/packages/catalyst/src/cli/commands/auth.spec.ts +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -1,4 +1,4 @@ -import { password } from '@inquirer/prompts'; +import { confirm, input, password } from '@inquirer/prompts'; import { Command } from 'commander'; import { http, HttpResponse } from 'msw'; import { realpath } from 'node:fs/promises'; @@ -27,9 +27,13 @@ import { auth } from './auth'; vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + input: vi.fn(), password: vi.fn(), })); +const confirmMock = vi.mocked(confirm); +const inputMock = vi.mocked(input); const passwordMock = vi.mocked(password); let exitMock: MockInstance; @@ -163,25 +167,16 @@ describe('login', () => { ), ); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); passwordMock.mockResolvedValueOnce('manual-access-token'); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - expect(message).toContain('Try logging in manually'); - expect(opts).toMatchObject({ type: 'confirm' }); - - return Promise.resolve(true); - }) - .mockImplementationOnce(async (message, opts) => { - expect(message).toBe('Store hash:'); - expect(opts).toMatchObject({ type: 'text' }); - - return Promise.resolve('manual-store-hash'); - }); - await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + expect(confirmMock).toHaveBeenCalledOnce(); + expect(confirmMock.mock.calls[0]?.[0].message).toContain('Try logging in manually'); + expect(inputMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'Store hash:' })); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work")); expect(consola.success).toHaveBeenCalledWith('Logged in to store manual-store-hash.'); expect(exitMock).toHaveBeenCalledWith(0); @@ -190,8 +185,6 @@ describe('login', () => { expect(config.get('storeHash')).toBe('manual-store-hash'); expect(config.get('accessToken')).toBe('manual-access-token'); - - promptMock.mockRestore(); }); test('exits cleanly when user declines manual login fallback', async () => { @@ -202,7 +195,7 @@ describe('login', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); await program.parseAsync(['node', 'catalyst', 'auth', 'login']); @@ -211,8 +204,6 @@ describe('login', () => { 'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.', ); expect(exitMock).toHaveBeenCalledWith(0); - - promptMock.mockRestore(); }); test('fails when manual credentials cannot be validated', async () => { @@ -227,21 +218,16 @@ describe('login', () => { ), ); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); passwordMock.mockResolvedValueOnce('bad-token'); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce('manual-store-hash'); - await program.parseAsync(['node', 'catalyst', 'auth', 'login']); expect(consola.error).toHaveBeenCalledWith( expect.stringContaining('Could not validate credentials'), ); expect(exitMock).toHaveBeenCalledWith(1); - - promptMock.mockRestore(); }); test('rejects empty store hash during manual login', async () => { @@ -252,17 +238,13 @@ describe('login', () => { ), ); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(' '); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce(' '); await program.parseAsync(['node', 'catalyst', 'auth', 'login']); expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Store hash is required')); expect(exitMock).toHaveBeenCalledWith(1); - - promptMock.mockRestore(); }); test('rejects empty access token during manual login', async () => { @@ -273,19 +255,14 @@ describe('login', () => { ), ); + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); passwordMock.mockResolvedValueOnce(' '); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce('manual-store-hash'); - await program.parseAsync(['node', 'catalyst', 'auth', 'login']); expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Access token is required')); expect(exitMock).toHaveBeenCalledWith(1); - - promptMock.mockRestore(); }); test('handles browser open failure gracefully', async () => { diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts index 664ad0b3cf..04cf558cd0 100644 --- a/packages/catalyst/src/cli/commands/channel.spec.ts +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -1,3 +1,4 @@ +import { confirm, select } from '@inquirer/prompts'; import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; @@ -13,12 +14,20 @@ import { program } from '../program'; import { channel } from './channel'; +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), +})); // `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')); +const mockSelect = vi.mocked(select); +const mockConfirm = vi.mocked(confirm); + let exitMock: MockInstance; let tmpDir: string; @@ -122,10 +131,7 @@ describe('channel update', () => { ), ); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') - .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await program.parseAsync([ 'node', @@ -140,7 +146,7 @@ describe('channel update', () => { linkedProjectUuid, ]); - expect(promptMock).toHaveBeenCalledTimes(2); + expect(mockSelect).toHaveBeenCalledTimes(2); expect(putChannelId).toBe('2'); expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); expect(consola.success).toHaveBeenCalledWith( @@ -152,10 +158,7 @@ describe('channel update', () => { test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => { config.set('projectUuid', linkedProjectUuid); - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') - .mockResolvedValueOnce('vanity.project-one.example.com'); + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com'); await program.parseAsync([ 'node', @@ -168,7 +171,7 @@ describe('channel update', () => { accessToken, ]); - expect(promptMock).toHaveBeenCalledTimes(2); + expect(mockSelect).toHaveBeenCalledTimes(2); expect(consola.success).toHaveBeenCalledWith( expect.stringContaining('https://vanity.project-one.example.com'), ); @@ -192,8 +195,6 @@ describe('channel update', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt'); - await program.parseAsync([ 'node', 'catalyst', @@ -211,7 +212,7 @@ describe('channel update', () => { 'override.example', ]); - expect(promptMock).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); expect(putChannelId).toBe('5'); expect(putBody).toEqual({ url: 'https://override.example' }); }); @@ -224,7 +225,7 @@ describe('channel update', () => { ); // First prompt: "Would you like to create one?" — user says no - vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + mockConfirm.mockResolvedValueOnce(false); await program.parseAsync([ 'node', @@ -248,9 +249,7 @@ describe('channel update', () => { ), ); - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') - .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await expect( program.parseAsync([ @@ -330,7 +329,7 @@ describe('channel link', () => { }), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + mockSelect.mockResolvedValueOnce(2); await program.parseAsync([ 'node', @@ -343,7 +342,7 @@ describe('channel link', () => { accessToken, ]); - expect(promptMock).toHaveBeenCalledTimes(1); + expect(mockSelect).toHaveBeenCalledTimes(1); expect(initChannelId).toBe('2'); // id 2 in the default channels handler is "Catalyst Storefront". expect(consola.success).toHaveBeenCalledWith( diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts index a1fc9e2c76..2c6313c18f 100644 --- a/packages/catalyst/src/cli/commands/channel.ts +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -1,3 +1,4 @@ +import { select } from '@inquirer/prompts'; import { Command, InvalidArgumentError, Option } from 'commander'; import type Conf from 'conf'; import { colorize } from 'consola/utils'; @@ -200,17 +201,15 @@ Examples: 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), + channelId = await select({ + message: 'Which channel would you like to link?', + choices: sortChannelsByPlatform(channels).map((c) => ({ + name: c.name, + value: c.id, + description: channelPlatformLabel(c.platform), })), - cancel: 'reject', }); - channelId = Number(selected); channelName = channels.find((c) => c.id === channelId)?.name; } @@ -241,7 +240,7 @@ Examples: 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`); + consola.log(`Next steps:\n\n ${colorize('yellow', 'pnpm run dev')}`); process.exit(0); }); diff --git a/packages/catalyst/src/cli/commands/create.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts index 5f0cba5445..70344f62b2 100644 --- a/packages/catalyst/src/cli/commands/create.spec.ts +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -22,6 +22,7 @@ import { create } from './create'; vi.mock('child_process', () => ({ execSync: vi.fn() })); vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), input: vi.fn(), select: vi.fn(), })); diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts index 297591df66..3d213f70ca 100644 --- a/packages/catalyst/src/cli/commands/create.ts +++ b/packages/catalyst/src/cli/commands/create.ts @@ -1,5 +1,5 @@ import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings'; -import { input, select } from '@inquirer/prompts'; +import { checkbox, input, select } from '@inquirer/prompts'; import { execSync } from 'child_process'; import { colorize } from 'consola/utils'; import { pathExistsSync } from 'fs-extra/esm'; @@ -100,30 +100,15 @@ async function handleChannelCreation( let additionalLocales: string[] = []; if (shouldAddAdditionalLocales) { - const localeOptions = availableLocales + const localeChoices = availableLocales .filter(({ value }) => value !== storefrontLocale) - .map(({ name, value }) => ({ label: name, value, hint: value })); - - // consola's multiselect returns the value strings at runtime, but its typed - // return is loose (the whole option array). Recursion + cast avoids the - // no-await-in-loop / no-constant-condition lint hits and re-prompts on overflow. - const pickLocales = async (): Promise => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const selected = (await consola.prompt( - 'Which additional languages would you like to add to your channel?', - { type: 'multiselect', options: localeOptions, cancel: 'reject' }, - )) as unknown as string[]; - - if (selected.length > 4) { - consola.warn('You can only select up to 4 additional languages. Please try again.'); - - return pickLocales(); - } - - return selected; - }; + .map(({ name, value }) => ({ name, value, description: value })); - additionalLocales = await pickLocales(); + additionalLocales = await checkbox({ + message: 'Which additional languages would you like to add to your channel?', + choices: localeChoices, + validate: (items) => items.length <= 4 || 'You can only select up to 4 additional languages.', + }); } const shouldInstallSampleData = await select({ @@ -487,15 +472,16 @@ Examples: } consola.success(`Created '${projectName}' at '${projectDir}'`); - consola.info('Next steps:'); - consola.info(colorize('yellow', ` cd ${projectName}/core && pnpm run dev`)); + + const steps = [`cd ${projectName}/core && pnpm run dev`]; if (useCommerceHosting) { - consola.info( - colorize( - 'yellow', - ` Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`, - ), + steps.push( + `Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`, ); } + + consola.log( + `Next steps:\n\n${steps.map((step) => ` ${colorize('yellow', step)}`).join('\n')}`, + ); }); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 431fe9c30b..ea060352da 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -1,3 +1,4 @@ +import { confirm, input, select } from '@inquirer/prompts'; import AdmZip from 'adm-zip'; import { Command } from 'commander'; import { http, HttpResponse } from 'msw'; @@ -36,6 +37,12 @@ import { uploadBundleZip, } from './deploy'; +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + input: vi.fn(), + select: vi.fn(), +})); + // eslint-disable-next-line import/dynamic-import-chunkname vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); vi.mock('./build', async (importOriginal) => { @@ -111,7 +118,9 @@ beforeAll(async () => { beforeEach(() => { process.chdir(tmpDir); - vi.spyOn(consola, 'prompt').mockResolvedValue(true); + // Default the transformation-guard confirm to "yes" so tests that don't + // exercise it proceed past the guard. + vi.mocked(confirm).mockResolvedValue(true); }); afterEach(() => { @@ -350,9 +359,8 @@ describe('linked project verification', () => { config.set('storeHash', storeHash); config.set('accessToken', accessToken); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(projectUuid)); + // The project picker is a select that returns the chosen project UUID. + vi.mocked(select).mockResolvedValue(projectUuid); await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); @@ -362,8 +370,6 @@ describe('linked project verification', () => { expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); expect(config.get('projectUuid')).toBe(projectUuid); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('prompts for a project when none is linked yet', async () => { @@ -373,9 +379,8 @@ describe('linked project verification', () => { config.set('storeHash', storeHash); config.set('accessToken', accessToken); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(projectUuid)); + // The project picker is a select that returns the chosen project UUID. + vi.mocked(select).mockResolvedValue(projectUuid); await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); @@ -383,8 +388,6 @@ describe('linked project verification', () => { expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); expect(config.get('projectUuid')).toBe(projectUuid); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('offers to create when no projects exist on the store', async () => { @@ -400,24 +403,24 @@ describe('linked project verification', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message) => { - expect(message).toContain('There are not any hosting projects that you can link to yet'); - - return Promise.resolve(true); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return Promise.resolve('My New Project'); - }); + // No projects exist → a confirm ("create one?") then an input (project name). + // The transformation-guard confirm defaults to true via beforeEach. + vi.mocked(input).mockResolvedValue('My New Project'); await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + 'There are not any hosting projects that you can link to yet', + ), + }), + ); + expect(input).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('exits gracefully with guidance when user declines to create', async () => { @@ -433,9 +436,8 @@ describe('linked project verification', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + // Declining the "create one?" confirm throws NoLinkedProjectError. + vi.mocked(confirm).mockResolvedValue(false); await expect(program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run'])).rejects.toThrow( 'No infrastructure project linked', @@ -445,8 +447,6 @@ describe('linked project verification', () => { "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", ); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); }); @@ -756,9 +756,11 @@ describe('transformation guard', () => { '--dry-run', ]); - expect(consola.prompt).toHaveBeenCalledWith( - expect.stringContaining('not yet set up for Commerce Hosting deployments'), - expect.objectContaining({ type: 'confirm' }), + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining('not yet set up for Commerce Hosting deployments'), + }), ); expect(setupCommerceHosting).toHaveBeenCalledWith({ projectDir: dirname(tmpDir), @@ -771,7 +773,7 @@ describe('transformation guard', () => { test('exits gracefully when user declines to run setup', async () => { vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); - vi.mocked(consola.prompt).mockResolvedValueOnce(false); + vi.mocked(confirm).mockResolvedValueOnce(false); // In production, process.exit halts. In tests it's mocked, so we can only // verify the user-visible signals: the guidance log and the exit code. @@ -860,10 +862,10 @@ describe('--update-site-url', () => { ), ); - // Override the default consola.prompt stub (always-true in beforeEach) with - // the channel and hostname selections the interactive flow will ask for. - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') + // The interactive flow asks two selects in order: the channel (returns the + // numeric channel id) then the hostname (returns the hostname string). + vi.mocked(select) + .mockResolvedValueOnce(2) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await program.parseAsync(deployArgs(['--update-site-url'])); @@ -876,7 +878,7 @@ describe('--update-site-url', () => { }); test('places the freshly-deployed hostname first in the hostname prompt', async () => { - let hostnameOptions: Array<{ label: string; value: string }> | undefined; + let hostnameChoices: Array<{ name: string; value: string }> | undefined; // Project Two has no hostnames by default; use Project One whose handler // already returns the two seeded hostnames. Inject the freshly-deployed @@ -898,18 +900,20 @@ describe('--update-site-url', () => { ), ); - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') // channel - .mockImplementationOnce((_message, opts) => { + vi.mocked(select) + .mockResolvedValueOnce(2) // channel + .mockImplementationOnce((config) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + const cfg = config as unknown as { choices: Array<{ name: string; value: string }> }; + + hostnameChoices = cfg.choices; - return Promise.resolve('example.com'); + return Object.assign(Promise.resolve('example.com'), { cancel: () => undefined }); }); await program.parseAsync(deployArgs(['--update-site-url'])); - expect(hostnameOptions?.[0]).toMatchObject({ value: 'example.com' }); + expect(hostnameChoices?.[0]).toMatchObject({ value: 'example.com' }); }); test('does not call the channel site API when the flag is omitted', async () => { @@ -936,8 +940,8 @@ describe('--update-site-url', () => { ), ); - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') + vi.mocked(select) + .mockResolvedValueOnce(2) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await program.parseAsync(deployArgs(['--update-site-url'])); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 7cc6a2668c..2474a3601a 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -1,3 +1,4 @@ +import { confirm } from '@inquirer/prompts'; import AdmZip from 'adm-zip'; import { Command, Option } from 'commander'; import { colorize } from 'consola/utils'; @@ -466,10 +467,11 @@ Example: // here so first-run `catalyst deploy` works on a fresh self-hosted scaffold // without forcing the user to re-run after a separate setup step. if (!getProjectState().isTransformed) { - const shouldSetup = await consola.prompt( - 'Your project is not yet set up for Commerce Hosting deployments. Would you like to run the Commerce Hosting setup now?', - { type: 'confirm', initial: true }, - ); + const shouldSetup = await confirm({ + message: + 'Your project is not yet set up for Commerce Hosting deployments. Would you like to run the Commerce Hosting setup now?', + default: true, + }); if (!shouldSetup) { consola.info("When you're ready to deploy, re-run `catalyst deploy` to complete setup."); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 3822261cf8..a1bde9383e 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -1,4 +1,4 @@ -import { password } from '@inquirer/prompts'; +import { confirm, input, password, select } from '@inquirer/prompts'; import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; @@ -30,6 +30,9 @@ vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); vi.mock('@inquirer/prompts', () => ({ password: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), + select: vi.fn(), })); vi.mock('../lib/project-state', () => ({ @@ -41,6 +44,9 @@ vi.mock('../lib/install-dependencies', () => ({ })); const passwordMock = vi.mocked(password); +const confirmMock = vi.mocked(confirm); +const inputMock = vi.mocked(input); +const selectMock = vi.mocked(select); vi.mock('../lib/commerce-hosting', async (importOriginal) => { const actual = await importOriginal(); @@ -180,7 +186,7 @@ describe('project', () => { describe('project create', () => { test('prompts for name and creates project', async () => { - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('My New Project'); + inputMock.mockResolvedValue('My New Project'); await program.parseAsync([ 'node', @@ -194,9 +200,8 @@ describe('project create', () => { ]); expect(mockIdentify).toHaveBeenCalledWith(storeHash); - expect(consolaPromptMock).toHaveBeenCalledWith( - 'Enter a name for the new project:', - expect.any(Object), + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), ); expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); expect(consola.start).toHaveBeenCalledWith( @@ -210,8 +215,6 @@ describe('project create', () => { expect(config.get('projectUuid')).toBe('c23f5785-fd99-4a94-9fb3-945551623925'); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('runs interactive login when no credentials are provided', async () => { @@ -221,7 +224,7 @@ describe('project create', () => { delete process.env.CATALYST_STORE_HASH; delete process.env.CATALYST_ACCESS_TOKEN; - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('My New Project'); + inputMock.mockResolvedValueOnce('My New Project'); await program.parseAsync(['node', 'catalyst', 'project', 'create']); @@ -238,8 +241,6 @@ describe('project create', () => { expect(config.get('storeHash')).toBe('mock-store-hash'); expect(config.get('accessToken')).toBe('mock-access-token'); expect(config.get('projectUuid')).toBe(projectUuid3); - - consolaPromptMock.mockRestore(); }); test('falls back to manual login when the device flow fails', async () => { @@ -258,11 +259,10 @@ describe('project create', () => { passwordMock.mockResolvedValueOnce(accessToken); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(storeHash) - .mockResolvedValueOnce('My New Project'); + // Manual-login fallback: confirm() to retry manually, input() for the store + // hash, password() for the token, then input() for the new project name. + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce(storeHash).mockResolvedValueOnce('My New Project'); await program.parseAsync(['node', 'catalyst', 'project', 'create']); @@ -276,8 +276,6 @@ describe('project create', () => { expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('exits cleanly when the user aborts the manual login fallback', async () => { @@ -294,7 +292,8 @@ describe('project create', () => { ), ); - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + // Decline the manual-login fallback confirm() — aborts cleanly. + confirmMock.mockResolvedValueOnce(false); await program.parseAsync(['node', 'catalyst', 'project', 'create']); @@ -306,8 +305,6 @@ describe('project create', () => { ); expect(exitMock).toHaveBeenCalledWith(0); expect(config.get('projectUuid')).toBeUndefined(); - - consolaPromptMock.mockRestore(); }); test('propagates create project API errors', async () => { @@ -317,7 +314,7 @@ describe('project create', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('Duplicate'); + inputMock.mockResolvedValue('Duplicate'); await expect( program.parseAsync([ @@ -331,8 +328,6 @@ describe('project create', () => { accessToken, ]), ).rejects.toThrow('Failed to create project, is the name already in use?'); - - promptMock.mockRestore(); }); test('propagates 422 validation error', async () => { @@ -342,7 +337,7 @@ describe('project create', () => { ), ); - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValue('bad name'); + inputMock.mockResolvedValue('bad name'); await expect( program.parseAsync([ @@ -358,8 +353,6 @@ describe('project create', () => { ).rejects.toThrow( "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", ); - - promptMock.mockRestore(); }); }); @@ -379,15 +372,17 @@ describe('project list', () => { expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - expect(consola.log).toHaveBeenCalledWith('Project One (a23f5785-fd99-4a94-9fb3-945551623923)'); - expect(consola.log).toHaveBeenCalledWith( - expect.stringContaining('https://project-one.catalyst-sandbox.store'), - ); - expect(consola.log).toHaveBeenCalledWith( - expect.stringContaining('https://vanity.project-one.example.com'), - ); - expect(consola.log).toHaveBeenCalledWith('Project Two (b23f5785-fd99-4a94-9fb3-945551623924)'); - expect(consola.log).toHaveBeenCalledWith(' (not deployed)'); + + const output = vi + .mocked(consola.log) + .mock.calls.map(([msg]) => String(msg)) + .join('\n'); + + expect(output).toContain('Project One (a23f5785-fd99-4a94-9fb3-945551623923)'); + expect(output).toContain('https://project-one.catalyst-sandbox.store'); + expect(output).toContain('https://vanity.project-one.example.com'); + expect(output).toContain('Project Two (b23f5785-fd99-4a94-9fb3-945551623924)'); + expect(output).toContain('(not deployed)'); expect(exitMock).toHaveBeenCalledWith(0); }); @@ -405,13 +400,17 @@ describe('project list', () => { accessToken, ]); - const logCalls = vi.mocked(consola.log).mock.calls.map(([msg]) => String(msg)); + const blocks = vi + .mocked(consola.log) + .mock.calls.map(([msg]) => String(msg)) + .join('\n') + .split('\n\n'); - const linkedLine = logCalls.find((line) => line.includes(projectUuid2)); - const otherLine = logCalls.find((line) => line.includes(projectUuid1)); + const linkedBlock = blocks.find((block) => block.includes(projectUuid2)); + const otherBlock = blocks.find((block) => block.includes(projectUuid1)); - expect(linkedLine).toContain('[linked]'); - expect(otherLine).not.toContain('[linked]'); + expect(linkedBlock).toContain('[linked]'); + expect(otherBlock).not.toContain('[linked]'); }); test('does not mark any project when nothing is linked', async () => { @@ -515,26 +514,7 @@ describe('project link', () => { }); test('fetches projects and prompts user to select one', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async (message, opts) => { - 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' }); - - return new Promise((resolve) => resolve(projectUuid2)); - }); + selectMock.mockResolvedValueOnce(projectUuid2); await program.parseAsync([ 'node', @@ -547,6 +527,22 @@ describe('project link', () => { accessToken, ]); + expect(selectMock).toHaveBeenCalledTimes(1); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); @@ -564,36 +560,11 @@ describe('project link', () => { expect(config.get('projectUuid')).toBe(projectUuid2); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('prompts to create a new project', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - 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' }); - - 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')); - }); + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('New Project'); await program.parseAsync([ 'node', @@ -606,6 +577,24 @@ describe('project link', () => { accessToken, ]); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); @@ -618,8 +607,6 @@ describe('project link', () => { expect(config.get('projectUuid')).toBe(projectUuid3); expect(config.get('storeHash')).toBe(storeHash); expect(config.get('accessToken')).toBe(accessToken); - - consolaPromptMock.mockRestore(); }); test('errors when create project API fails', async () => { @@ -629,31 +616,8 @@ describe('project link', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - 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' }); - - 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')); - }); + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('New Project'); await expect( program.parseAsync([ @@ -668,12 +632,28 @@ describe('project link', () => { ]), ).rejects.toThrow('Failed to create project, is the name already in use?'); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - - consolaPromptMock.mockRestore(); }); test('errors when create project returns 422 validation error', async () => { @@ -683,31 +663,8 @@ describe('project link', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - 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' }); - - return new Promise((resolve) => resolve('create')); - }) - .mockImplementationOnce(async (message) => { - expect(message).toBe('Enter a name for the new project:'); - - return new Promise((resolve) => resolve('bad name')); - }); + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('bad name'); await expect( program.parseAsync([ @@ -724,31 +681,34 @@ describe('project link', () => { "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", ); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain( + 'Select a project or create a new project (Press to select).', + ); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Create a new project', value: 'create' }); + + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); - - consolaPromptMock.mockRestore(); }); test('marks the currently linked project with [linked] in the select prompt', async () => { config.set('projectUuid', projectUuid2); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - const linkedOption = options.find((o) => o.value === projectUuid2); - const otherOption = options.find((o) => o.value === projectUuid1); - - expect(linkedOption?.label).toContain('[linked]'); - expect(otherOption?.label).not.toContain('[linked]'); - - return Promise.resolve(projectUuid2); - }); + selectMock.mockResolvedValueOnce(projectUuid2); await program.parseAsync([ 'node', @@ -761,7 +721,16 @@ describe('project link', () => { accessToken, ]); - consolaPromptMock.mockRestore(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as unknown as { + choices: Array<{ name: string; value: string }>; + }; + + const linkedOption = selectArgs.choices.find((o) => o.value === projectUuid2); + const otherOption = selectArgs.choices.find((o) => o.value === projectUuid1); + + expect(linkedOption?.name).toContain('[linked]'); + expect(otherOption?.name).not.toContain('[linked]'); }); test('exits gracefully with guidance when user declines to create from empty list', async () => { @@ -771,9 +740,8 @@ describe('project link', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + // Empty list → confirm() to create; declining throws NoLinkedProjectError. + confirmMock.mockResolvedValue(false); await expect( program.parseAsync([ @@ -792,8 +760,6 @@ describe('project link', () => { "When you're ready to create a project, run `catalyst project create` or re-run `catalyst project link`.", ); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('errors when infrastructure projects API is not found', async () => { @@ -825,8 +791,6 @@ describe('project link', () => { describe('post-link Commerce Hosting setup prompt', () => { test('does not prompt when project is already transformed', async () => { - const consolaPromptMock = vi.spyOn(consola, 'prompt'); - vi.mocked(getProjectState).mockReturnValue(transformedState); await program.parseAsync([ @@ -838,23 +802,17 @@ describe('project link', () => { projectUuid1, ]); - const promptMessages = consolaPromptMock.mock.calls.map(([msg]) => msg); + const confirmMessages = confirmMock.mock.calls.map(([opts]) => opts.message); - expect(promptMessages).not.toContain( + expect(confirmMessages).not.toContainEqual( expect.stringContaining('not fully set up for Commerce Hosting'), ); - - consolaPromptMock.mockRestore(); }); test('prompts and runs setup when user accepts', async () => { vi.mocked(getProjectState).mockReturnValue(untransformedState); - const consolaPromptMock = vi.spyOn(consola, 'prompt').mockImplementation(async (message) => { - expect(message).toContain('not fully set up for Commerce Hosting'); - - return Promise.resolve(true); - }); + confirmMock.mockResolvedValue(true); await program.parseAsync([ 'node', @@ -865,20 +823,21 @@ describe('project link', () => { projectUuid1, ]); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(confirmMock.mock.calls[0][0].message).toContain( + 'not fully set up for Commerce Hosting', + ); + expect(setupCommerceHosting).toHaveBeenCalledWith( expect.objectContaining({ projectUuid: projectUuid1 }), ); expect(installDependencies).toHaveBeenCalled(); - - consolaPromptMock.mockRestore(); }); test('skips setup when user declines', async () => { vi.mocked(getProjectState).mockReturnValue(untransformedState); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + confirmMock.mockResolvedValue(false); await program.parseAsync([ 'node', @@ -891,8 +850,6 @@ describe('project link', () => { expect(setupCommerceHosting).not.toHaveBeenCalled(); expect(installDependencies).not.toHaveBeenCalled(); - - consolaPromptMock.mockRestore(); }); }); @@ -914,8 +871,6 @@ describe('project link', () => { describe('project delete', () => { test('with --project-uuid and --force deletes without prompting', async () => { - const consolaPromptMock = vi.spyOn(consola, 'prompt'); - await program.parseAsync([ 'node', 'catalyst', @@ -930,26 +885,16 @@ describe('project delete', () => { accessToken, ]); - expect(consolaPromptMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith(`Deleting project ${projectUuid1}...`); expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('with --project-uuid prompts for confirmation and deletes on accept', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async (message, opts) => { - expect(message).toContain('Are you sure you want to delete project'); - expect(message).toContain(projectUuid1); - expect(message).toContain('irreversible'); - expect(opts).toMatchObject({ type: 'confirm' }); - - return Promise.resolve(true); - }); + confirmMock.mockResolvedValue(true); await program.parseAsync([ 'node', @@ -964,11 +909,17 @@ describe('project delete', () => { accessToken, ]); - expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + + const confirmArgs = confirmMock.mock.calls[0][0]; + + expect(confirmArgs.message).toContain('Are you sure you want to delete project'); + expect(confirmArgs.message).toContain(projectUuid1); + expect(confirmArgs.message).toContain('irreversible'); + expect(confirmArgs.default).toBe(false); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('aborts when user declines the confirmation prompt', async () => { @@ -985,9 +936,7 @@ describe('project delete', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve(false)); + confirmMock.mockResolvedValue(false); await program.parseAsync([ 'node', @@ -1005,32 +954,11 @@ describe('project delete', () => { expect(deleteRequested).toBe(false); expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('without --project-uuid fetches projects and prompts to select one', async () => { - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (message, opts) => { - expect(message).toContain('Select a project to delete'); - - // 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: 'Cancel', value: 'cancel' }); - - return Promise.resolve(projectUuid2); - }) - .mockImplementationOnce(async (message) => { - expect(message).toContain('"Project Two"'); - expect(message).toContain(projectUuid2); - - return Promise.resolve(true); - }); + selectMock.mockResolvedValueOnce(projectUuid2); + confirmMock.mockResolvedValueOnce(true); await program.parseAsync([ 'node', @@ -1043,32 +971,34 @@ describe('project delete', () => { accessToken, ]); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as { + message: string; + choices: Array<{ name: string; value: string }>; + }; + + expect(selectArgs.message).toContain('Select a project to delete'); + expect(selectArgs.choices).toHaveLength(3); + expect(selectArgs.choices[0]).toMatchObject({ name: 'Project One', value: projectUuid1 }); + expect(selectArgs.choices[1]).toMatchObject({ name: 'Project Two', value: projectUuid2 }); + expect(selectArgs.choices[2]).toMatchObject({ name: 'Cancel', value: 'cancel' }); + + const confirmArgs = confirmMock.mock.calls[0][0]; + + expect(confirmArgs.message).toContain('"Project Two"'); + expect(confirmArgs.message).toContain(projectUuid2); + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid2} deleted.`); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('marks the currently linked project with [linked] in the select prompt', async () => { config.set('projectUuid', projectUuid2); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementationOnce(async (_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const options = (opts as { options: Array<{ label: string; value: string }> }).options; - - const linkedOption = options.find((o) => o.value === projectUuid2); - const otherOption = options.find((o) => o.value === projectUuid1); - - expect(linkedOption?.label).toContain('[linked]'); - expect(otherOption?.label).not.toContain('[linked]'); - - return Promise.resolve(projectUuid1); - }) - .mockImplementationOnce(async () => Promise.resolve(true)); + selectMock.mockResolvedValueOnce(projectUuid1); + confirmMock.mockResolvedValueOnce(true); await program.parseAsync([ 'node', @@ -1081,7 +1011,16 @@ describe('project delete', () => { accessToken, ]); - consolaPromptMock.mockRestore(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selectArgs = selectMock.mock.calls[0][0] as unknown as { + choices: Array<{ name: string; value: string }>; + }; + + const linkedOption = selectArgs.choices.find((o) => o.value === projectUuid2); + const otherOption = selectArgs.choices.find((o) => o.value === projectUuid1); + + expect(linkedOption?.name).toContain('[linked]'); + expect(otherOption?.name).not.toContain('[linked]'); }); test('aborts when user selects Cancel from the project list', async () => { @@ -1098,9 +1037,7 @@ describe('project delete', () => { ), ); - const consolaPromptMock = vi - .spyOn(consola, 'prompt') - .mockImplementation(async () => Promise.resolve('cancel')); + selectMock.mockResolvedValue('cancel'); await program.parseAsync([ 'node', @@ -1113,12 +1050,11 @@ describe('project delete', () => { accessToken, ]); - expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); expect(deleteRequested).toBe(false); expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); expect(exitMock).toHaveBeenCalledWith(0); - - consolaPromptMock.mockRestore(); }); test('exits cleanly when there are no projects to delete', async () => { diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 7942d88fe7..5995ecc12b 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -1,3 +1,4 @@ +import { confirm, input, select } from '@inquirer/prompts'; import { Command } from 'commander'; import { colorize } from 'consola/utils'; import { dirname } from 'node:path'; @@ -42,10 +43,11 @@ async function offerCommerceHostingSetup( return; } - const shouldSetup = await consola.prompt( - 'Your project has been linked, but is not fully set up for Commerce Hosting deployments yet. Would you like to run the setup now?', - { type: 'confirm', initial: true }, - ); + const shouldSetup = await confirm({ + message: + 'Your project has been linked, but is not fully set up for Commerce Hosting deployments yet. Would you like to run the setup now?', + default: true, + }); if (!shouldSetup) return; @@ -94,22 +96,22 @@ Example: const linkedProjectUuid = config.get('projectUuid'); - projects.forEach((p) => { + const projectList = projects.map((p) => { const marker = p.uuid === linkedProjectUuid ? ` ${colorize('green', '[linked]')}` : ''; - - consola.log(`${p.name} (${p.uuid})${marker}`); - - if (p.deployment_hostnames.length === 0) { - consola.log(' (not deployed)'); - } else { - p.deployment_hostnames.forEach((hostname) => { - consola.log(` ${colorize('blue', `https://${hostname}`)}`); - }); - } - - consola.log(''); + const hostnames = + p.deployment_hostnames.length === 0 + ? [' (not deployed)'] + : p.deployment_hostnames.map( + (hostname) => ` ${colorize('blue', `https://${hostname}`)}`, + ); + + return [`${p.name} (${p.uuid})${marker}`, ...hostnames].join('\n'); }); + // Single log call so projects are blank-line separated without each blank + // picking up a reporter timestamp. + consola.log(projectList.join('\n\n')); + process.exit(0); }); @@ -165,9 +167,7 @@ Example: await getTelemetry().identify(storeHash); - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); + const newProjectName = await input({ message: 'Enter a name for the new project:' }); const data = await createProject(newProjectName, storeHash, accessToken, options.apiHost); @@ -307,24 +307,18 @@ Examples: const linkedProjectUuid = config.get('projectUuid'); - const selected = await consola.prompt( - 'Select a project to delete (Press to select).', - { - type: 'select', - options: [ - ...projects.map((p) => ({ - label: - p.uuid === linkedProjectUuid - ? `${p.name} ${colorize('green', '[linked]')}` - : p.name, - value: p.uuid, - hint: p.uuid, - })), - { label: 'Cancel', value: 'cancel', hint: 'Exit without deleting any project.' }, - ], - cancel: 'reject', - }, - ); + const selected = await select({ + message: 'Select a project to delete (Press to select).', + choices: [ + ...projects.map((p) => ({ + name: + p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, + value: p.uuid, + description: p.uuid, + })), + { name: 'Cancel', value: 'cancel', description: 'Exit without deleting any project.' }, + ], + }); if (selected === 'cancel') { consola.info('Aborted. No project was deleted.'); @@ -346,10 +340,10 @@ Examples: if (!options.force) { const label = targetName ? `"${targetName}" (${targetUuid})` : targetUuid; - const confirmed = await consola.prompt( - `Are you sure you want to delete project ${label}? This action is irreversible and will permanently destroy the project and all of its data.`, - { type: 'confirm', initial: false }, - ); + const confirmed = await confirm({ + message: `Are you sure you want to delete project ${label}? This action is irreversible and will permanently destroy the project and all of its data.`, + default: false, + }); if (!confirmed) { consola.info('Aborted. No project was deleted.'); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts index 67ae9b3724..8a8beba66d 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -1,3 +1,4 @@ +import { confirm, input, select } from '@inquirer/prompts'; import { http, HttpResponse } from 'msw'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -7,6 +8,16 @@ import { runChannelSiteUrlFlow } from './channel-site-flow'; import { NoLinkedProjectError } from './commerce-hosting'; import { consola } from './logger'; +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), +})); + +const selectMock = vi.mocked(select); +const confirmMock = vi.mocked(confirm); +const inputMock = vi.mocked(input); + const storeHash = 'test-store'; const accessToken = 'test-token'; const apiHost = 'api.bigcommerce.com'; @@ -67,11 +78,10 @@ describe('runChannelSiteUrlFlow', () => { ), ); - const promptMock = vi - .spyOn(consola, 'prompt') - // First prompt — channel select; resolve with channel id (as string) - .mockResolvedValueOnce('2') - // Second prompt — hostname select + selectMock + // First select — channel select; resolves with the channel id (a number) + .mockResolvedValueOnce(2) + // Second select — hostname select (a string) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await runChannelSiteUrlFlow({ @@ -81,7 +91,7 @@ describe('runChannelSiteUrlFlow', () => { projectUuid: linkedProjectUuid, }); - expect(promptMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalledTimes(2); expect(putChannelId).toBe('2'); expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); expect(consola.success).toHaveBeenCalledWith( @@ -90,9 +100,7 @@ describe('runChannelSiteUrlFlow', () => { }); test('--channel-id short-circuits the channel prompt', async () => { - const promptMock = vi - .spyOn(consola, 'prompt') - .mockResolvedValueOnce('vanity.project-one.example.com'); + selectMock.mockResolvedValueOnce('vanity.project-one.example.com'); await runChannelSiteUrlFlow({ storeHash, @@ -102,16 +110,15 @@ describe('runChannelSiteUrlFlow', () => { channelId: 99, }); - // Only the hostname prompt fires - expect(promptMock).toHaveBeenCalledTimes(1); - expect(promptMock).toHaveBeenCalledWith( - 'Select the hostname to point the channel at.', - expect.any(Object), + // Only the hostname select fires + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select the hostname to point the channel at.' }), ); }); test('--hostname short-circuits the hostname prompt', async () => { - const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + selectMock.mockResolvedValueOnce(2); await runChannelSiteUrlFlow({ storeHash, @@ -121,16 +128,16 @@ describe('runChannelSiteUrlFlow', () => { hostname: 'manual.example.com', }); - expect(promptMock).toHaveBeenCalledTimes(1); - expect(promptMock).toHaveBeenCalledWith('Select the channel to update.', expect.any(Object)); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select the channel to update.' }), + ); expect(consola.success).toHaveBeenCalledWith( expect.stringContaining('https://manual.example.com'), ); }); test('both overrides skip all prompts', async () => { - const promptMock = vi.spyOn(consola, 'prompt'); - await runChannelSiteUrlFlow({ storeHash, accessToken, @@ -140,23 +147,18 @@ describe('runChannelSiteUrlFlow', () => { hostname: 'auto.example.com', }); - expect(promptMock).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); expect(consola.success).toHaveBeenCalledWith( expect.stringContaining('Updated channel 42 site URL to https://auto.example.com.'), ); }); test('preferHostname is placed first in the hostname options', async () => { - let hostnameOptions: Array<{ label: string; value: string }> | undefined; - - vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('2') // channel (the catalyst one) - .mockImplementationOnce((_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; - - return Promise.resolve('vanity.project-one.example.com'); - }); + selectMock + .mockResolvedValueOnce(2) // channel (the catalyst one) + .mockResolvedValueOnce('vanity.project-one.example.com'); // hostname await runChannelSiteUrlFlow({ storeHash, @@ -166,7 +168,12 @@ describe('runChannelSiteUrlFlow', () => { preferHostname: 'vanity.project-one.example.com', }); - expect(hostnameOptions?.[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); + // The hostname select is the second call; inspect the choices passed to it. + const hostnameCall = selectMock.mock.calls[1][0]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const hostnameChoices = hostnameCall.choices as Array<{ name: string; value: string }>; + + expect(hostnameChoices[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); }); test('throws when no Catalyst channels are available', async () => { @@ -190,16 +197,9 @@ describe('runChannelSiteUrlFlow', () => { }); test('filters non-Catalyst channels out of the picker', async () => { - let channelOptions: Array<{ label: string; value: string }> | undefined; - - vi.spyOn(consola, 'prompt') - .mockImplementationOnce((_message, opts) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - channelOptions = (opts as { options: Array<{ label: string; value: string }> }).options; - - return Promise.resolve('2'); - }) - .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + selectMock + .mockResolvedValueOnce(2) // channel + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); // hostname await runChannelSiteUrlFlow({ storeHash, @@ -208,17 +208,22 @@ describe('runChannelSiteUrlFlow', () => { projectUuid: linkedProjectUuid, }); + // The channel select is the first call; inspect the choices passed to it. + const channelCall = selectMock.mock.calls[0][0]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const channelChoices = channelCall.choices as Array<{ name: string; value: number }>; + // The default handler returns one bigcommerce + one catalyst channel; only // the catalyst one should appear in the picker. - expect(channelOptions).toHaveLength(1); - expect(channelOptions?.[0]).toMatchObject({ label: 'Catalyst Storefront', value: '2' }); + expect(channelChoices).toHaveLength(1); + expect(channelChoices[0]).toMatchObject({ name: 'Catalyst Storefront', value: 2 }); }); test('throws when the project has no deployment hostnames', async () => { // Project Two in the default handler has deployment_hostnames: [] const projectTwo = 'b23f5785-fd99-4a94-9fb3-945551623924'; - vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + selectMock.mockResolvedValueOnce(2); await expect( runChannelSiteUrlFlow({ @@ -249,12 +254,12 @@ describe('runChannelSiteUrlFlow', () => { }), ); - vi.spyOn(consola, 'prompt') - // selectOrCreateInfrastructureProject prompt — pick the only project + selectMock + // selectOrCreateInfrastructureProject select — pick the only project (UUID string) .mockResolvedValueOnce(linkedProjectUuid) - // channel - .mockResolvedValueOnce('2') - // hostname + // channel (number) + .mockResolvedValueOnce(2) + // hostname (string) .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost }); @@ -273,7 +278,7 @@ describe('runChannelSiteUrlFlow', () => { ); // The "create a new project?" confirm prompt — user says no - vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); await expect(runChannelSiteUrlFlow({ storeHash, accessToken, apiHost })).rejects.toBeInstanceOf( NoLinkedProjectError, @@ -295,12 +300,12 @@ describe('runChannelSiteUrlFlow', () => { ), ); - vi.spyOn(consola, 'prompt') - // project picker + selectMock + // project picker (UUID string) .mockResolvedValueOnce('different-uuid') - // channel - .mockResolvedValueOnce('1') - // hostname + // channel (number) + .mockResolvedValueOnce(1) + // hostname (string) .mockResolvedValueOnce('other.example.com'); await runChannelSiteUrlFlow({ diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index 01fb552973..5d8a41dff7 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -1,3 +1,5 @@ +import { select } from '@inquirer/prompts'; + import { type Channel, fetchAvailableChannels, updateChannelSiteUrl } from './channels'; import { selectOrCreateInfrastructureProject } from './commerce-hosting'; import { consola } from './logger'; @@ -71,17 +73,15 @@ async function resolveChannel( ); } - const selectedId = await consola.prompt('Select the channel to update.', { - type: 'select', - options: catalystChannels.map((c: Channel) => ({ - label: c.name, - value: String(c.id), - hint: `id: ${c.id}`, + const id = await select({ + message: 'Select the channel to update.', + choices: catalystChannels.map((c: Channel) => ({ + name: c.name, + value: c.id, + description: `id: ${c.id}`, })), - cancel: 'reject', }); - const id = Number(selectedId); const matched = catalystChannels.find((c) => c.id === id); return { id, name: matched?.name }; @@ -111,13 +111,12 @@ async function resolveHostname( ] : project.deployment_hostnames; - const selected = await consola.prompt('Select the hostname to point the channel at.', { - type: 'select', - options: ordered.map((h) => ({ label: h, value: h })), - cancel: 'reject', + const selected = await select({ + message: 'Select the hostname to point the channel at.', + choices: ordered.map((h) => ({ name: h, value: h })), }); - return String(selected); + return selected; } export async function runChannelSiteUrlFlow(options: ChannelSiteFlowOptions): Promise { diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts index 0a0c85324d..7ae46b8b86 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts @@ -1,4 +1,4 @@ -import { input, select } from '@inquirer/prompts'; +import { confirm, input, select } from '@inquirer/prompts'; import { existsSync, lstatSync, @@ -20,11 +20,11 @@ import { promptForCommerceHostingProject, setupCommerceHosting, } from './commerce-hosting'; -import { consola } from './logger'; import * as projectLib from './project'; import { InfrastructureProjectValidationError } from './project'; vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), input: vi.fn(), select: vi.fn(), Separator: class FakeSeparator { @@ -32,6 +32,7 @@ vi.mock('@inquirer/prompts', () => ({ }, })); +const confirmMock = vi.mocked(confirm); const inputMock = vi.mocked(input); const selectMock = vi.mocked(select); @@ -59,6 +60,7 @@ let createProjectSpy: MockInstance; let consoleErrorSpy: MockInstance<(typeof console)['error']>; beforeEach(() => { + confirmMock.mockReset(); inputMock.mockReset(); selectMock.mockReset(); fetchProjectsSpy = vi.spyOn(projectLib, 'fetchProjects').mockResolvedValue([]); @@ -578,19 +580,16 @@ describe('setupCommerceHosting', () => { let projectDir: string; let restoreTty: () => void; - let consolaPromptSpy: MockInstance; beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'catalyst-create-test-')); restoreTty = withInteractiveTty(); - consolaPromptSpy = vi.spyOn(consola, 'prompt'); - consolaPromptSpy.mockResolvedValue(true); + confirmMock.mockResolvedValue(true); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); restoreTty(); - consolaPromptSpy.mockRestore(); }); function writeCorePackageJson(contents: unknown) { @@ -676,7 +675,7 @@ describe('setupCommerceHosting', () => { const pkg = readCorePackageJson(); expect(pkg.dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); }); it('does not modify package.json scripts (handled by setupCoreProject)', async () => { @@ -881,7 +880,7 @@ describe('setupCommerceHosting', () => { }); it('preserves core/instrumentation.ts when the user declines the prompt', async () => { - consolaPromptSpy.mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); writeCorePackageJson({ scripts: { dev: 'next dev' }, @@ -899,7 +898,7 @@ describe('setupCommerceHosting', () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).resolves.not.toThrow(); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); }); }); }); @@ -909,19 +908,16 @@ describe('cleanupCloudflareIncompatibilities', () => { let projectDir: string; let restoreTty: () => void; - let consolaPromptSpy: MockInstance; beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'catalyst-cleanup-test-')); restoreTty = withInteractiveTty(); - consolaPromptSpy = vi.spyOn(consola, 'prompt'); - consolaPromptSpy.mockResolvedValue(true); + confirmMock.mockResolvedValue(true); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); restoreTty(); - consolaPromptSpy.mockRestore(); }); function writeCorePackageJson(contents: unknown) { @@ -958,7 +954,7 @@ describe('cleanupCloudflareIncompatibilities', () => { }); it('leaves the file and the dep alone when the user declines (TTY)', async () => { - consolaPromptSpy.mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); writeCorePackageJson({ dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' }, @@ -982,7 +978,7 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); }); @@ -992,7 +988,7 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); - expect(consolaPromptSpy).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); }); diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.ts b/packages/catalyst/src/cli/lib/commerce-hosting.ts index 8dac968c37..161b8aec26 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.ts @@ -1,4 +1,4 @@ -import { input, select, Separator } from '@inquirer/prompts'; +import { confirm, input, select, Separator } from '@inquirer/prompts'; import { colorize } from 'consola/utils'; import { existsSync, @@ -89,14 +89,13 @@ export const cleanupCloudflareIncompatibilities = async (projectDir: string) => return; } - const shouldRemove = await consola.prompt( - 'Catalyst found core/instrumentation.ts, which is incompatible with the Cloudflare Workers ' + + const shouldRemove = await confirm({ + message: + 'Catalyst found core/instrumentation.ts, which is incompatible with the Cloudflare Workers ' + 'bundle when it uses @vercel/otel (causes "Failed to prepare server" at cold start). ' + 'Remove it and drop @vercel/otel from core/package.json?', - { type: 'confirm', initial: true }, - ); - - consola.log(''); + default: true, + }); if (!shouldRemove) { consola.info( @@ -180,16 +179,9 @@ export class NoLinkedProjectError extends Error { } async function promptForNewProjectName(api: CommerceHostingApiContext): Promise { - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); + const newProjectName = await input({ message: 'Enter a name for the new project:' }); - const data = await createProject( - String(newProjectName), - api.storeHash, - api.accessToken, - api.apiHost, - ); + const data = await createProject(newProjectName, api.storeHash, api.accessToken, api.apiHost); consola.success(`Project "${data.name}" created successfully.`); @@ -217,10 +209,11 @@ export async function selectOrCreateInfrastructureProject( // No existing projects on the store — skip the select prompt and offer // creation directly. Declining means we have nothing to link to. if (existingProjects.length === 0) { - const shouldCreate = await consola.prompt( - 'There are not any hosting projects that you can link to yet. Would you like to create one?', - { type: 'confirm', initial: true }, - ); + const shouldCreate = await confirm({ + message: + 'There are not any hosting projects that you can link to yet. Would you like to create one?', + default: true, + }); if (!shouldCreate) { throw new NoLinkedProjectError(); @@ -229,23 +222,23 @@ export async function selectOrCreateInfrastructureProject( return promptForNewProjectName(api); } - const promptOptions = [ + const choices = [ ...existingProjects.map((p) => ({ - label: p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, + name: p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, value: p.uuid, - hint: p.uuid, + description: p.uuid, })), { - label: 'Create a new project', + name: 'Create a new project', value: 'create', - hint: 'Create a new hosting project for this Catalyst storefront.', + description: 'Create a new hosting project for this Catalyst storefront.', }, ]; - const selected = await consola.prompt( - 'Select a project or create a new project (Press to select).', - { type: 'select', options: promptOptions, cancel: 'reject' }, - ); + const selected = await select({ + message: 'Select a project or create a new project (Press to select).', + choices, + }); if (selected === 'create') { return promptForNewProjectName(api); diff --git a/packages/catalyst/src/cli/lib/login.ts b/packages/catalyst/src/cli/lib/login.ts index 4891111e7b..a4ffa35f16 100644 --- a/packages/catalyst/src/cli/lib/login.ts +++ b/packages/catalyst/src/cli/lib/login.ts @@ -1,4 +1,4 @@ -import { password } from '@inquirer/prompts'; +import { confirm, input, password } from '@inquirer/prompts'; import { colorize } from 'consola/utils'; import open from 'open'; import yoctoSpinner from 'yocto-spinner'; @@ -88,8 +88,8 @@ async function manualLogin(apiHost: string): Promise { `Grant these OAuth scopes: ${DEVICE_OAUTH_SCOPES}`, ); - const storeHashInput = await consola.prompt('Store hash:', { type: 'text' }); - const storeHash = String(storeHashInput).trim(); + const storeHashInput = await input({ message: 'Store hash:' }); + const storeHash = storeHashInput.trim(); if (!storeHash) { throw new Error('Store hash is required.'); @@ -138,10 +138,10 @@ export async function login(loginUrl: string, apiHost: string): Promise Date: Wed, 24 Jun 2026 11:45:54 -0500 Subject: [PATCH 76/90] chore: version @bigcommerce/catalyst@1.0.0-alpha.6 Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/pre.json | 1 + packages/catalyst/CHANGELOG.md | 6 ++++++ packages/catalyst/package.json | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index f891793b33..774bfd7797 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -12,6 +12,7 @@ "alpha-release-notes", "auto-detect-deploy-secrets", "cli-built-in-help-text", + "cli-dynamic-compat-date", "cli-show-env-path-in-help", "cli-strip-instrumentation-hook", "fix-env-var-names", diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md index 67961f8391..7aca6f92e9 100644 --- a/packages/catalyst/CHANGELOG.md +++ b/packages/catalyst/CHANGELOG.md @@ -1,5 +1,11 @@ # @bigcommerce/catalyst +## 1.0.0-alpha.6 + +### Patch Changes + +- [#3061](https://github.com/bigcommerce/catalyst/pull/3061) [`eea1355`](https://github.com/bigcommerce/catalyst/commit/eea135543423dc0d50d6bff68d93f1548e54e096) Thanks [@jorgemoya](https://github.com/jorgemoya)! - `catalyst build` now derives the Cloudflare Workers `compatibility_date` dynamically (current date minus one month) instead of using a pinned date, keeping the build-time runtime semantics aligned with what the deployment service applies at deploy time. + ## 1.0.0-alpha.5 ### Minor Changes diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index f9e001aaf5..e7b8f60350 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,6 +1,6 @@ { "name": "@bigcommerce/catalyst", - "version": "1.0.0-alpha.5", + "version": "1.0.0-alpha.6", "type": "module", "bin": { "catalyst": "dist/cli.js" From 8eeea2b865dbb7f7443a82d8a5a1c1f53ff2b5e4 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 25 Jun 2026 14:36:40 -0500 Subject: [PATCH 77/90] LTRAC-468: feat(cli) - Replace git clone with tarball extraction in create (#3061) Merchants now receive a clean standalone project instead of the full monorepo. `catalyst create` downloads the core/ subdirectory as a tarball from GitHub's codeload endpoint and stream-extracts it to the project root, resolves workspace: dependencies to their published npm versions, detects the invoking package manager (npm/pnpm/yarn/bun) from npm_config_user_agent, and initializes a fresh git repository. Previously create cloned the entire bigcommerce/catalyst monorepo (full history, packages/*, turbo) and built the workspace packages locally. The core/ contents are now flattened to the project root, so deploy/project/ commerce-hosting no longer assume a core/ subdirectory. Refs LTRAC-468 Co-authored-by: Claude Fable 5 --- .changeset/cli-tarball-extraction.md | 5 + packages/catalyst/package.json | 1 + .../catalyst/src/cli/commands/create.spec.ts | 57 +++++---- packages/catalyst/src/cli/commands/create.ts | 44 +++---- .../catalyst/src/cli/commands/deploy.spec.ts | 4 +- packages/catalyst/src/cli/commands/deploy.ts | 6 +- packages/catalyst/src/cli/commands/project.ts | 7 +- .../src/cli/lib/build-workspace-packages.ts | 33 ----- packages/catalyst/src/cli/lib/checkout-ref.ts | 42 ------- .../catalyst/src/cli/lib/clone-catalyst.ts | 46 ------- .../src/cli/lib/commerce-hosting.spec.ts | 113 ++++------------- .../catalyst/src/cli/lib/commerce-hosting.ts | 55 +++----- .../src/cli/lib/detect-package-manager.ts | 22 ++++ .../catalyst/src/cli/lib/extract-catalyst.ts | 90 +++++++++++++ .../catalyst/src/cli/lib/has-github-ssh.ts | 26 ---- .../catalyst/src/cli/lib/init-git-repo.ts | 23 ++++ .../src/cli/lib/install-dependencies.ts | 13 +- .../catalyst/src/cli/lib/is-exec-exception.ts | 5 - .../src/cli/lib/reset-branch-to-ref.ts | 15 --- .../src/cli/lib/rewrite-core-package.ts | 118 ++++++++++++++++++ .../src/cli/lib/setup-core-project.spec.ts | 11 +- .../src/cli/lib/setup-core-project.ts | 4 +- packages/catalyst/src/cli/lib/write-env.ts | 7 +- pnpm-lock.yaml | 112 ++++++++--------- 24 files changed, 430 insertions(+), 429 deletions(-) create mode 100644 .changeset/cli-tarball-extraction.md delete mode 100644 packages/catalyst/src/cli/lib/build-workspace-packages.ts delete mode 100644 packages/catalyst/src/cli/lib/checkout-ref.ts delete mode 100644 packages/catalyst/src/cli/lib/clone-catalyst.ts create mode 100644 packages/catalyst/src/cli/lib/detect-package-manager.ts create mode 100644 packages/catalyst/src/cli/lib/extract-catalyst.ts delete mode 100644 packages/catalyst/src/cli/lib/has-github-ssh.ts create mode 100644 packages/catalyst/src/cli/lib/init-git-repo.ts delete mode 100644 packages/catalyst/src/cli/lib/is-exec-exception.ts delete mode 100644 packages/catalyst/src/cli/lib/reset-branch-to-ref.ts create mode 100644 packages/catalyst/src/cli/lib/rewrite-core-package.ts diff --git a/.changeset/cli-tarball-extraction.md b/.changeset/cli-tarball-extraction.md new file mode 100644 index 0000000000..a59e5fa72f --- /dev/null +++ b/.changeset/cli-tarball-extraction.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Replace `git clone` with tarball extraction in `catalyst create`. Merchants now receive a clean standalone project (only `core/`, flattened to the project root) instead of the full monorepo: the tarball is downloaded from GitHub's codeload endpoint, `workspace:` dependencies are resolved to their published npm versions, and a fresh git repository is initialized. The package manager is detected from `npm_config_user_agent` (npm, pnpm, yarn, or bun) and used to install. diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index e7b8f60350..a78731543a 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -37,6 +37,7 @@ "nypm": "^0.5.4", "open": "^10.1.0", "std-env": "^3.9.0", + "tar": "^7.5.7", "yocto-spinner": "^1.0.0", "zod": "^4.0.5" }, diff --git a/packages/catalyst/src/cli/commands/create.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts index 70344f62b2..596d0df93e 100644 --- a/packages/catalyst/src/cli/commands/create.spec.ts +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -3,14 +3,16 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; -import { buildWorkspacePackages } from '../lib/build-workspace-packages'; -import { cloneCatalyst } from '../lib/clone-catalyst'; import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { detectPackageManager } from '../lib/detect-package-manager'; +import { extractCatalyst } from '../lib/extract-catalyst'; +import { initGitRepo } from '../lib/init-git-repo'; import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { login, LoginAbortedError } from '../lib/login'; import { mkTempDir } from '../lib/mk-temp-dir'; import { hasProjectsAccess } from '../lib/project'; +import { rewriteCorePackage } from '../lib/rewrite-core-package'; import { setupCoreProject } from '../lib/setup-core-project'; import { writeEnv } from '../lib/write-env'; import { program } from '../program'; @@ -44,12 +46,18 @@ vi.mock('../lib/login', () => ({ LoginAbortedError: MockLoginAbortedError, })); -vi.mock('../lib/clone-catalyst', () => ({ cloneCatalyst: vi.fn() })); +vi.mock('../lib/extract-catalyst', () => ({ + extractCatalyst: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../lib/rewrite-core-package', () => ({ + rewriteCorePackage: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../lib/detect-package-manager', () => ({ detectPackageManager: vi.fn(() => 'pnpm') })); +vi.mock('../lib/init-git-repo', () => ({ initGitRepo: vi.fn() })); vi.mock('../lib/setup-core-project', () => ({ setupCoreProject: vi.fn() })); vi.mock('../lib/install-dependencies', () => ({ installDependencies: vi.fn().mockResolvedValue(undefined), })); -vi.mock('../lib/build-workspace-packages', () => ({ buildWorkspacePackages: vi.fn() })); vi.mock('../lib/write-env', () => ({ writeEnv: vi.fn() })); vi.mock('../lib/commerce-hosting', async (importOriginal) => { @@ -111,7 +119,7 @@ const accessToken = 'flag-access-token'; // Each test gets a unique --project-name so the computed projectDir // (`${tmpDir}/${name}`) doesn't collide with prior tests' directories -// when cloneCatalyst's mock creates them. +// when extractCatalyst's mock creates them. const uniqueProjectName = () => `test-project-${(testCounter += 1)}`; beforeAll(async () => { @@ -165,11 +173,13 @@ describe('happy paths', () => { expect(login).not.toHaveBeenCalled(); expect(mockIdentify).toHaveBeenCalledWith(storeHash); - expect(cloneCatalyst).toHaveBeenCalled(); + expect(extractCatalyst).toHaveBeenCalled(); + expect(rewriteCorePackage).toHaveBeenCalled(); + expect(detectPackageManager).toHaveBeenCalled(); expect(setupCoreProject).toHaveBeenCalled(); expect(setupCommerceHosting).not.toHaveBeenCalled(); expect(installDependencies).toHaveBeenCalled(); - expect(buildWorkspacePackages).toHaveBeenCalled(); + expect(initGitRepo).toHaveBeenCalled(); expect(writeEnv).toHaveBeenCalledWith( join(tmpDir, projectName), expect.objectContaining({ @@ -341,10 +351,9 @@ describe('parser validation', () => { }); describe('ordering invariants', () => { - test('writeEnv runs before installDependencies and buildWorkspacePackages', async () => { - // Regression test for edge case #2: previously env vars were written after - // install/build, which would break any future workspace build script that - // reads env vars. + test('writeEnv runs before install, and git init runs after install', async () => { + // Env vars must be written before install (postinstall scripts may read + // them), and the git repo is initialized only after the project is complete. await program.parseAsync([ 'node', 'catalyst', @@ -365,23 +374,25 @@ describe('ordering invariants', () => { const [writeEnvOrder] = vi.mocked(writeEnv).mock.invocationCallOrder; const [installOrder] = vi.mocked(installDependencies).mock.invocationCallOrder; - const [buildOrder] = vi.mocked(buildWorkspacePackages).mock.invocationCallOrder; + const [gitInitOrder] = vi.mocked(initGitRepo).mock.invocationCallOrder; expect(writeEnvOrder).toBeLessThan(installOrder); - expect(writeEnvOrder).toBeLessThan(buildOrder); + expect(installOrder).toBeLessThan(gitInitOrder); }); }); describe('failure handling', () => { test('mid-flow failure surfaces cleanup warning when projectDir exists', async () => { - // Regression test for edge case #5. cloneCatalyst's mock creates the dir + // Regression test for edge case #5. extractCatalyst's mock creates the dir // so the cleanup-warning's pathExistsSync check passes; installDependencies // then throws to simulate a mid-flow failure. const projectName = uniqueProjectName(); const projectDir = join(tmpDir, projectName); - vi.mocked(cloneCatalyst).mockImplementationOnce(() => { + vi.mocked(extractCatalyst).mockImplementationOnce(() => { mkdirSync(projectDir, { recursive: true }); + + return Promise.resolve(); }); vi.mocked(installDependencies).mockRejectedValueOnce(new Error('install failed')); @@ -411,11 +422,11 @@ describe('failure handling', () => { }); test('mid-flow failure does not log cleanup warning if projectDir does not exist', async () => { - // cloneCatalyst is mocked but does NOT create the dir, so pathExistsSync - // returns false and the cleanup warning is suppressed. - vi.mocked(cloneCatalyst).mockImplementationOnce(() => { - throw new Error('clone failed before creating directory'); - }); + // extractCatalyst is mocked to reject WITHOUT creating the dir, so + // pathExistsSync returns false and the cleanup warning is suppressed. + vi.mocked(extractCatalyst).mockRejectedValueOnce( + new Error('extract failed before creating directory'), + ); await expect( program.parseAsync([ @@ -435,7 +446,7 @@ describe('failure handling', () => { '--storefront-token', 'flag-storefront-token', ]), - ).rejects.toThrow('clone failed before creating directory'); + ).rejects.toThrow('extract failed before creating directory'); expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('partial state')); }); @@ -457,7 +468,7 @@ describe('failure handling', () => { 'Login aborted. Re-run `catalyst create` when you have your credentials ready.', ); expect(exitMock).toHaveBeenCalledWith(0); - expect(cloneCatalyst).not.toHaveBeenCalled(); + expect(extractCatalyst).not.toHaveBeenCalled(); }); test('propagates non-LoginAbortedError login failures', async () => { @@ -475,7 +486,7 @@ describe('failure handling', () => { ]), ).rejects.toThrow('network down'); - expect(cloneCatalyst).not.toHaveBeenCalled(); + expect(extractCatalyst).not.toHaveBeenCalled(); }); }); diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts index 3d213f70ca..ff6c07d413 100644 --- a/packages/catalyst/src/cli/commands/create.ts +++ b/packages/catalyst/src/cli/commands/create.ts @@ -7,7 +7,6 @@ import kebabCase from 'lodash.kebabcase'; import { join } from 'path'; import { DEFAULT_LOGIN_URL } from '../lib/auth'; -import { buildWorkspacePackages } from '../lib/build-workspace-packages'; import { channelPlatformLabel, checkChannelEligibility, @@ -16,13 +15,16 @@ import { getChannelInit, sortChannelsByPlatform, } from '../lib/channels'; -import { cloneCatalyst } from '../lib/clone-catalyst'; import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { detectPackageManager } from '../lib/detect-package-manager'; +import { extractCatalyst } from '../lib/extract-catalyst'; +import { initGitRepo } from '../lib/init-git-repo'; import { installDependencies } from '../lib/install-dependencies'; import { getAvailableLocales } from '../lib/localization'; import { consola } from '../lib/logger'; import { login, LoginAbortedError } from '../lib/login'; import { hasProjectsAccess, type ProjectListItem } from '../lib/project'; +import { rewriteCorePackage } from '../lib/rewrite-core-package'; import { setupCoreProject } from '../lib/setup-core-project'; import { accessTokenOption, storeHashOption } from '../lib/shared-options'; import { getTelemetry } from '../lib/telemetry'; @@ -202,15 +204,6 @@ function checkRequiredTools() { consola.error('git is required to create a Catalyst project'); process.exit(1); } - - try { - execSync(getPlatformCheckCommand('pnpm'), { stdio: 'ignore' }); - } catch { - consola.error( - 'pnpm is required to create a Catalyst project. Enable it by running `corepack enable pnpm`.', - ); - process.exit(1); - } } export const create = new Command('create') @@ -237,11 +230,10 @@ Examples: .option('--storefront-token ', 'BigCommerce storefront token') .option( '--gh-ref ', - 'Clone a specific ref from the source repository', + 'Extract a specific ref (tag, branch, or commit) from the source repository', '@bigcommerce/catalyst-core@latest', ) - .option('--reset-main', 'Reset the main branch to the gh-ref') - .option('--repository ', 'GitHub repository to clone from', 'bigcommerce/catalyst') + .option('--repository ', 'GitHub repository to extract from', 'bigcommerce/catalyst') .option( '--env ', 'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).', @@ -411,9 +403,9 @@ Examples: // (already in envVars from the channel init response) at startup. if (accessToken) envVars.CATALYST_ACCESS_TOKEN = accessToken; - // Resolve the Commerce Hosting project before cloning so credential checks + // Resolve the Commerce Hosting project before extraction so credential checks // and prompts happen up-front. We defer the file mutations - // (`setupCommerceHosting`) until after the clone. + // (`setupCommerceHosting`) until after extraction. let commerceHostingProject: ProjectListItem | undefined; if (useCommerceHosting && storeHash && accessToken) { @@ -437,12 +429,17 @@ Examples: consola.info(`Creating '${projectName}' at '${projectDir}'`); + const packageManager = detectPackageManager(); + // Anything that mutates `projectDir` runs inside this block. If a step // fails, the directory is likely partially populated — surface that to the // user so they can clean up before retrying. We don't auto-delete because // they may want to inspect the partial state first. try { - cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain }); + await extractCatalyst({ repository, ref: ghRef, projectDir }); + // Turn the extracted core/ into an installable standalone project + // (resolve workspace deps, drop `private`, record the catalyst version/ref). + await rewriteCorePackage(projectDir, ghRef); setupCoreProject(projectDir); if (useCommerceHosting && commerceHostingProject && storeHash && accessToken) { @@ -454,13 +451,12 @@ Examples: }); } - // Write env before install/build — keeps env vars available to any future - // workspace build script that might read them, and matters today for - // postinstall scripts that may resolve env-driven config. + // Write env before install — matters for postinstall scripts that may + // resolve env-driven config. writeEnv(projectDir, envVars); - await installDependencies(projectDir); - buildWorkspacePackages(projectDir); + await installDependencies(projectDir, packageManager); + initGitRepo(projectDir); } catch (error) { if (pathExistsSync(projectDir)) { consola.warn( @@ -473,11 +469,11 @@ Examples: consola.success(`Created '${projectName}' at '${projectDir}'`); - const steps = [`cd ${projectName}/core && pnpm run dev`]; + const steps = [`cd ${projectName} && ${packageManager} run dev`]; if (useCommerceHosting) { steps.push( - `Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`, + `Run 'cd ${projectName} && ${packageManager} run deploy' when ready to deploy to Commerce Hosting.`, ); } diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index ea060352da..83675a9dd7 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -763,12 +763,12 @@ describe('transformation guard', () => { }), ); expect(setupCommerceHosting).toHaveBeenCalledWith({ - projectDir: dirname(tmpDir), + projectDir: tmpDir, projectUuid, storeHash, accessToken, }); - expect(installDependencies).toHaveBeenCalledWith(dirname(tmpDir)); + expect(installDependencies).toHaveBeenCalledWith(tmpDir); }); test('exits gracefully when user declines to run setup', async () => { diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 2474a3601a..bee338f4d6 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -3,7 +3,7 @@ import AdmZip from 'adm-zip'; import { Command, Option } from 'commander'; import { colorize } from 'consola/utils'; import { access, readdir, readFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; @@ -478,7 +478,7 @@ Example: process.exit(0); } - const projectDir = dirname(process.cwd()); + const projectDir = process.cwd(); await setupCommerceHosting({ projectDir, projectUuid, storeHash, accessToken }); consola.success('Commerce Hosting setup complete.'); @@ -489,7 +489,7 @@ Example: // the Cloudflare worker bundle from earlier Catalyst versions // (`core/instrumentation.ts`, `@vercel/otel`). Sweep them on every deploy // so the fix lands without forcing a re-link. - await cleanupCloudflareIncompatibilities(dirname(process.cwd())); + await cleanupCloudflareIncompatibilities(process.cwd()); } if (options.prebuilt) { diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 5995ecc12b..9cab045615 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -1,7 +1,6 @@ import { confirm, input, select } from '@inquirer/prompts'; import { Command } from 'commander'; import { colorize } from 'consola/utils'; -import { dirname } from 'node:path'; import { cleanupCloudflareIncompatibilities, @@ -25,13 +24,13 @@ import { } from '../lib/shared-options'; import { getTelemetry } from '../lib/telemetry'; -// `catalyst project link` runs from inside `core/`, so the project root (which -// `setupCommerceHosting` and `installDependencies` expect) is one level up. +// `catalyst project link` runs from the project root, which is what +// `setupCommerceHosting` and `installDependencies` expect. async function offerCommerceHostingSetup( projectUuid: string, credentials?: { storeHash: string; accessToken: string }, ) { - const projectDir = dirname(process.cwd()); + const projectDir = process.cwd(); // Already-transformed projects skip the setup prompt below — but they may // still carry artifacts incompatible with the Cloudflare worker bundle diff --git a/packages/catalyst/src/cli/lib/build-workspace-packages.ts b/packages/catalyst/src/cli/lib/build-workspace-packages.ts deleted file mode 100644 index 4533f72701..0000000000 --- a/packages/catalyst/src/cli/lib/build-workspace-packages.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import yoctoSpinner from 'yocto-spinner'; - -const WORKSPACE_PACKAGES = ['packages/catalyst', 'packages/client'] as const; - -// Catalyst monorepo layouts ship pre-built workspace packages, but a fresh -// clone needs them rebuilt before `core` can resolve them. Skip silently when -// the layout doesn't match (e.g. flat repo, custom fork) so this stays a no-op -// for non-monorepo scaffolds. -export const buildWorkspacePackages = (projectDir: string) => { - const hasCore = existsSync(join(projectDir, 'core')); - const hasAllWorkspacePackages = WORKSPACE_PACKAGES.every((pkg) => - existsSync(join(projectDir, pkg)), - ); - - if (!hasCore || !hasAllWorkspacePackages) return; - - WORKSPACE_PACKAGES.forEach((pkg) => { - const spinner = yoctoSpinner().start(`Building ${pkg}...`); - - try { - execSync('pnpm build', { cwd: join(projectDir, pkg), stdio: 'ignore' }); - spinner.success(`Built ${pkg}.`); - } catch (error) { - const message = error instanceof Error ? error.message : 'unknown error'; - - spinner.error(`Failed to build ${pkg}: ${message}`); - throw error; - } - }); -}; diff --git a/packages/catalyst/src/cli/lib/checkout-ref.ts b/packages/catalyst/src/cli/lib/checkout-ref.ts deleted file mode 100644 index 1bddda6fd5..0000000000 --- a/packages/catalyst/src/cli/lib/checkout-ref.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { sync as spawnSync } from 'cross-spawn'; - -import { isExecException } from './is-exec-exception'; -import { consola } from './logger'; - -export function checkoutRef(repoDir: string, ref: string): void { - try { - const spawn = spawnSync('git', ['checkout', ref, '--'], { - cwd: repoDir, - encoding: 'utf8', - shell: false, - }); - - const stderr = spawn.stderr.trim(); - - if (spawn.status !== 0 && stderr) { - throw new Error(stderr); - } - - consola.success(`Checked out ref ${ref} successfully.`); - } catch (error: unknown) { - if (isExecException(error)) { - const stderr = error.stderr ? error.stderr.toString() : ''; - - if ( - stderr.includes(`fatal: reference is not a tree: ${ref}`) || - stderr.includes(`fatal: ambiguous argument '${ref}'`) || - stderr.includes(`unknown revision or path not in the working tree`) - ) { - consola.error(`Ref '${ref}' not found in the repository.`); - } else { - consola.error(`Error checking out ref '${ref}':`, stderr.trim()); - } - } else if (error instanceof Error) { - consola.error(`Error checking out ref '${ref}':`, error.message); - } else { - consola.error(`Unknown error occurred while checking out ref '${ref}'.`); - } - - consola.warn(`Falling back to the default branch.`); - } -} diff --git a/packages/catalyst/src/cli/lib/clone-catalyst.ts b/packages/catalyst/src/cli/lib/clone-catalyst.ts deleted file mode 100644 index 52eb009ace..0000000000 --- a/packages/catalyst/src/cli/lib/clone-catalyst.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { execSync } from 'child_process'; - -import { checkoutRef } from './checkout-ref'; -import { hasGitHubSSH } from './has-github-ssh'; -import { consola } from './logger'; -import { resetBranchToRef } from './reset-branch-to-ref'; - -export const cloneCatalyst = ({ - repository, - projectName, - projectDir, - ghRef, - resetMain = false, -}: { - repository: string; - projectName: string; - projectDir: string; - ghRef?: string; - resetMain?: boolean; -}) => { - const useSSH = hasGitHubSSH(); - - consola.info(`Cloning ${repository} using ${useSSH ? 'SSH' : 'HTTPS'}...`); - - const cloneCommand = `git clone ${ - useSSH ? `git@github.com:${repository}` : `https://github.com/${repository}` - }.git${projectName ? ` ${projectName}` : ''}`; - - execSync(cloneCommand, { stdio: 'inherit' }); - - execSync('git remote rename origin upstream', { cwd: projectDir, stdio: 'inherit' }); - - if (ghRef) { - if (resetMain) { - execSync('git checkout -b main', { cwd: projectDir, stdio: 'inherit' }); - - resetBranchToRef(projectDir, ghRef); - - consola.success(`Reset main to ${ghRef} successfully.`); - - return; - } - - checkoutRef(projectDir, ghRef); - } -}; diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts index 7ae46b8b86..3ef4086807 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts @@ -1,14 +1,5 @@ import { confirm, input, select } from '@inquirer/prompts'; -import { - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - readlinkSync, - rmSync, - writeFileSync, -} from 'fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; @@ -593,35 +584,26 @@ describe('setupCommerceHosting', () => { }); function writeCorePackageJson(contents: unknown) { - const coreDir = join(projectDir, 'core'); - - mkdirSync(coreDir, { recursive: true }); - writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); + writeFileSync(join(projectDir, 'package.json'), JSON.stringify(contents, null, 2)); } function writeCoreProxyFile(contents: string) { - const coreDir = join(projectDir, 'core'); - - mkdirSync(coreDir, { recursive: true }); - writeFileSync(join(coreDir, 'proxy.ts'), contents); + writeFileSync(join(projectDir, 'proxy.ts'), contents); } function writeCoreInstrumentationFile(contents: string) { - const coreDir = join(projectDir, 'core'); - - mkdirSync(coreDir, { recursive: true }); - writeFileSync(join(coreDir, 'instrumentation.ts'), contents); + writeFileSync(join(projectDir, 'instrumentation.ts'), contents); } function readCorePackageJson() { return packageJsonSchema.parse( - JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), + JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8')), ); } function readProjectJson() { return projectJsonSchema.parse( - JSON.parse(readFileSync(join(projectDir, 'core', '.bigcommerce', 'project.json'), 'utf-8')), + JSON.parse(readFileSync(join(projectDir, '.bigcommerce', 'project.json'), 'utf-8')), ); } @@ -717,7 +699,7 @@ describe('setupCommerceHosting', () => { expect(pkg.devDependencies).toEqual({ jest: '^29.0.0' }); }); - it('writes core/.bigcommerce/project.json with the correct shape', async () => { + it('writes .bigcommerce/project.json with the correct shape', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); await setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); @@ -769,55 +751,16 @@ describe('setupCommerceHosting', () => { expect(projectJson.accessToken).toBeUndefined(); }); - it('throws when core/package.json is missing', async () => { + it('throws when package.json is missing', async () => { await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).rejects.toThrow(); }); - it('throws when core/package.json has an invalid shape', async () => { + it('throws when package.json has an invalid shape', async () => { writeCorePackageJson({ dependencies: { next: 42 } }); await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).rejects.toThrow(); }); - describe('core/.env.local symlink', () => { - it('creates a symlink at core/.env.local pointing to ../.env.local', async () => { - writeCorePackageJson({ scripts: { dev: 'next dev' } }); - - await setupCommerceHosting({ projectDir, projectUuid: 'u' }); - - const coreEnvPath = join(projectDir, 'core', '.env.local'); - - expect(lstatSync(coreEnvPath).isSymbolicLink()).toBe(true); - expect(readlinkSync(coreEnvPath)).toBe(join('..', '.env.local')); - }); - - it('keeps both files in sync via the symlink target', async () => { - writeCorePackageJson({ scripts: { dev: 'next dev' } }); - writeFileSync(join(projectDir, '.env.local'), 'FOO=bar\n'); - - await setupCommerceHosting({ projectDir, projectUuid: 'u' }); - - expect(readFileSync(join(projectDir, 'core', '.env.local'), 'utf-8')).toBe('FOO=bar\n'); - - writeFileSync(join(projectDir, 'core', '.env.local'), 'FOO=baz\n'); - - expect(readFileSync(join(projectDir, '.env.local'), 'utf-8')).toBe('FOO=baz\n'); - }); - - it('does not clobber an existing core/.env.local file', async () => { - writeCorePackageJson({ scripts: { dev: 'next dev' } }); - mkdirSync(join(projectDir, 'core'), { recursive: true }); - writeFileSync(join(projectDir, 'core', '.env.local'), 'PRESERVE=me\n'); - - await setupCommerceHosting({ projectDir, projectUuid: 'u' }); - - const coreEnvPath = join(projectDir, 'core', '.env.local'); - - expect(lstatSync(coreEnvPath).isSymbolicLink()).toBe(false); - expect(readFileSync(coreEnvPath, 'utf-8')).toBe('PRESERVE=me\n'); - }); - }); - describe('proxy.ts → middleware.ts conversion', () => { const proxyFixture = [ "import { composeProxies } from './proxies/compose-proxies';", @@ -836,8 +779,8 @@ describe('setupCommerceHosting', () => { await setupCommerceHosting({ projectDir, projectUuid: 'u' }); - const middlewarePath = join(projectDir, 'core', 'middleware.ts'); - const proxyPath = join(projectDir, 'core', 'proxy.ts'); + const middlewarePath = join(projectDir, 'middleware.ts'); + const proxyPath = join(projectDir, 'proxy.ts'); expect(existsSync(middlewarePath)).toBe(true); expect(existsSync(proxyPath)).toBe(false); @@ -855,7 +798,7 @@ describe('setupCommerceHosting', () => { await setupCommerceHosting({ projectDir, projectUuid: 'u' }); - const middleware = readFileSync(join(projectDir, 'core', 'middleware.ts'), 'utf-8'); + const middleware = readFileSync(join(projectDir, 'middleware.ts'), 'utf-8'); expect(middleware).toContain("import { composeProxies } from './proxies/compose-proxies';"); expect(middleware).toContain("matcher: ['/((?!api).*)']"); @@ -865,21 +808,21 @@ describe('setupCommerceHosting', () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); await expect(setupCommerceHosting({ projectDir, projectUuid: 'u' })).resolves.not.toThrow(); - expect(existsSync(join(projectDir, 'core', 'middleware.ts'))).toBe(false); + expect(existsSync(join(projectDir, 'middleware.ts'))).toBe(false); }); }); describe('instrumentation.ts removal', () => { - it('deletes core/instrumentation.ts when the user accepts the prompt', async () => { + it('deletes instrumentation.ts when the user accepts the prompt', async () => { writeCorePackageJson({ scripts: { dev: 'next dev' } }); writeCoreInstrumentationFile('export function register() {}\n'); await setupCommerceHosting({ projectDir, projectUuid: 'u' }); - expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(false); + expect(existsSync(join(projectDir, 'instrumentation.ts'))).toBe(false); }); - it('preserves core/instrumentation.ts when the user declines the prompt', async () => { + it('preserves instrumentation.ts when the user declines the prompt', async () => { confirmMock.mockResolvedValueOnce(false); writeCorePackageJson({ @@ -890,7 +833,7 @@ describe('setupCommerceHosting', () => { await setupCommerceHosting({ projectDir, projectUuid: 'u' }); - expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); + expect(existsSync(join(projectDir, 'instrumentation.ts'))).toBe(true); expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); }); @@ -921,26 +864,20 @@ describe('cleanupCloudflareIncompatibilities', () => { }); function writeCorePackageJson(contents: unknown) { - const coreDir = join(projectDir, 'core'); - - mkdirSync(coreDir, { recursive: true }); - writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); + writeFileSync(join(projectDir, 'package.json'), JSON.stringify(contents, null, 2)); } function writeCoreInstrumentationFile(contents: string) { - const coreDir = join(projectDir, 'core'); - - mkdirSync(coreDir, { recursive: true }); - writeFileSync(join(coreDir, 'instrumentation.ts'), contents); + writeFileSync(join(projectDir, 'instrumentation.ts'), contents); } function readCorePackageJson() { return packageJsonSchema.parse( - JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), + JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8')), ); } - it('removes core/instrumentation.ts and drops @vercel/otel when the user accepts (TTY)', async () => { + it('removes instrumentation.ts and drops @vercel/otel when the user accepts (TTY)', async () => { writeCorePackageJson({ dependencies: { next: '^15.0.0', '@vercel/otel': '^2.1.0' }, }); @@ -948,7 +885,7 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); - expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(false); + expect(existsSync(join(projectDir, 'instrumentation.ts'))).toBe(false); expect(readCorePackageJson().dependencies).not.toHaveProperty('@vercel/otel'); expect(readCorePackageJson().dependencies).toMatchObject({ next: '^15.0.0' }); }); @@ -963,7 +900,7 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); - expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); + expect(existsSync(join(projectDir, 'instrumentation.ts'))).toBe(true); expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); }); @@ -979,7 +916,7 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); expect(confirmMock).not.toHaveBeenCalled(); - expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(true); + expect(existsSync(join(projectDir, 'instrumentation.ts'))).toBe(true); expect(readCorePackageJson().dependencies).toHaveProperty('@vercel/otel', '^2.1.0'); }); @@ -997,6 +934,6 @@ describe('cleanupCloudflareIncompatibilities', () => { await cleanupCloudflareIncompatibilities(projectDir); - expect(existsSync(join(projectDir, 'core', 'instrumentation.ts'))).toBe(false); + expect(existsSync(join(projectDir, 'instrumentation.ts'))).toBe(false); }); }); diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.ts b/packages/catalyst/src/cli/lib/commerce-hosting.ts index 161b8aec26..1dccf714a5 100644 --- a/packages/catalyst/src/cli/lib/commerce-hosting.ts +++ b/packages/catalyst/src/cli/lib/commerce-hosting.ts @@ -1,14 +1,6 @@ import { confirm, input, select, Separator } from '@inquirer/prompts'; import { colorize } from 'consola/utils'; -import { - existsSync, - lstatSync, - mkdirSync, - readFileSync, - symlinkSync, - unlinkSync, - writeFileSync, -} from 'fs'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { z } from 'zod'; @@ -32,27 +24,9 @@ const writeJson = (path: string, value: unknown) => { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); }; -const symlinkRootEnvToCore = (projectDir: string) => { - const coreEnvPath = join(projectDir, 'core', '.env.local'); - - if (lstatSync(coreEnvPath, { throwIfNoEntry: false })) return; - - try { - symlinkSync('../.env.local', coreEnvPath); - } catch (error) { - const message = error instanceof Error ? error.message : 'unknown error'; - - consola.warn( - `Could not create symlink at core/.env.local: ${message}\n` + - 'On Windows, enable Developer Mode or run as administrator to allow symlinks.\n' + - 'You will need to keep .env.local and core/.env.local in sync manually.', - ); - } -}; - const convertProxyToMiddleware = (projectDir: string) => { - const proxyPath = join(projectDir, 'core', 'proxy.ts'); - const middlewarePath = join(projectDir, 'core', 'middleware.ts'); + const proxyPath = join(projectDir, 'proxy.ts'); + const middlewarePath = join(projectDir, 'middleware.ts'); if (!existsSync(proxyPath)) return; @@ -64,7 +38,7 @@ const convertProxyToMiddleware = (projectDir: string) => { unlinkSync(proxyPath); }; -// The default `core/instrumentation.ts` registers `@vercel/otel`, whose node +// The default `instrumentation.ts` registers `@vercel/otel`, whose node // build OpenNext bundles into the worker chunk; workerd then throws on cold // start with "Failed to prepare server". A Vercel-deploying user may have // customized the hook though, so we prompt before removing it (and only drop @@ -74,13 +48,13 @@ const convertProxyToMiddleware = (projectDir: string) => { // already-transformed projects too, where `setupCommerceHosting` would // short-circuit. export const cleanupCloudflareIncompatibilities = async (projectDir: string) => { - const instrumentationPath = join(projectDir, 'core', 'instrumentation.ts'); + const instrumentationPath = join(projectDir, 'instrumentation.ts'); if (!existsSync(instrumentationPath)) return; if (!process.stdin.isTTY) { consola.warn( - 'core/instrumentation.ts is present and may be incompatible with the Cloudflare Workers ' + + 'instrumentation.ts is present and may be incompatible with the Cloudflare Workers ' + 'bundle (the default @vercel/otel scaffolding throws at cold start under workerd). ' + 'Skipping automatic cleanup in non-interactive mode — re-run interactively to remove it, ' + 'or delete/gate it manually.', @@ -91,15 +65,15 @@ export const cleanupCloudflareIncompatibilities = async (projectDir: string) => const shouldRemove = await confirm({ message: - 'Catalyst found core/instrumentation.ts, which is incompatible with the Cloudflare Workers ' + + 'Catalyst found instrumentation.ts, which is incompatible with the Cloudflare Workers ' + 'bundle when it uses @vercel/otel (causes "Failed to prepare server" at cold start). ' + - 'Remove it and drop @vercel/otel from core/package.json?', + 'Remove it and drop @vercel/otel from package.json?', default: true, }); if (!shouldRemove) { consola.info( - 'Leaving core/instrumentation.ts in place. The Cloudflare worker will continue to log ' + + 'Leaving instrumentation.ts in place. The Cloudflare worker will continue to log ' + '"Failed to prepare server" at cold start until this is resolved manually.', ); @@ -107,9 +81,9 @@ export const cleanupCloudflareIncompatibilities = async (projectDir: string) => } unlinkSync(instrumentationPath); - consola.info('Removed core/instrumentation.ts (incompatible with Cloudflare Workers).'); + consola.info('Removed instrumentation.ts (incompatible with Cloudflare Workers).'); - const corePackageJsonPath = join(projectDir, 'core', 'package.json'); + const corePackageJsonPath = join(projectDir, 'package.json'); if (existsSync(corePackageJsonPath)) { const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); @@ -119,7 +93,7 @@ export const cleanupCloudflareIncompatibilities = async (projectDir: string) => pkg.dependencies = preservedDeps; writeJson(corePackageJsonPath, sortPackageJsonFields(pkg)); - consola.info('Dropped @vercel/otel from core/package.json.'); + consola.info('Dropped @vercel/otel from package.json.'); } } }; @@ -137,7 +111,7 @@ export const setupCommerceHosting = async ({ }) => { await cleanupCloudflareIncompatibilities(projectDir); - const corePackageJsonPath = join(projectDir, 'core', 'package.json'); + const corePackageJsonPath = join(projectDir, 'package.json'); const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); pkg.dependencies = { @@ -155,9 +129,8 @@ export const setupCommerceHosting = async ({ if (storeHash) projectJson.storeHash = storeHash; if (accessToken) projectJson.accessToken = accessToken; - writeJson(join(projectDir, 'core', '.bigcommerce', 'project.json'), projectJson); + writeJson(join(projectDir, '.bigcommerce', 'project.json'), projectJson); - symlinkRootEnvToCore(projectDir); convertProxyToMiddleware(projectDir); }; diff --git a/packages/catalyst/src/cli/lib/detect-package-manager.ts b/packages/catalyst/src/cli/lib/detect-package-manager.ts new file mode 100644 index 0000000000..4c1a764003 --- /dev/null +++ b/packages/catalyst/src/cli/lib/detect-package-manager.ts @@ -0,0 +1,22 @@ +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +// Detect the package manager that INVOKED the CLI (via npx / pnpm dlx / yarn dlx +// / bunx) by parsing the leading token of npm_config_user_agent, which every +// manager sets when it spawns a child process. This is deliberately not +// nypm.detectPackageManager(cwd): that keys off a lockfile in the project, which +// a freshly extracted project does not have yet. +export const detectPackageManager = (): PackageManager => { + const userAgent = process.env.npm_config_user_agent ?? ''; + const name = userAgent.split(' ')[0]?.split('/')[0]; + + if (name === 'pnpm' || name === 'yarn' || name === 'bun') { + return name; + } + + // Some bunx versions omit npm_config_user_agent; fall back to the bun runtime marker. + if (process.versions.bun) { + return 'bun'; + } + + return 'npm'; +}; diff --git a/packages/catalyst/src/cli/lib/extract-catalyst.ts b/packages/catalyst/src/cli/lib/extract-catalyst.ts new file mode 100644 index 0000000000..c6833cdb86 --- /dev/null +++ b/packages/catalyst/src/cli/lib/extract-catalyst.ts @@ -0,0 +1,90 @@ +import { mkdirSync } from 'fs'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; +import type { ReadableStream as NodeReadableStream } from 'stream/web'; +import { x } from 'tar'; + +import { consola } from './logger'; + +const SUBDIR = 'core'; +const MAX_ATTEMPTS = 3; + +const codeloadUrl = (repository: string, ref: string) => + `https://codeload.github.com/${repository}/tar.gz/${encodeURIComponent(ref)}`; + +const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +// Download the repo tarball from GitHub's codeload endpoint and stream-extract +// ONLY the `core/` subdirectory into `projectDir`, so merchants receive a clean +// standalone project instead of the whole monorepo. Mirrors create-next-app: +// fetch -> stream.pipeline -> tar.x({ strip, filter }). +// +// The archive's top-level directory is dynamic (e.g. +// `catalyst--bigcommerce-catalyst-core-1.6.2` for a tag, `catalyst-canary` for a +// branch), so we never hardcode it — `filter` keeps entries whose first path +// segment after the top dir is `core/`, and `strip: 2` drops `/core` so the +// contents land at the project root. +const downloadAndExtract = async (repository: string, ref: string, projectDir: string) => { + const url = codeloadUrl(repository, ref); + const response = await fetch(url); + + if (!response.ok || !response.body) { + throw new Error(`Failed to download ${url} (HTTP ${response.status})`); + } + + await pipeline( + // Node's global fetch types `body` as the DOM ReadableStream; it is the same + // object Readable.fromWeb expects at runtime. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + Readable.fromWeb(response.body as NodeReadableStream), + x({ + cwd: projectDir, + strip: 2, + filter: (path) => path.split('/')[1] === SUBDIR, + }), + ); +}; + +// Retries the download up to MAX_ATTEMPTS with linear backoff, following +// create-next-app's pattern. Recursive rather than looped to keep each await off +// the hot loop path (and satisfy lint). +const attemptDownload = async ( + repository: string, + ref: string, + projectDir: string, + attempt = 1, +): Promise => { + try { + await downloadAndExtract(repository, ref, projectDir); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + if (attempt >= MAX_ATTEMPTS) { + throw new Error(`Failed to download Catalyst after ${MAX_ATTEMPTS} attempts: ${message}`); + } + + consola.warn(`Download attempt ${attempt} failed (${message}). Retrying...`); + await wait(attempt * 1000); + + return attemptDownload(repository, ref, projectDir, attempt + 1); + } +}; + +export const extractCatalyst = async ({ + repository, + ref, + projectDir, +}: { + repository: string; + ref: string; + projectDir: string; +}) => { + consola.info(`Downloading ${repository}#${ref}...`); + + mkdirSync(projectDir, { recursive: true }); + + await attemptDownload(repository, ref, projectDir); +}; diff --git a/packages/catalyst/src/cli/lib/has-github-ssh.ts b/packages/catalyst/src/cli/lib/has-github-ssh.ts deleted file mode 100644 index 509d72538c..0000000000 --- a/packages/catalyst/src/cli/lib/has-github-ssh.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { execSync } from 'child_process'; - -import { isExecException } from './is-exec-exception'; - -export function hasGitHubSSH(): boolean { - try { - const output = execSync('ssh -T git@github.com', { - encoding: 'utf8', - stdio: 'pipe', - }).toString(); - - return output.includes('successfully authenticated'); - } catch (error: unknown) { - if (isExecException(error)) { - const stdout = error.stdout ? error.stdout.toString() : ''; - const stderr = error.stderr ? error.stderr.toString() : ''; - const combinedOutput = stdout + stderr; - - if (combinedOutput.includes('successfully authenticated')) { - return true; - } - } - - return false; - } -} diff --git a/packages/catalyst/src/cli/lib/init-git-repo.ts b/packages/catalyst/src/cli/lib/init-git-repo.ts new file mode 100644 index 0000000000..ec28846624 --- /dev/null +++ b/packages/catalyst/src/cli/lib/init-git-repo.ts @@ -0,0 +1,23 @@ +import { execSync } from 'child_process'; +import { rmSync } from 'fs'; +import { join } from 'path'; + +import { consola } from './logger'; + +const run = (command: string, cwd: string) => execSync(command, { cwd, stdio: 'ignore' }); + +// Initialize a fresh git repository for the merchant with a single initial +// commit. We no longer clone, so there is no history or upstream remote to carry +// over — merchants add their own. Best-effort: a missing git or a pre-existing +// repo shouldn't fail project creation, so we warn and roll back a partial init. +export const initGitRepo = (projectDir: string) => { + try { + run('git init', projectDir); + run('git add -A', projectDir); + run('git commit -m "Initial commit from Catalyst"', projectDir); + consola.success('Initialized a git repository.'); + } catch { + rmSync(join(projectDir, '.git'), { recursive: true, force: true }); + consola.warn('Could not initialize a git repository. Skipping.'); + } +}; diff --git a/packages/catalyst/src/cli/lib/install-dependencies.ts b/packages/catalyst/src/cli/lib/install-dependencies.ts index 76c56c09b6..9419862d12 100644 --- a/packages/catalyst/src/cli/lib/install-dependencies.ts +++ b/packages/catalyst/src/cli/lib/install-dependencies.ts @@ -1,11 +1,18 @@ import { installDependencies as installDeps } from 'nypm'; import yoctoSpinner from 'yocto-spinner'; -export const installDependencies = async (projectDir: string) => { - const spinner = yoctoSpinner().start('Installing dependencies. This could take a minute...'); +import type { PackageManager } from './detect-package-manager'; + +export const installDependencies = async ( + projectDir: string, + packageManager: PackageManager = 'pnpm', +) => { + const spinner = yoctoSpinner().start( + `Installing dependencies with ${packageManager}. This could take a minute...`, + ); try { - await installDeps({ cwd: projectDir, silent: true, packageManager: 'pnpm' }); + await installDeps({ cwd: projectDir, silent: true, packageManager }); spinner.success('Dependencies installed successfully.'); } catch (error) { const message = error instanceof Error ? error.message : 'unknown error'; diff --git a/packages/catalyst/src/cli/lib/is-exec-exception.ts b/packages/catalyst/src/cli/lib/is-exec-exception.ts deleted file mode 100644 index 9cdf5a6280..0000000000 --- a/packages/catalyst/src/cli/lib/is-exec-exception.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExecException } from 'node:child_process'; - -export function isExecException(error: unknown): error is ExecException { - return typeof error === 'object' && error !== null && 'stdout' in error && 'stderr' in error; -} diff --git a/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts b/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts deleted file mode 100644 index 9a68c41ecf..0000000000 --- a/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { sync as spawnSync } from 'cross-spawn'; - -export function resetBranchToRef(projectDir: string, ghRef: string) { - const spawn = spawnSync('git', ['reset', '--hard', ghRef, '--'], { - cwd: projectDir, - encoding: 'utf8', - shell: false, - }); - - const stderr = spawn.stderr.trim(); - - if (spawn.status !== 0 && stderr) { - throw new Error(stderr); - } -} diff --git a/packages/catalyst/src/cli/lib/rewrite-core-package.ts b/packages/catalyst/src/cli/lib/rewrite-core-package.ts new file mode 100644 index 0000000000..acf32a4d37 --- /dev/null +++ b/packages/catalyst/src/cli/lib/rewrite-core-package.ts @@ -0,0 +1,118 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { z } from 'zod'; + +import { consola } from './logger'; +import { sortPackageJsonFields } from './sort-package-json'; + +// Native-module build allowlist, lifted from the monorepo's root +// pnpm-workspace.yaml `onlyBuiltDependencies`. A standalone install needs this so +// pnpm (`pnpm.onlyBuiltDependencies`) and bun (`trustedDependencies`) run the +// native postinstall builds without prompting. npm/yarn-classic run them anyway. +const NATIVE_BUILD_ALLOWLIST = [ + '@parcel/watcher', + '@swc/core', + '@vercel/speed-insights', + 'esbuild', + 'msw', + 'protobufjs', + 'puppeteer', + 'sharp', + 'workerd', +]; + +const DEP_FIELDS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] as const; + +const corePackageJsonSchema = z.looseObject({ + version: z.string().optional(), + private: z.boolean().optional(), + scripts: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), + devDependencies: z.record(z.string(), z.string()).optional(), + optionalDependencies: z.record(z.string(), z.string()).optional(), + peerDependencies: z.record(z.string(), z.string()).optional(), + pnpm: z.looseObject({}).optional(), + catalyst: z.looseObject({}).optional(), +}); + +const registryVersionSchema = z.object({ version: z.string() }); + +// Resolve the published npm version for a workspace dependency. The extracted +// tag tree still carries `workspace:^` (core is private, so changesets' publish- +// time rewrite never touches the committed tree) — so we resolve the real +// version at extraction time from the npm registry. The two workspace deps +// (`@bigcommerce/catalyst-client`, `@bigcommerce/eslint-config-catalyst`) are +// published in lockstep with core, so `latest` is the compatible version. +const resolvePublishedVersion = async (pkgName: string): Promise => { + const response = await fetch(`https://registry.npmjs.org/${pkgName}/latest`); + + if (!response.ok) { + throw new Error( + `Could not resolve a published version for "${pkgName}" (HTTP ${response.status}). ` + + 'The extracted project still references it via the workspace protocol and cannot be installed.', + ); + } + + const { version } = registryVersionSchema.parse(await response.json()); + + return `^${version}`; +}; + +// Transforms an extracted `core/package.json` into an installable, standalone +// manifest: resolves `workspace:` deps to published versions, drops `private`, +// bakes the native-build allowlist for pnpm/bun, strips monorepo-only (turbo) +// scripts, and records the Catalyst version/ref the project was created from. +// Also writes `.npmrc` so npm tolerates react-headroom's stale react peer range. +export const rewriteCorePackage = async (projectDir: string, ref: string) => { + const packageJsonPath = join(projectDir, 'package.json'); + const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(packageJsonPath, 'utf-8'))); + + // Collect every workspace-protocol dependency across all dep fields, keeping a + // reference to its owning record so we can rewrite it in place after resolving. + const workspaceDeps = DEP_FIELDS.flatMap((field) => { + const deps = pkg[field]; + + if (!deps) return []; + + return Object.entries(deps) + .filter(([, range]) => range.startsWith('workspace:')) + .map(([name]) => ({ deps, name })); + }); + + await Promise.all( + workspaceDeps.map(async ({ deps, name }) => { + const resolved = await resolvePublishedVersion(name); + + deps[name] = resolved; + consola.info(`Resolved ${name} to ${resolved}`); + }), + ); + + // Merchants need an installable project; `private` blocks publish/install ergonomics. + delete pkg.private; + + // Drop any monorepo-only scripts that shell out to turbo. + if (pkg.scripts) { + pkg.scripts = Object.fromEntries( + Object.entries(pkg.scripts).filter(([, command]) => !command.includes('turbo')), + ); + } + + pkg.pnpm = { ...pkg.pnpm, onlyBuiltDependencies: NATIVE_BUILD_ALLOWLIST }; + pkg.trustedDependencies = NATIVE_BUILD_ALLOWLIST; + + // Track what this project was scaffolded from (consumed by `catalyst upgrade` + // and the backend user-agent). `ref` is the exact ref extracted. + pkg.catalyst = { ...pkg.catalyst, version: pkg.version, ref }; + + writeFileSync(packageJsonPath, `${JSON.stringify(sortPackageJsonFields(pkg), null, 2)}\n`); + + // react-headroom (unmaintained) declares a react <=18 peer; core ships react 19. + // pnpm/yarn/bun tolerate it, but npm errors ERESOLVE without this. + writeFileSync(join(projectDir, '.npmrc'), 'legacy-peer-deps=true\n'); +}; diff --git a/packages/catalyst/src/cli/lib/setup-core-project.spec.ts b/packages/catalyst/src/cli/lib/setup-core-project.spec.ts index e781a46990..012b5d7394 100644 --- a/packages/catalyst/src/cli/lib/setup-core-project.spec.ts +++ b/packages/catalyst/src/cli/lib/setup-core-project.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -17,16 +17,11 @@ const packageJsonSchema = z.looseObject({ let projectDir: string; const writeCorePackageJson = (contents: unknown) => { - const coreDir = join(projectDir, 'core'); - - mkdirSync(coreDir, { recursive: true }); - writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); + writeFileSync(join(projectDir, 'package.json'), JSON.stringify(contents, null, 2)); }; const readCorePackageJson = () => - packageJsonSchema.parse( - JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), - ); + packageJsonSchema.parse(JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'))); beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'catalyst-setup-core-test-')); diff --git a/packages/catalyst/src/cli/lib/setup-core-project.ts b/packages/catalyst/src/cli/lib/setup-core-project.ts index 0d812c8408..52171db865 100644 --- a/packages/catalyst/src/cli/lib/setup-core-project.ts +++ b/packages/catalyst/src/cli/lib/setup-core-project.ts @@ -17,11 +17,11 @@ const writeJson = (path: string, value: unknown) => { }; // Wires Catalyst CLI scripts and the `@bigcommerce/catalyst` dep into a freshly -// cloned `core/`. Always runs at create time, regardless of hosting choice — +// extracted project. Always runs at create time, regardless of hosting choice — // `catalyst build` / `catalyst start` / `catalyst deploy` dispatch on project // state, so these scripts work for self-hosted projects too without rewrite. export const setupCoreProject = (projectDir: string) => { - const corePackageJsonPath = join(projectDir, 'core', 'package.json'); + const corePackageJsonPath = join(projectDir, 'package.json'); const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); pkg.scripts = { diff --git a/packages/catalyst/src/cli/lib/write-env.ts b/packages/catalyst/src/cli/lib/write-env.ts index bbf2f49b1c..0d8014e0ab 100644 --- a/packages/catalyst/src/cli/lib/write-env.ts +++ b/packages/catalyst/src/cli/lib/write-env.ts @@ -1,11 +1,12 @@ import { outputFileSync } from 'fs-extra/esm'; import { join } from 'path'; -// Writes core/.env.local — Next.js (and all the catalyst CLI commands) read env vars -// from there, since `core/` is the package they run inside. +// Writes .env.local at the project root — Next.js (and all the catalyst CLI +// commands) read env vars from there, since the extracted project is the package +// they run inside. export const writeEnv = (projectDir: string, envVars: Record) => { outputFileSync( - join(projectDir, 'core', '.env.local'), + join(projectDir, '.env.local'), `${Object.entries(envVars) .map(([key, value]) => `${key}=${value}`) .join('\n')}\n`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b92b7f7bba..07e5c3853d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: std-env: specifier: ^3.9.0 version: 3.9.0 + tar: + specifier: ^7.5.7 + version: 7.5.16 yocto-spinner: specifier: ^1.0.0 version: 1.0.0 @@ -2305,6 +2308,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -4401,6 +4408,7 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unlighthouse/cli@0.16.3': resolution: {integrity: sha512-4erwxa9mij2qxbtMtJe3OB0ONjjg6ZpWcHr4tQHwgjewn9tNVyhdnIiMneig8EfyoKbxnvY0HBJOG4VBMJcaVA==} @@ -4948,6 +4956,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} @@ -5122,6 +5131,10 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chrome-launcher@1.2.0: resolution: {integrity: sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q==} engines: {node: '>=12.13.0'} @@ -7589,6 +7602,10 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -9163,7 +9180,11 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + engines: {node: '>=18'} term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -9930,6 +9951,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.6.0: resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==} engines: {node: '>= 14'} @@ -10996,9 +11021,9 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-plugin-gettext: 1.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(typescript@5.8.3) eslint-plugin-jest-dom: 5.5.0(eslint@8.57.1) eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) @@ -12318,6 +12343,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -15791,6 +15820,8 @@ snapshots: chownr@2.0.0: {} + chownr@3.0.0: {} + chrome-launcher@1.2.0: dependencies: '@types/node': 22.15.30 @@ -16554,8 +16585,8 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -16582,21 +16613,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 8.57.1 - get-tsconfig: 4.10.0 - is-bun-module: 1.3.0 - rspack-resolver: 1.2.2 - stable-hash: 0.0.5 - tinyglobby: 0.2.14 - optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -16613,17 +16629,6 @@ snapshots: - supports-color eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -16644,35 +16649,6 @@ snapshots: dependencies: gettext-parser: 4.2.0 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 @@ -16684,7 +16660,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -18782,6 +18758,10 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + mitt@3.0.1: {} mkdirp@1.0.4: {} @@ -20529,6 +20509,14 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tar@7.5.16: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + term-size@2.2.1: {} terser@5.16.9: @@ -21372,6 +21360,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@2.6.0: {} yaml@2.8.1: {} From ba722da75f95fc3e81d9fc25c7093d1dd2a69c19 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Fri, 26 Jun 2026 12:59:37 -0600 Subject: [PATCH 78/90] feat: TRAC-924 upgrade: utility helpers and per-file merge engine Adds the foundational layer for `catalyst upgrade`: small fs utilities, ref parsing, base-version similarity scoring, and the per-file 3-way merge engine (`git merge-file`). Refs TRAC-924 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../catalyst/src/cli/commands/upgrade.spec.ts | 108 ++++++++ packages/catalyst/src/cli/commands/upgrade.ts | 245 ++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 packages/catalyst/src/cli/commands/upgrade.spec.ts create mode 100644 packages/catalyst/src/cli/commands/upgrade.ts diff --git a/packages/catalyst/src/cli/commands/upgrade.spec.ts b/packages/catalyst/src/cli/commands/upgrade.spec.ts new file mode 100644 index 0000000000..cc330078de --- /dev/null +++ b/packages/catalyst/src/cli/commands/upgrade.spec.ts @@ -0,0 +1,108 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { computeBaseSimilarity, parseRef } from './upgrade'; + +const createdDirs: string[] = []; + +async function mkTmp(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'upgrade-spec-')); + + createdDirs.push(dir); + + return dir; +} + +async function write(file: string, content: string | Buffer): Promise { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content); +} + +afterEach(async () => { + await Promise.all(createdDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('parseRef', () => { + test('splits a scoped package ref on the last @', () => { + expect(parseRef('@bigcommerce/catalyst-core@1.7.0')).toEqual({ + packageName: '@bigcommerce/catalyst-core', + version: '1.7.0', + }); + }); + + test('handles integration families', () => { + expect(parseRef('@bigcommerce/catalyst-makeswift@1.7.0')).toEqual({ + packageName: '@bigcommerce/catalyst-makeswift', + version: '1.7.0', + }); + }); + + test('throws when there is no version separator', () => { + expect(() => parseRef('catalyst-core')).toThrow(); + }); +}); + +describe('computeBaseSimilarity', () => { + test('returns 1.0 when all base files match exactly', async () => { + const root = await mkTmp(); + + await Promise.all([ + write(join(root, 'base', 'a.txt'), 'hello\n'), + write(join(root, 'base', 'b.txt'), 'world\n'), + write(join(root, 'dest', 'a.txt'), 'hello\n'), + write(join(root, 'dest', 'b.txt'), 'world\n'), + ]); + + expect(await computeBaseSimilarity(join(root, 'base'), join(root, 'dest'))).toBe(1.0); + }); + + test('returns 0.5 when half the base files match', async () => { + const root = await mkTmp(); + + await Promise.all([ + write(join(root, 'base', 'match.txt'), 'same\n'), + write(join(root, 'base', 'differ.txt'), 'original\n'), + write(join(root, 'dest', 'match.txt'), 'same\n'), + write(join(root, 'dest', 'differ.txt'), 'modified\n'), + ]); + + expect(await computeBaseSimilarity(join(root, 'base'), join(root, 'dest'))).toBe(0.5); + }); + + test('returns 0 when no base files exist in dest', async () => { + const root = await mkTmp(); + + await Promise.all([ + write(join(root, 'base', 'a.txt'), 'content\n'), + mkdir(join(root, 'dest'), { recursive: true }), + ]); + + expect(await computeBaseSimilarity(join(root, 'base'), join(root, 'dest'))).toBe(0); + }); + + test('returns 0 for an empty base', async () => { + const root = await mkTmp(); + + await Promise.all([ + mkdir(join(root, 'base'), { recursive: true }), + mkdir(join(root, 'dest'), { recursive: true }), + ]); + + expect(await computeBaseSimilarity(join(root, 'base'), join(root, 'dest'))).toBe(0); + }); + + test('extra files in dest do not affect the score (only base coverage counts)', async () => { + const root = await mkTmp(); + + await Promise.all([ + write(join(root, 'base', 'a.txt'), 'content\n'), + write(join(root, 'dest', 'a.txt'), 'content\n'), + write(join(root, 'dest', 'extra.txt'), 'merchant addition\n'), + ]); + + // 1 base file, 1 match → 1.0 regardless of extra dest files + expect(await computeBaseSimilarity(join(root, 'base'), join(root, 'dest'))).toBe(1.0); + }); +}); diff --git a/packages/catalyst/src/cli/commands/upgrade.ts b/packages/catalyst/src/cli/commands/upgrade.ts new file mode 100644 index 0000000000..35f16fb867 --- /dev/null +++ b/packages/catalyst/src/cli/commands/upgrade.ts @@ -0,0 +1,245 @@ +import { execa } from 'execa'; +import { access, copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; + +const CorePackageJson = z.object({ + name: z.string().optional(), + version: z.string(), + catalyst: z.object({ version: z.string(), ref: z.string() }).optional(), +}); + +// ── small fs helpers ────────────────────────────────────────────────────────── +const pathExists = (p: string) => + access(p) + .then(() => true) + .catch(() => false); + +async function listFiles(dir: string, prefix = ''): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + + const nested = await Promise.all( + entries.map((entry) => { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + + return entry.isDirectory() ? listFiles(join(dir, entry.name), rel) : Promise.resolve([rel]); + }), + ); + + return nested.flat(); +} + +async function filesEqual(a: string, b: string): Promise { + const [ba, bb] = await Promise.all([ + readFile(a).catch(() => null), + readFile(b).catch(() => null), + ]); + + return ba !== null && bb !== null && ba.equals(bb); +} + +// Returns the fraction of files in baseDir that exist and are identical in +// destDir. Used to validate an inferred base: a correct base scores ~0.7-0.8+ +// (the files the merchant hasn't touched); a wrong base scores much lower. +export async function computeBaseSimilarity(baseDir: string, destDir: string): Promise { + const baseFiles = await listFiles(baseDir); + + if (baseFiles.length === 0) return 0; + + const matches = await Promise.all( + baseFiles.map((rel) => filesEqual(join(baseDir, rel), join(destDir, rel))), + ); + + return matches.filter(Boolean).length / baseFiles.length; +} + +async function isBinary(p: string): Promise { + const buf = await readFile(p).catch(() => null); + + if (!buf) return false; + + // Same heuristic git uses: a NUL byte near the start means binary. + return buf.subarray(0, 8000).includes(0); +} + +async function copyInto(src: string, dest: string): Promise { + await mkdir(dirname(dest), { recursive: true }); + await copyFile(src, dest); +} + +// ── ref parsing ───────────────────────────────────────────────────────────── +// Splits "@bigcommerce/catalyst-core@1.7.0" → { packageName, version }. +// The last "@" separates the version (scoped names start with "@"). +export function parseRef(ref: string): { packageName: string; version: string } { + const lastAt = ref.lastIndexOf('@'); + + if (lastAt <= 0) throw new Error(`Cannot parse ref "${ref}" — expected @.`); + + return { packageName: ref.slice(0, lastAt), version: ref.slice(lastAt + 1) }; +} + +async function readResolvedVersion(coreDir: string): Promise { + const raw = await readFile(join(coreDir, 'package.json'), 'utf-8'); + const pkg = CorePackageJson.parse(JSON.parse(raw)); + + // catalyst.version is the source of truth; older tags lack it and fall back to version. + return pkg.catalyst?.version ?? pkg.version; +} + +// ── per-file 3-way merge engine ─────────────────────────────────────────────── +type MergeOutcome = 'applied' | 'added' | 'deleted' | 'conflicted'; + +// Runs git merge-file (standalone, no object store) writing the merged result +// (with <<>> markers on conflict) to `oursPath`. +async function mergeFile(oursPath: string, basePath: string, theirsPath: string): Promise { + const merged = await execa( + 'git', + [ + 'merge-file', + '-p', + '-L', + 'ours', + '-L', + 'base', + '-L', + 'theirs', + oursPath, + basePath, + theirsPath, + ], + { reject: false, stripFinalNewline: false }, + ); + + // git merge-file exits 0 (clean) or N>0 (N conflict hunks) — both are normal. + // A fatal error (e.g. unreadable file) produces stderr output; guard against + // overwriting the user's file with empty/partial stdout in that case. + if (merged.stderr) throw new Error(`git merge-file failed: ${merged.stderr}`); + + await writeFile(oursPath, merged.stdout); + + return (merged.exitCode ?? 0) > 0; // >0 = conflict hunks +} + +async function mergeModified( + rel: string, + baseDir: string, + theirsDir: string, + catalystRoot: string, +): Promise { + const basePath = join(baseDir, rel); + const theirsPath = join(theirsDir, rel); + const oursPath = join(catalystRoot, rel); + + // Upstream didn't actually change this file — nothing to do. + if (await filesEqual(basePath, theirsPath)) return null; + + // Merchant deleted a file upstream modifies → restore theirs, flag for review. + if (!(await pathExists(oursPath))) { + await copyInto(theirsPath, oursPath); + + return 'conflicted'; + } + + if ((await isBinary(basePath)) || (await isBinary(theirsPath)) || (await isBinary(oursPath))) { + if (await filesEqual(oursPath, basePath)) { + await copyInto(theirsPath, oursPath); + + return 'applied'; + } + + return 'conflicted'; // binary both-changed — can't 3-way; leave ours + } + + return (await mergeFile(oursPath, basePath, theirsPath)) ? 'conflicted' : 'applied'; +} + +async function mergeAdded( + rel: string, + theirsDir: string, + catalystRoot: string, + emptyFile: string, +): Promise { + const theirsPath = join(theirsDir, rel); + const oursPath = join(catalystRoot, rel); + + if (!(await pathExists(oursPath))) { + await copyInto(theirsPath, oursPath); + + return 'added'; + } + + if (await filesEqual(oursPath, theirsPath)) return 'applied'; // merchant already has it + + if ((await isBinary(oursPath)) || (await isBinary(theirsPath))) return 'conflicted'; + + // Both added a different version — merge against an empty ancestor. + const hadConflict = await mergeFile(oursPath, emptyFile, theirsPath); + + return hadConflict ? 'conflicted' : 'applied'; +} + +async function mergeDeleted( + rel: string, + baseDir: string, + catalystRoot: string, +): Promise { + const basePath = join(baseDir, rel); + const oursPath = join(catalystRoot, rel); + + if (!(await pathExists(oursPath))) return null; // already gone + + // Merchant kept upstream's version → safe to delete. Otherwise it's a + // delete/modify conflict: keep ours and flag. + if (await filesEqual(oursPath, basePath)) { + await rm(oursPath, { force: true }); + + return 'deleted'; + } + + return 'conflicted'; +} + +export interface MergeResult { + applied: string[]; + added: string[]; + deleted: string[]; + conflicted: string[]; +} + +// Per-file engine: walk the changed-file set and merge each file with +// `git merge-file`. Needs no git object store, so it works on shallow / no-history +// merchant repos. Trade-off: no rename detection (a rename = delete + add). +export async function mergeCorePerFile( + baseDir: string, + theirsDir: string, + catalystRoot: string, + emptyFile: string, +): Promise { + const baseFiles = new Set(await listFiles(baseDir)); + const theirsFiles = new Set(await listFiles(theirsDir)); + const result: MergeResult = { applied: [], added: [], deleted: [], conflicted: [] }; + + const decide = async (rel: string): Promise => { + const inBase = baseFiles.has(rel); + const inTheirs = theirsFiles.has(rel); + + let outcome: MergeOutcome | null; + + if (inBase && inTheirs) { + outcome = await mergeModified(rel, baseDir, theirsDir, catalystRoot); + } else if (inTheirs) { + outcome = await mergeAdded(rel, theirsDir, catalystRoot, emptyFile); + } else { + outcome = await mergeDeleted(rel, baseDir, catalystRoot); + } + + if (outcome) result[outcome].push(rel); + }; + + await Promise.all([...new Set([...baseFiles, ...theirsFiles])].map(decide)); + + return result; +} + +// Re-export readResolvedVersion so later PRs can use it without re-importing. +export { readResolvedVersion }; From 0533f3a1185d64f22479f77e7d2dec01d97e4987 Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Fri, 26 Jun 2026 13:00:48 -0600 Subject: [PATCH 79/90] feat: TRAC-925 upgrade: whole-tree merge engine and index staging Adds the whole-tree merge engine (`git merge-tree --write-tree`) as the default strategy, plus `applyIndexState` which registers conflicts as real unmerged index entries so editors surface the merge UI. Refs TRAC-925 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../catalyst/src/cli/commands/upgrade.spec.ts | 291 +++++++++++++++++- packages/catalyst/src/cli/commands/upgrade.ts | 282 ++++++++++++++++- 2 files changed, 570 insertions(+), 3 deletions(-) diff --git a/packages/catalyst/src/cli/commands/upgrade.spec.ts b/packages/catalyst/src/cli/commands/upgrade.spec.ts index cc330078de..81c3a9fcdc 100644 --- a/packages/catalyst/src/cli/commands/upgrade.spec.ts +++ b/packages/catalyst/src/cli/commands/upgrade.spec.ts @@ -1,9 +1,18 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { execa } from 'execa'; +import { execSync } from 'node:child_process'; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; -import { computeBaseSimilarity, parseRef } from './upgrade'; +import { + applyIndexState, + computeBaseSimilarity, + mergeCorePerFile, + mergeCoreTree, + parseRef, + resolveStrategy, +} from './upgrade'; const createdDirs: string[] = []; @@ -20,6 +29,11 @@ async function write(file: string, content: string | Buffer): Promise { await writeFile(file, content); } +const exists = (p: string) => + access(p) + .then(() => true) + .catch(() => false); + afterEach(async () => { await Promise.all(createdDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -106,3 +120,276 @@ describe('computeBaseSimilarity', () => { expect(await computeBaseSimilarity(join(root, 'base'), join(root, 'dest'))).toBe(1.0); }); }); + +// merge-tree --write-tree (the whole-tree engine) needs git >= 2.38. Gate its +// tests so they're skipped rather than failing on an older git. +const SUPPORTS_TREE = (() => { + try { + const match = /(\d+)\.(\d+)/.exec(execSync('git --version').toString()); + + return !!match && (Number(match[1]) > 2 || (Number(match[1]) === 2 && Number(match[2]) >= 38)); + } catch { + return false; + } +})(); + +const engines: Array<'per-file' | 'tree'> = SUPPORTS_TREE ? ['per-file', 'tree'] : ['per-file']; + +describe('resolveStrategy', () => { + test('passes explicit engines through unchanged', async () => { + expect(await resolveStrategy('per-file')).toBe('per-file'); + expect(await resolveStrategy('tree')).toBe('tree'); + }); + + test('auto resolves to a concrete engine', async () => { + expect(['tree', 'per-file']).toContain(await resolveStrategy('auto')); + }); +}); + +describe('applyIndexState', () => { + test('pre-stages clean changes and marks conflicts as unmerged', async () => { + const root = await mkTmp(); + const gitRoot = join(root, 'repo'); + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + + await Promise.all([ + mkdir(gitRoot, { recursive: true }), + mkdir(baseDir, { recursive: true }), + mkdir(theirsDir, { recursive: true }), + ]); + await execa('git', ['init', '-q'], { cwd: gitRoot }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: gitRoot }); + await execa('git', ['config', 'user.name', 't'], { cwd: gitRoot }); + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: gitRoot }); + + // Committed (ours) state. `cf-added` is added by the merchant (add/add); + // `cf-deleted` is absent (merchant deleted it → modify/delete). + await Promise.all([ + write(join(gitRoot, 'applied.txt'), 'base\n'), + write(join(gitRoot, 'conflict.txt'), 'ours\n'), + write(join(gitRoot, 'cf-added.txt'), 'ours-added\n'), + write(join(gitRoot, 'todelete.txt'), 'base\n'), + write(join(gitRoot, 'package.json'), '{}\n'), + ]); + await execa('git', ['add', '-A'], { cwd: gitRoot }); + await execa('git', ['commit', '-qm', 'ours'], { cwd: gitRoot }); + + // base + theirs trees the merge ran against. + await Promise.all([ + write(join(baseDir, 'applied.txt'), 'base\n'), + write(join(baseDir, 'conflict.txt'), 'base\n'), + write(join(baseDir, 'cf-deleted.txt'), 'base\n'), + write(join(baseDir, 'todelete.txt'), 'base\n'), + write(join(theirsDir, 'applied.txt'), 'upstream\n'), + write(join(theirsDir, 'conflict.txt'), 'theirs\n'), + write(join(theirsDir, 'cf-added.txt'), 'theirs-added\n'), + write(join(theirsDir, 'cf-deleted.txt'), 'theirs\n'), + write(join(theirsDir, 'added.txt'), 'new\n'), + ]); + + // Worktree as the engine + ref-stamp would have left it. + await Promise.all([ + write(join(gitRoot, 'applied.txt'), 'upstream\n'), + write(join(gitRoot, 'conflict.txt'), '<<<<<<< ours\nours\n=======\ntheirs\n>>>>>>> theirs\n'), + write( + join(gitRoot, 'cf-added.txt'), + '<<<<<<< ours\nours-added\n=======\ntheirs-added\n>>>>>>> theirs\n', + ), + write(join(gitRoot, 'cf-deleted.txt'), 'theirs\n'), + write(join(gitRoot, 'added.txt'), 'new\n'), + write(join(gitRoot, 'package.json'), '{ "catalyst": {} }\n'), + rm(join(gitRoot, 'todelete.txt'), { force: true }), + ]); + + await applyIndexState( + gitRoot, + '.', + baseDir, + theirsDir, + { + applied: ['applied.txt'], + added: ['added.txt'], + deleted: ['todelete.txt'], + conflicted: ['conflict.txt', 'cf-added.txt', 'cf-deleted.txt'], + }, + true, + ); + + const status = (await execa('git', ['status', '--porcelain'], { cwd: gitRoot })).stdout; + const line = (file: string) => status.split('\n').find((l) => l.endsWith(file)) ?? ''; + + expect(line('applied.txt').startsWith('M')).toBe(true); // staged modify + expect(line('added.txt').startsWith('A')).toBe(true); // staged add + expect(line('todelete.txt').startsWith('D')).toBe(true); // staged delete + expect(line('package.json').startsWith('M')).toBe(true); // stamped ref, staged + expect(line('conflict.txt').startsWith('UU')).toBe(true); // both modified + expect(line('cf-added.txt').startsWith('AA')).toBe(true); // both added + expect(line('cf-deleted.txt').startsWith('DU')).toBe(true); // deleted by us + }, 15_000); +}); + +// Both engines must satisfy the same MergeResult contract. +describe.each(engines)('mergeCore engine: %s', (engine) => { + interface Fixture { + base?: Record; + theirs?: Record; + ours?: Record; + } + + async function setup(fixture: Fixture) { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + const oursDir = join(root, 'ours'); + const emptyFile = join(root, '.empty'); + + await Promise.all([ + mkdir(baseDir, { recursive: true }), + mkdir(theirsDir, { recursive: true }), + mkdir(oursDir, { recursive: true }), + ]); + await write(emptyFile, ''); + await Promise.all([ + ...Object.entries(fixture.base ?? {}).map(([k, v]) => write(join(baseDir, k), v)), + ...Object.entries(fixture.theirs ?? {}).map(([k, v]) => write(join(theirsDir, k), v)), + ...Object.entries(fixture.ours ?? {}).map(([k, v]) => write(join(oursDir, k), v)), + ]); + + return { baseDir, theirsDir, oursDir, emptyFile }; + } + + const run = (baseDir: string, theirsDir: string, oursDir: string, emptyFile: string) => + engine === 'tree' + ? mergeCoreTree(baseDir, theirsDir, oursDir) + : mergeCorePerFile(baseDir, theirsDir, oursDir, emptyFile); + + test('clean modify: upstream change applies when ours == base', async () => { + const { baseDir, theirsDir, oursDir, emptyFile } = await setup({ + base: { 'a.txt': 'one\ntwo\n' }, + theirs: { 'a.txt': 'one\ntwo-upstream\n' }, + ours: { 'a.txt': 'one\ntwo\n' }, + }); + + const result = await run(baseDir, theirsDir, oursDir, emptyFile); + + expect(result.applied).toContain('a.txt'); + expect(result.conflicted).toHaveLength(0); + expect(await readFile(join(oursDir, 'a.txt'), 'utf-8')).toBe('one\ntwo-upstream\n'); + }); + + test('overlapping modify: writes conflict markers, never throws', async () => { + const { baseDir, theirsDir, oursDir, emptyFile } = await setup({ + base: { 'a.txt': 'line\n' }, + theirs: { 'a.txt': 'line-upstream\n' }, + ours: { 'a.txt': 'line-merchant\n' }, + }); + + const result = await run(baseDir, theirsDir, oursDir, emptyFile); + + expect(result.conflicted).toContain('a.txt'); + + const merged = await readFile(join(oursDir, 'a.txt'), 'utf-8'); + + expect(merged).toContain('<<<<<<< ours'); + expect(merged).toContain('>>>>>>> theirs'); + }); + + test('added file is copied in', async () => { + const { baseDir, theirsDir, oursDir, emptyFile } = await setup({ + theirs: { 'new.ts': 'export const x = 1;\n' }, + }); + + const result = await run(baseDir, theirsDir, oursDir, emptyFile); + + expect(result.added).toContain('new.ts'); + expect(await readFile(join(oursDir, 'new.ts'), 'utf-8')).toBe('export const x = 1;\n'); + }); + + test('deleted upstream + unmodified locally is removed', async () => { + const { baseDir, theirsDir, oursDir, emptyFile } = await setup({ + base: { 'gone.ts': 'old\n' }, + ours: { 'gone.ts': 'old\n' }, + }); + + const result = await run(baseDir, theirsDir, oursDir, emptyFile); + + expect(result.deleted).toContain('gone.ts'); + expect(await exists(join(oursDir, 'gone.ts'))).toBe(false); + }); + + test('modify/delete: merchant-deleted file upstream modified is restored + flagged', async () => { + const { baseDir, theirsDir, oursDir, emptyFile } = await setup({ + base: { 'keep.ts': 'v1\n' }, + theirs: { 'keep.ts': 'v2\n' }, + // ours is missing keep.ts (merchant deleted it) + }); + + const result = await run(baseDir, theirsDir, oursDir, emptyFile); + + expect(result.conflicted).toContain('keep.ts'); + expect(await readFile(join(oursDir, 'keep.ts'), 'utf-8')).toBe('v2\n'); + }); + + test('binary change applies when ours == base (no corruption)', async () => { + const baseBin = Buffer.from([0x89, 0x50, 0x00, 0x01]); + const theirsBin = Buffer.from([0x89, 0x50, 0x00, 0x02, 0x03]); + const { baseDir, theirsDir, oursDir, emptyFile } = await setup({ + base: { 'img.png': baseBin }, + theirs: { 'img.png': theirsBin }, + ours: { 'img.png': baseBin }, + }); + + const result = await run(baseDir, theirsDir, oursDir, emptyFile); + + expect(result.applied).toContain('img.png'); + expect(await readFile(join(oursDir, 'img.png'))).toEqual(theirsBin); + }); + + test('identical versions produce no changes', async () => { + const { baseDir, theirsDir, oursDir, emptyFile } = await setup({ + base: { 'a.txt': 'same\n' }, + theirs: { 'a.txt': 'same\n' }, + ours: { 'a.txt': 'same\n' }, + }); + + const result = await run(baseDir, theirsDir, oursDir, emptyFile); + + expect(result.applied).toHaveLength(0); + expect(result.added).toHaveLength(0); + expect(result.deleted).toHaveLength(0); + expect(result.conflicted).toHaveLength(0); + }); +}); + +// The whole-tree engine's headline advantage over per-file: it follows renames +// and merges local edits across them (per-file would see delete + add instead). +describe.skipIf(!SUPPORTS_TREE)('mergeCoreTree rename fidelity', () => { + test('follows a rename and carries a local edit across it', async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + const oursDir = join(root, 'ours'); + + await Promise.all([ + mkdir(baseDir, { recursive: true }), + mkdir(theirsDir, { recursive: true }), + mkdir(oursDir, { recursive: true }), + ]); + + // theirs renames old.ts → new.ts; ours edits old.ts in place. A rename-aware + // merge lands the edit in new.ts; per-file would hit a modify/delete conflict. + await Promise.all([ + write(join(baseDir, 'old.ts'), 'a\nb\nc\nd\ne\nf\n'), + write(join(oursDir, 'old.ts'), 'a\nb\nc\nd\ne-merchant\nf\n'), + write(join(theirsDir, 'new.ts'), 'a\nb\nc\nd\ne\nf\n'), + ]); + + const result = await mergeCoreTree(baseDir, theirsDir, oursDir); + + expect(await exists(join(oursDir, 'new.ts'))).toBe(true); + expect(await exists(join(oursDir, 'old.ts'))).toBe(false); + expect(result.conflicted).toHaveLength(0); + expect(await readFile(join(oursDir, 'new.ts'), 'utf-8')).toBe('a\nb\nc\nd\ne-merchant\nf\n'); + }); +}); diff --git a/packages/catalyst/src/cli/commands/upgrade.ts b/packages/catalyst/src/cli/commands/upgrade.ts index 35f16fb867..479f484e81 100644 --- a/packages/catalyst/src/cli/commands/upgrade.ts +++ b/packages/catalyst/src/cli/commands/upgrade.ts @@ -1,8 +1,20 @@ import { execa } from 'execa'; -import { access, copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { + access, + copyFile, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { z } from 'zod'; +import { consola } from '../lib/logger'; + const CorePackageJson = z.object({ name: z.string().optional(), version: z.string(), @@ -241,5 +253,273 @@ export async function mergeCorePerFile( return result; } +// ── merge strategy selection ─────────────────────────────────────────────────── +export type MergeStrategy = 'auto' | 'tree' | 'per-file'; + +// `git merge-tree --write-tree` (the whole-tree engine) landed in git 2.38. +export async function gitSupportsMergeTree(): Promise { + try { + const { stdout } = await execa('git', ['--version']); + const match = /(\d+)\.(\d+)/.exec(stdout); + + if (!match) return false; + + const [major, minor] = [Number(match[1]), Number(match[2])]; + + return major > 2 || (major === 2 && minor >= 38); + } catch { + return false; + } +} + +// 'auto' resolves to the whole-tree engine when git supports it, else per-file. +export async function resolveStrategy(strategy: MergeStrategy): Promise<'tree' | 'per-file'> { + if (strategy !== 'auto') return strategy; + + if (await gitSupportsMergeTree()) return 'tree'; + + consola.warn('git < 2.38 — using the per-file merge engine (no `git merge-tree`).'); + + return 'per-file'; +} + +// ── whole-tree 3-way merge engine (git merge-tree, 2.38+) ────────────────────── +// Builds base/ours/theirs as commits in a throwaway object store, runs a real +// recursive merge (rename detection, modify/delete, mode changes, binary — all +// native to git), then materialises the merged tree into the catalyst root. +// Conflicts come back as in-blob <<>> markers; like per-file it +// never aborts. Branch names `ours`/`theirs` make the markers match the per-file +// engine's labels. +export async function mergeCoreTree( + baseDir: string, + theirsDir: string, + catalystRoot: string, +): Promise { + const scratch = await mkdtemp(join(tmpdir(), 'catalyst-merge-')); + + try { + await execa('git', ['init', '-q', scratch]); + // Disable line-ending conversion so checkout-index writes LF on all platforms. + await execa('git', ['config', 'core.autocrlf', 'false'], { + env: { ...process.env, GIT_DIR: join(scratch, '.git') }, + }); + + const env = { + ...process.env, + GIT_DIR: join(scratch, '.git'), + GIT_AUTHOR_NAME: 'catalyst', + GIT_AUTHOR_EMAIL: 'catalyst@bigcommerce.com', + GIT_COMMITTER_NAME: 'catalyst', + GIT_COMMITTER_EMAIL: 'catalyst@bigcommerce.com', + }; + + // Snapshot a directory as a tree object (own index per side; .gitignore is + // honoured, so build artifacts like node_modules never enter the tree). + const writeTree = async (workTree: string, tag: string): Promise => { + const sideEnv = { + ...env, + GIT_WORK_TREE: workTree, + GIT_INDEX_FILE: join(scratch, `index.${tag}`), + }; + + await execa('git', ['add', '-A'], { env: sideEnv }); + + return (await execa('git', ['write-tree'], { env: sideEnv })).stdout.trim(); + }; + + const commitTree = (tree: string, parent?: string) => + execa('git', ['commit-tree', tree, '-m', 'x', ...(parent ? ['-p', parent] : [])], { + env, + }).then((r) => r.stdout.trim()); + + const [baseTree, oursTree, theirsTree] = await Promise.all([ + writeTree(baseDir, 'base'), + writeTree(catalystRoot, 'ours'), + writeTree(theirsDir, 'theirs'), + ]); + + const baseCommit = await commitTree(baseTree); + const [oursCommit, theirsCommit] = await Promise.all([ + commitTree(oursTree, baseCommit), + commitTree(theirsTree, baseCommit), + ]); + + await Promise.all([ + execa('git', ['branch', 'ours', oursCommit], { env }), + execa('git', ['branch', 'theirs', theirsCommit], { env }), + ]); + + // -z --name-only output: NUL, then conflicted paths each + // NUL-terminated, then an empty field marks the end of the conflicted set + // (everything after is informational messages we don't need). + const merge = await execa( + 'git', + [ + 'merge-tree', + '--write-tree', + '-z', + '--name-only', + `--merge-base=${baseCommit}`, + 'ours', + 'theirs', + ], + { env, reject: false }, + ); + + if ((merge.exitCode ?? 0) > 1) + throw new Error(merge.stderr || merge.stdout.slice(0, 500) || 'git merge-tree failed'); + + // -z --name-only emits , then conflicted paths, then an + // empty field; everything after that is informational and ignored. + const fields = merge.stdout.split('\0'); + const mergedTree = fields[0]; + const afterOid = fields.slice(1); + const emptyAt = afterOid.indexOf(''); + const conflicted = afterOid.slice(0, emptyAt === -1 ? afterOid.length : emptyAt); + const conflictedSet = new Set(conflicted); + + // Materialise the merged tree into the catalyst root (conflict markers are + // already baked into the conflicted blobs). + const outEnv = { + ...env, + GIT_WORK_TREE: catalystRoot, + GIT_INDEX_FILE: join(scratch, 'index.out'), + }; + + await execa('git', ['read-tree', mergedTree], { env: outEnv }); + await execa('git', ['checkout-index', '-a', '-f'], { env: outEnv }); + + // Classify ours → merged. -z --name-status emits [status, path, status, …]. + const diff = await execa( + 'git', + ['diff', '-z', '--no-renames', '--name-status', oursTree, mergedTree], + { env }, + ); + + const tokens = diff.stdout.split('\0'); + const changes: Array<{ status: string; path: string }> = []; + + for (let i = 0; i + 1 < tokens.length; i += 2) { + const status = tokens[i]; + const path = tokens[i + 1]; + + if (status && path && !conflictedSet.has(path)) { + changes.push({ status, path }); + } + } + + // checkout-index only writes; it won't remove paths the merge dropped. + const deleted = changes.filter((change) => change.status === 'D').map((change) => change.path); + + await Promise.all(deleted.map((path) => rm(join(catalystRoot, path), { force: true }))); + + return { + applied: changes + .filter((change) => change.status !== 'A' && change.status !== 'D') + .map((change) => change.path), + added: changes.filter((change) => change.status === 'A').map((change) => change.path), + deleted, + conflicted, + }; + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} + +// ── index staging ────────────────────────────────────────────────────────────── +// After the merge, stage everything that merged cleanly (so the merchant only +// reviews what needs attention) and register conflicted files as real unmerged +// index entries: stage 1 = base, 2 = ours (the committed pre-upgrade blob), +// 3 = theirs. `git status` then reports them as conflicts (UU / AA / DU), which +// lights up editors' merge UIs. No MERGE_HEAD — the target tag isn't an ancestor, +// so resolving is a normal `git add` + commit, not a merge commit. +export async function applyIndexState( + gitRoot: string, + relDir: string, + baseDir: string, + theirsDir: string, + result: MergeResult, + stampedPkg: boolean, +): Promise { + const toRepoPath = (rel: string) => (relDir === '.' ? rel : `${relDir}/${rel}`); + const pkgRel = 'package.json'; + + // If the stamp resolved package.json (stripped its conflict markers and wrote + // clean JSON), treat it as a clean file rather than a conflict — it should be + // staged normally so it shows up in `git diff --cached`, not registered as UU. + const stillConflicted = result.conflicted.filter((rel) => !(stampedPkg && rel === pkgRel)); + const conflictedRepoPaths = new Set(stillConflicted.map(toRepoPath)); + + // 1. Pre-stage the clean changes (+ the resolved package.json when stamped). + const clean = [...result.applied, ...result.added, ...result.deleted].map(toRepoPath); + + if (stampedPkg) clean.push(toRepoPath(pkgRel)); + + const toStage = [...new Set(clean)].filter((path) => !conflictedRepoPaths.has(path)); + + if (toStage.length) { + await execa('git', ['add', '-A', '--', ...toStage], { cwd: gitRoot }); + } + + if (stillConflicted.length === 0) return; + + // 2. Conflicts → unmerged index entries. ours = the committed blob (the + // clean-tree precondition guarantees the worktree matched HEAD before the + // merge); base/theirs blobs get written into the repo's object store. + const OID = /^[0-9a-f]{40,64}$/; + // Detect SHA-256 repos (git 2.29+). SHA-1 uses a 40-zero null OID; SHA-256 uses 64. + const gitFormat = ( + await execa('git', ['rev-parse', '--show-object-format'], { cwd: gitRoot, reject: false }) + ).stdout.trim(); + const NULL_OID = gitFormat === 'sha256' ? '0'.repeat(64) : '0'.repeat(40); + + // Write a file's content as a blob; null if the file is absent or the OID + // comes back malformed (guards against shell wrappers polluting stdout). + const hashBlob = async (file: string): Promise => { + if (!(await pathExists(file))) return null; + + const oid = ( + await execa('git', ['hash-object', '-w', '--', file], { cwd: gitRoot }) + ).stdout.trim(); + + return OID.test(oid) ? oid : null; + }; + + // ours mode + blob via `ls-tree`. Unlike `rev-parse HEAD:`, ls-tree + // never echoes a missing path back as if it were an object name. + const headEntry = async (repoPath: string): Promise<{ mode: string; oid: string } | null> => { + const { stdout } = await execa('git', ['ls-tree', 'HEAD', '--', repoPath], { + cwd: gitRoot, + reject: false, + }); + const match = /^(\d{6}) blob ([0-9a-f]+)\t/.exec(stdout); + + return match ? { mode: match[1], oid: match[2] } : null; + }; + + const records = await Promise.all( + stillConflicted.map(async (rel) => { + const repoPath = toRepoPath(rel); + const [base, ours, theirs] = await Promise.all([ + hashBlob(join(baseDir, rel)), + headEntry(repoPath), + hashBlob(join(theirsDir, rel)), + ]); + + return [ + `0 ${NULL_OID}\t${repoPath}`, + ...(base ? [`100644 ${base} 1\t${repoPath}`] : []), + ...(ours ? [`${ours.mode} ${ours.oid} 2\t${repoPath}`] : []), + ...(theirs ? [`100644 ${theirs} 3\t${repoPath}`] : []), + ].join('\n'); + }), + ); + + await execa('git', ['update-index', '--index-info'], { + cwd: gitRoot, + input: `${records.join('\n')}\n`, + }); +} + // Re-export readResolvedVersion so later PRs can use it without re-importing. export { readResolvedVersion }; From b8af097c494f8893a6de2c8099069d020326271e Mon Sep 17 00:00:00 2001 From: Chancellor Clark Date: Fri, 26 Jun 2026 13:01:43 -0600 Subject: [PATCH 80/90] feat: TRAC-926 upgrade: CLI command, project resolution, download, and tests Completes the `catalyst upgrade` command: project layout detection, tarball download/cache, base ref resolution, the full CLI action, and the integration and action-level test suite. Refs TRAC-926, TRAC-927 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .changeset/cli-upgrade-command.md | 5 + .../src/cli/commands/upgrade.action.spec.ts | 343 +++++++++++ .../cli/commands/upgrade.integration.spec.ts | 406 +++++++++++++ .../catalyst/src/cli/commands/upgrade.spec.ts | 137 ++++- packages/catalyst/src/cli/commands/upgrade.ts | 562 +++++++++++++++++- packages/catalyst/src/cli/program.ts | 2 + 6 files changed, 1443 insertions(+), 12 deletions(-) create mode 100644 .changeset/cli-upgrade-command.md create mode 100644 packages/catalyst/src/cli/commands/upgrade.action.spec.ts create mode 100644 packages/catalyst/src/cli/commands/upgrade.integration.spec.ts diff --git a/.changeset/cli-upgrade-command.md b/.changeset/cli-upgrade-command.md new file mode 100644 index 0000000000..bfda79fa0d --- /dev/null +++ b/.changeset/cli-upgrade-command.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Add `catalyst upgrade` command for upgrading a Catalyst project to a newer version via a 3-way merge. The command downloads the base and target version tarballs, runs a whole-tree `git merge-tree` merge (falling back to per-file `git merge-file` on older git), and applies the result directly to the project — never aborting, always producing resolvable `<<>>` markers for conflicts. Clean changes are pre-staged; conflicts are registered as real unmerged index entries so editors surface the merge UI. Supports flat and nested repo layouts, integration tag families (`--ref`), dry-run preview, explicit base override (`--from`), and alternate source repos (`--repository`). Projects without a `catalyst.ref` tracking field are detected automatically and backfilled. diff --git a/packages/catalyst/src/cli/commands/upgrade.action.spec.ts b/packages/catalyst/src/cli/commands/upgrade.action.spec.ts new file mode 100644 index 0000000000..1b7c9e513d --- /dev/null +++ b/packages/catalyst/src/cli/commands/upgrade.action.spec.ts @@ -0,0 +1,343 @@ +/** + * Action-level tests for `catalyst upgrade`. Invokes upgrade.parseAsync() the + * same way the real CLI does, using process.chdir() to control the working + * directory. Telemetry, logger output, and spinner animations are mocked. + * Downloads use the shared CLI cache so subsequent runs are offline. + */ + +import { execa } from 'execa'; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +// Windows holds git file locks longer than the default 10 s hook timeout. +vi.setConfig({ hookTimeout: 60_000 }); + +import { downloadCore, parseRef, upgrade } from './upgrade'; + +vi.mock('../lib/telemetry', () => ({ getTelemetry: () => ({ track: vi.fn() }) })); +vi.mock('../lib/logger', () => ({ + consola: { log: vi.fn(), success: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})); +vi.mock('yocto-spinner', () => ({ + default: () => ({ start: () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn() }) }), +})); + +const REPO = 'bigcommerce/catalyst'; +const BASE_REF = '@bigcommerce/catalyst-core@1.6.3'; +const TARGET_VERSION = '1.7.0'; +const TARGET_REF = `@bigcommerce/catalyst-core@${TARGET_VERSION}`; +const MAKESWIFT_BASE_REF = '@bigcommerce/catalyst-makeswift@1.2.0'; +const MAKESWIFT_TARGET_REF = '@bigcommerce/catalyst-makeswift@1.3.0'; +const TIMEOUT = 120_000; + +const createdDirs: string[] = []; +let originalCwd: string; + +// JSON.parse returns `any`; centralise the unsafe-return suppression here. +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +const parseJson = (raw: string): Record => JSON.parse(raw); + +async function mkTmp(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'upgrade-action-')); + + createdDirs.push(dir); + + return dir; +} + +beforeEach(() => { + originalCwd = process.cwd(); +}); + +afterEach(async () => { + process.chdir(originalCwd); + vi.restoreAllMocks(); + await Promise.all( + createdDirs + .splice(0) + .map((d) => rm(d, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })), + ); +}); + +async function initGitProject(dir: string): Promise { + await execa('git', ['init', '-q'], { cwd: dir }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: dir }); + await execa('git', ['config', 'user.name', 't'], { cwd: dir }); + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir }); + await execa('git', ['config', 'gc.auto', '0'], { cwd: dir }); + await execa('git', ['add', '-A'], { cwd: dir }); + await execa('git', ['commit', '-qm', 'base'], { cwd: dir }); +} + +// Creates and returns a committed project directory seeded from the 1.6.3 tarball. +// Versions <= 1.7.0 predate LTRAC-466 and have no catalyst.ref field in the +// tarball, so we inject it here when withCatalystRef is true so that action +// tests which don't specifically test the missing-ref path don't hit the +// interactive confirm prompt. +async function setup163Project( + root: string, + opts: { withCatalystRef?: boolean } = {}, +): Promise { + const { withCatalystRef = true } = opts; + const baseDir = join(root, 'base'); + + await downloadCore(REPO, BASE_REF, baseDir); + + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + const pkgPath = join(projectDir, 'package.json'); + const pkg = parseJson(await readFile(pkgPath, 'utf-8')); + + if (withCatalystRef) { + const { version } = parseRef(BASE_REF); + + pkg.catalyst = { version, ref: BASE_REF }; + } else { + delete pkg.catalyst; + } + + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(projectDir); + + return projectDir; +} + +// ── Fast tests (no tarball downloads) ──────────────────────────────────────── + +test('already up to date short-circuits before downloading', async () => { + const root = await mkTmp(); + const projectDir = join(root, 'project'); + + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, 'package.json'), + `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version: TARGET_VERSION, + catalyst: { version: TARGET_VERSION, ref: TARGET_REF }, + }, + null, + 2, + )}\n`, + ); + await initGitProject(projectDir); + process.chdir(projectDir); + + // Resolves cleanly — no download, no exit, no file changes. + await expect(upgrade.parseAsync([TARGET_VERSION], { from: 'user' })).resolves.toBeDefined(); + + const status = (await execa('git', ['status', '--porcelain'], { cwd: projectDir })).stdout; + + expect(status.trim()).toBe(''); + // git init + commit on Windows CI takes longer than the 5s default +}, 15_000); + +test('dirty worktree: uncommitted changes cause exit(1) before any download', async () => { + const root = await mkTmp(); + const projectDir = join(root, 'project'); + + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, 'package.json'), + `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version: '1.6.3', + catalyst: { version: '1.6.3', ref: BASE_REF }, + }, + null, + 2, + )}\n`, + ); + await initGitProject(projectDir); + + // Untracked file → dirty tree. + await writeFile(join(projectDir, 'dirty.ts'), 'export const x = 1;\n'); + process.chdir(projectDir); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect(upgrade.parseAsync([TARGET_VERSION], { from: 'user' })).rejects.toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); +}); + +test('invalid tag: a nonexistent version surfaces a 404 error', async () => { + const root = await mkTmp(); + + // Test downloadCore directly — the action re-throws this error unchanged. + await expect( + downloadCore(REPO, '@bigcommerce/catalyst-core@0.0.1-nonexistent', join(root, 'dest')), + ).rejects.toThrow(/404.*not found/i); +}, 30_000); + +// ── Tests that use real tarballs (cached after first run) ───────────────────── + +test( + '--dry-run prints the diff but leaves the project completely unchanged', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root); + const pkgBefore = await readFile(join(projectDir, 'package.json'), 'utf-8'); + + process.chdir(projectDir); + + await upgrade.parseAsync([TARGET_VERSION, '--dry-run'], { from: 'user' }); + + // Nothing staged or modified. + const status = (await execa('git', ['status', '--porcelain'], { cwd: projectDir })).stdout; + + expect(status.trim()).toBe(''); + expect(await readFile(join(projectDir, 'package.json'), 'utf-8')).toBe(pkgBefore); + }, + TIMEOUT, +); + +test( + 'missing catalyst.ref with --yes infers the base and stamps the target ref', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root, { withCatalystRef: false }); + + process.chdir(projectDir); + + await upgrade.parseAsync([TARGET_VERSION, '--yes'], { from: 'user' }); + + const pkg = parseJson(await readFile(join(projectDir, 'package.json'), 'utf-8')); + + expect(pkg.catalyst).toMatchObject({ ref: TARGET_REF }); + }, + TIMEOUT, +); + +test( + 'running upgrade from inside core/ resolves to the git root and applies correctly', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + + await downloadCore(REPO, BASE_REF, baseDir); + + // Nested layout: Catalyst package lives under /core/. + const projectRoot = join(root, 'project'); + + await mkdir(join(projectRoot, 'core'), { recursive: true }); + await cp(baseDir, join(projectRoot, 'core'), { recursive: true }); + + // 1.6.3 predates LTRAC-466 — inject catalyst.ref so the action doesn't + // hit the interactive confirm prompt. + const corePkgPath = join(projectRoot, 'core', 'package.json'); + const corePkg = parseJson(await readFile(corePkgPath, 'utf-8')); + const { version: baseVersion } = parseRef(BASE_REF); + + corePkg.catalyst = { version: baseVersion, ref: BASE_REF }; + await writeFile(corePkgPath, `${JSON.stringify(corePkg, null, 2)}\n`); + await initGitProject(projectRoot); + + // Simulate running `catalyst upgrade` from inside core/. + process.chdir(join(projectRoot, 'core')); + + await upgrade.parseAsync([TARGET_VERSION], { from: 'user' }); + + const pkg = parseJson(await readFile(join(projectRoot, 'core', 'package.json'), 'utf-8')); + + expect(pkg.catalyst).toMatchObject({ ref: TARGET_REF }); + }, + TIMEOUT, +); + +test( + 'dirty worktree with --dry-run proceeds without error', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root); + + // Staged change — normally blocks the upgrade. + await writeFile(join(projectDir, 'dirty.ts'), 'export const x = 1;\n'); + process.chdir(projectDir); + + // Should resolve without throwing and leave the tree as-is. + await expect( + upgrade.parseAsync([TARGET_VERSION, '--dry-run'], { from: 'user' }), + ).resolves.toBeDefined(); + }, + TIMEOUT, +); + +test( + 'staged-but-uncommitted changes are treated as a dirty worktree and cause exit(1)', + async () => { + const root = await mkTmp(); + const projectDir = await setup163Project(root); + + // Stage a new file without committing — this is dirty too. + await writeFile(join(projectDir, 'staged.ts'), 'export const x = 1;\n'); + await execa('git', ['add', 'staged.ts'], { cwd: projectDir }); + process.chdir(projectDir); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect(upgrade.parseAsync([TARGET_VERSION], { from: 'user' })).rejects.toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + }, + TIMEOUT, +); + +test( + '--from overrides base detection when catalyst.ref is missing', + async () => { + const root = await mkTmp(); + // Start with a project that has no catalyst.ref — normally triggers the + // interactive confirm prompt. Passing --from bypasses it entirely. + const projectDir = await setup163Project(root, { withCatalystRef: false }); + + process.chdir(projectDir); + + await upgrade.parseAsync([TARGET_VERSION, '--from', BASE_REF], { from: 'user' }); + + const pkg = parseJson(await readFile(join(projectDir, 'package.json'), 'utf-8')); + + expect(pkg.catalyst).toMatchObject({ ref: TARGET_REF }); + }, + TIMEOUT, +); + +test( + '--ref flag upgrades a makeswift integration family project to the target tag', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + + await downloadCore(REPO, MAKESWIFT_BASE_REF, baseDir); + + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + // makeswift 1.2.0 also predates LTRAC-466 — inject catalyst.ref. + const pkgPath = join(projectDir, 'package.json'); + const pkg = parseJson(await readFile(pkgPath, 'utf-8')); + const { version: baseVersion } = parseRef(MAKESWIFT_BASE_REF); + + pkg.catalyst = { version: baseVersion, ref: MAKESWIFT_BASE_REF }; + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(projectDir); + + process.chdir(projectDir); + + await upgrade.parseAsync(['--ref', MAKESWIFT_TARGET_REF], { from: 'user' }); + + const updated = parseJson(await readFile(pkgPath, 'utf-8')); + + expect(updated.catalyst).toMatchObject({ ref: MAKESWIFT_TARGET_REF }); + }, + TIMEOUT, +); diff --git a/packages/catalyst/src/cli/commands/upgrade.integration.spec.ts b/packages/catalyst/src/cli/commands/upgrade.integration.spec.ts new file mode 100644 index 0000000000..9bc7ebbd08 --- /dev/null +++ b/packages/catalyst/src/cli/commands/upgrade.integration.spec.ts @@ -0,0 +1,406 @@ +/** + * Integration tests for `catalyst upgrade`. These tests download real Catalyst + * tarballs (using the same cache as the CLI at ~/.cache/catalyst-cli/cores) and + * run the full upgrade pipeline against them. On first run, tarballs are fetched + * from GitHub (~3MB each). Subsequent runs use the on-disk cache and run offline. + */ +import { execa } from 'execa'; +import { execSync } from 'node:child_process'; +import { access, cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.setConfig({ hookTimeout: 60_000 }); + +import { + applyIndexState, + computeBaseSimilarity, + downloadCore, + mergeCorePerFile, + mergeCoreTree, + resolveProject, +} from './upgrade'; + +const SUPPORTS_TREE = (() => { + try { + const match = /(\d+)\.(\d+)/.exec(execSync('git --version').toString()); + + return !!match && (Number(match[1]) > 2 || (Number(match[1]) === 2 && Number(match[2]) >= 38)); + } catch { + return false; + } +})(); + +const engines: Array<'per-file' | 'tree'> = SUPPORTS_TREE ? ['per-file', 'tree'] : ['per-file']; + +const exists = (p: string) => + access(p) + .then(() => true) + .catch(() => false); + +const createdDirs: string[] = []; + +async function mkTmp(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'upgrade-integ-')); + + createdDirs.push(dir); + + return dir; +} + +afterEach(async () => { + await Promise.all( + createdDirs + .splice(0) + .map((d) => rm(d, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })), + ); +}); + +const REPO = 'bigcommerce/catalyst'; +// Versions used in the manual spike testing — known to have a meaningful diff. +const BASE_REF = '@bigcommerce/catalyst-core@1.6.3'; +const TARGET_REF = '@bigcommerce/catalyst-core@1.7.0'; + +const MAKESWIFT_BASE_REF = '@bigcommerce/catalyst-makeswift@1.2.0'; +const MAKESWIFT_TARGET_REF = '@bigcommerce/catalyst-makeswift@1.3.0'; + +async function fetchTarballs(root: string): Promise<{ baseDir: string; theirsDir: string }> { + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + + await Promise.all([ + downloadCore(REPO, BASE_REF, baseDir), + downloadCore(REPO, TARGET_REF, theirsDir), + ]); + + return { baseDir, theirsDir }; +} + +async function initGitProject(dir: string): Promise { + await execa('git', ['init', '-q'], { cwd: dir }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: dir }); + await execa('git', ['config', 'user.name', 't'], { cwd: dir }); + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir }); + // Disable auto-gc so git never spawns background pack processes that would + // still be running when afterEach tries to remove the temp directory. + await execa('git', ['config', 'gc.auto', '0'], { cwd: dir }); + await execa('git', ['add', '-A'], { cwd: dir }); + await execa('git', ['commit', '-qm', 'base'], { cwd: dir }); +} + +// 120s per test to allow cold-cache downloads on first run. +const TIMEOUT = 120_000; + +describe.each(engines)('integration (engine: %s)', (engine) => { + const runMerge = (baseDir: string, theirsDir: string, oursDir: string, emptyFile: string) => + engine === 'tree' + ? mergeCoreTree(baseDir, theirsDir, oursDir) + : mergeCorePerFile(baseDir, theirsDir, oursDir, emptyFile); + + test( + 'clean project upgrades without conflicts and all changes staged', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Project = fresh copy of 1.6.3 with no merchant modifications. + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // No merchant customizations → no conflicts. + expect(result.conflicted).toHaveLength(0); + // Versions aren't identical, so there must be at least one change. + expect(result.applied.length + result.added.length + result.deleted.length).toBeGreaterThan( + 0, + ); + + await applyIndexState(oursDir, '.', baseDir, theirsDir, result, false); + + const status = (await execa('git', ['status', '--porcelain'], { cwd: oursDir })).stdout; + const lines = status.trim().split('\n').filter(Boolean); + + // Every change should be staged (leading column non-space, non-?). + expect(lines.length).toBeGreaterThan(0); + expect(lines.every((l) => !l.startsWith(' ') && !l.startsWith('?'))).toBe(true); + }, + TIMEOUT, + ); + + test( + 'merchant-deleted file that upstream modifies is restored as a conflict', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Start from the base version, then delete package.json — a file upstream always + // modifies (version bump at minimum between 1.6.3 and 1.7.0). + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await rm(join(oursDir, 'package.json'), { force: true }); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // modify/delete: upstream's version should be restored and flagged as conflicted. + expect(result.conflicted).toContain('package.json'); + expect(await exists(join(oursDir, 'package.json'))).toBe(true); + }, + TIMEOUT, + ); + + test( + 'merchant dep addition in a non-overlapping region is preserved after upgrade', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + + const pkgPath = join(oursDir, 'package.json'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const pkg: Record = JSON.parse(await readFile(pkgPath, 'utf-8')); + const deps: Record = {}; + + if (typeof pkg.dependencies === 'object' && pkg.dependencies !== null) { + Object.assign(deps, pkg.dependencies); + } + + deps['some-merchant-package'] = '^1.0.0'; + pkg.dependencies = deps; + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // The merchant dep must be present in the merged output — either cleanly + // applied or in the ours side of any conflict markers. + const merged = await readFile(pkgPath, 'utf-8'); + + expect(merged).toContain('"some-merchant-package"'); + }, + TIMEOUT, + ); + + test( + 're-merging after upgrade with identical base and target produces no changes', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + // First upgrade: base → target (ours now at target state) + await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // Second "upgrade": target → target (no upstream diff) → nothing to do + const idempotentResult = await runMerge(theirsDir, theirsDir, oursDir, emptyFile); + + expect(idempotentResult.applied).toHaveLength(0); + expect(idempotentResult.added).toHaveLength(0); + expect(idempotentResult.deleted).toHaveLength(0); + expect(idempotentResult.conflicted).toHaveLength(0); + }, + TIMEOUT, + ); + + test( + 'flat repo layout — changes land at root and resolveProject detects relDir "."', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Flat layout: extract base tarball contents directly to the repo root (no core/ subdir). + const oursDir = join(root, 'flat-project'); + + await cp(baseDir, oursDir, { recursive: true }); + await initGitProject(oursDir); + + // resolveProject must identify this as a flat layout. + const project = await resolveProject(oursDir); + + expect(project).not.toBeNull(); + expect(project?.relDir).toBe('.'); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // All changed paths should be at the root — no "core/" prefix. + const allPaths = [ + ...result.applied, + ...result.added, + ...result.deleted, + ...result.conflicted, + ]; + + expect(allPaths.some((p) => p.startsWith('core/'))).toBe(false); + + // No nested core/ directory should have been created inside the flat project. + expect(await exists(join(oursDir, 'core'))).toBe(false); + }, + TIMEOUT, + ); + + test( + 'file modified by merchant AND upstream produces conflict markers', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // Merchant project: start from 1.6.3, then change package.json's version + // field — the same field that the 1.6.3 → 1.7.0 diff also touches. + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + + const pkgPath = join(oursDir, 'package.json'); + const raw = await readFile(pkgPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const pkg: Record = JSON.parse(raw); + + pkg.version = 'custom-merchant-version'; // overlaps with upstream's version bump + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + await initGitProject(oursDir); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // package.json was modified by both sides → must be conflicted. + expect(result.conflicted).toContain('package.json'); + + // Confirm the file contains conflict markers. + const merged = await readFile(join(oursDir, 'package.json'), 'utf-8'); + + expect(merged).toContain('<<<<<<< ours'); + expect(merged).toContain('>>>>>>> theirs'); + }, + TIMEOUT, + ); +}); + +describe.each(engines)('integration makeswift family (engine: %s)', (engine) => { + const runMerge = (baseDir: string, theirsDir: string, oursDir: string, emptyFile: string) => + engine === 'tree' + ? mergeCoreTree(baseDir, theirsDir, oursDir) + : mergeCorePerFile(baseDir, theirsDir, oursDir, emptyFile); + + test( + 'clean makeswift project (1.2.0 → 1.3.0) upgrades without conflicts', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + + await Promise.all([ + downloadCore(REPO, MAKESWIFT_BASE_REF, baseDir), + downloadCore(REPO, MAKESWIFT_TARGET_REF, theirsDir), + ]); + + const oursDir = join(root, 'project'); + + await cp(baseDir, oursDir, { recursive: true }); + await execa('git', ['init', '-q'], { cwd: oursDir }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: oursDir }); + await execa('git', ['config', 'user.name', 't'], { cwd: oursDir }); + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: oursDir }); + await execa('git', ['add', '-A'], { cwd: oursDir }); + await execa('git', ['commit', '-qm', 'base'], { cwd: oursDir }); + + const emptyFile = join(root, '.empty'); + + await writeFile(emptyFile, ''); + + const result = await runMerge(baseDir, theirsDir, oursDir, emptyFile); + + // A clean makeswift project should merge without conflicts. + expect(result.conflicted).toHaveLength(0); + expect(result.applied.length + result.added.length + result.deleted.length).toBeGreaterThan( + 0, + ); + }, + TIMEOUT, + ); + + test( + 'computeBaseSimilarity scores higher for the correct makeswift base than a wrong base', + async () => { + const root = await mkTmp(); + const baseDir = join(root, 'base'); + const theirsDir = join(root, 'theirs'); + + await Promise.all([ + downloadCore(REPO, MAKESWIFT_BASE_REF, baseDir), + downloadCore(REPO, MAKESWIFT_TARGET_REF, theirsDir), + ]); + + // "Project" = clean makeswift 1.2.0 (no modifications). + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + // Correct base scores near-perfect (project is an unmodified 1.2.0 copy). + const correctScore = await computeBaseSimilarity(baseDir, projectDir); + // Wrong base (1.3.0) scores lower because files changed between versions. + const wrongScore = await computeBaseSimilarity(theirsDir, projectDir); + + expect(correctScore).toBeGreaterThan(0.9); + expect(wrongScore).toBeLessThan(correctScore); + }, + TIMEOUT, + ); +}); + +test( + 'computeBaseSimilarity: correct base scores higher than a wrong base', + async () => { + const root = await mkTmp(); + const { baseDir, theirsDir } = await fetchTarballs(root); + + // "Project" = clean 1.6.3 (no merchant modifications). + const projectDir = join(root, 'project'); + + await cp(baseDir, projectDir, { recursive: true }); + + // Correct base (1.6.3 vs a fresh 1.6.3 project) → near-perfect similarity. + const correctScore = await computeBaseSimilarity(baseDir, projectDir); + + // Wrong base (1.7.0 vs the same 1.6.3 project) → lower similarity. + const wrongScore = await computeBaseSimilarity(theirsDir, projectDir); + + expect(correctScore).toBeGreaterThan(0.9); + expect(wrongScore).toBeLessThan(correctScore); + }, + TIMEOUT, +); diff --git a/packages/catalyst/src/cli/commands/upgrade.spec.ts b/packages/catalyst/src/cli/commands/upgrade.spec.ts index 81c3a9fcdc..f2b81f347f 100644 --- a/packages/catalyst/src/cli/commands/upgrade.spec.ts +++ b/packages/catalyst/src/cli/commands/upgrade.spec.ts @@ -1,9 +1,14 @@ +import { Command } from '@commander-js/extra-typings'; import { execa } from 'execa'; import { execSync } from 'node:child_process'; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { afterEach, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +// The tree engine runs many git subprocesses sequentially on Windows CI; give +// every test in this file enough headroom (the fast ones finish in < 1 s). +vi.setConfig({ testTimeout: 30_000 }); import { applyIndexState, @@ -11,7 +16,10 @@ import { mergeCorePerFile, mergeCoreTree, parseRef, + resolveBaseRef, + resolveProject, resolveStrategy, + upgrade, } from './upgrade'; const createdDirs: string[] = []; @@ -35,7 +43,16 @@ const exists = (p: string) => .catch(() => false); afterEach(async () => { - await Promise.all(createdDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + await Promise.all( + createdDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })), + ); +}); + +test('properly configured Command instance', () => { + expect(upgrade).toBeInstanceOf(Command); + expect(upgrade.name()).toBe('upgrade'); }); describe('parseRef', () => { @@ -58,6 +75,122 @@ describe('parseRef', () => { }); }); +describe('resolveProject', () => { + async function initRepo(): Promise { + const repo = await mkTmp(); + + await execa('git', ['init', '-q'], { cwd: repo }); + await execa('git', ['config', 'user.email', 't@t.com'], { cwd: repo }); + await execa('git', ['config', 'user.name', 't'], { cwd: repo }); + + return repo; + } + + const catalystPkg = (version: string) => + `${JSON.stringify( + { + name: '@bigcommerce/catalyst-core', + version, + catalyst: { version, ref: `@bigcommerce/catalyst-core@${version}` }, + }, + null, + 2, + )}\n`; + + test('detects a nested (monorepo) layout → relDir "core"', async () => { + const repo = await initRepo(); + + await write(join(repo, 'core', 'package.json'), catalystPkg('1.6.3')); + + const project = await resolveProject(repo); + + expect(project).not.toBeNull(); + expect(project?.relDir).toBe('core'); + expect(project?.catalystRoot.endsWith('core')).toBe(true); + expect(project?.pkg.catalyst?.ref).toBe('@bigcommerce/catalyst-core@1.6.3'); + }); + + test('detects a flat layout → relDir "."', async () => { + const repo = await initRepo(); + + await write(join(repo, 'package.json'), catalystPkg('1.7.0')); + + const project = await resolveProject(repo); + + expect(project).not.toBeNull(); + expect(project?.relDir).toBe('.'); + expect(project?.pkg.catalyst?.ref).toBe('@bigcommerce/catalyst-core@1.7.0'); + }); + + test('returns null outside a git repo', async () => { + const notARepo = await mkTmp(); + + expect(await resolveProject(notARepo)).toBeNull(); + }); +}); + +describe('resolveBaseRef', () => { + // Minimal Project stub — resolveBaseRef only reads `.pkg` (plus its --from / + // --yes args). Conditional spreads keep optional fields truly absent so the + // stub satisfies CorePackageJson under exactOptionalPropertyTypes. + const projectWith = (pkg: { name?: string; version: string; ref?: string }) => ({ + gitRoot: '/repo', + catalystRoot: '/repo/core', + relDir: 'core', + pkgPath: '/repo/core/package.json', + rawContent: '', + pkg: { + ...(pkg.name === undefined ? {} : { name: pkg.name }), + version: pkg.version, + ...(pkg.ref === undefined ? {} : { catalyst: { version: pkg.version, ref: pkg.ref } }), + }, + }); + + test('uses catalyst.ref verbatim when present (ignores --from)', async () => { + expect( + await resolveBaseRef( + projectWith({ + name: '@bigcommerce/catalyst-core', + version: '1.6.3', + ref: '@bigcommerce/catalyst-core@1.6.3', + }), + '@bigcommerce/catalyst-core@1.0.0', + false, + ), + ).toBe('@bigcommerce/catalyst-core@1.6.3'); + }); + + test('missing catalyst.ref: --from wins when provided', async () => { + expect( + await resolveBaseRef( + projectWith({ name: '@bigcommerce/catalyst-core', version: '1.6.3' }), + '@bigcommerce/catalyst-makeswift@1.5.0', + false, + ), + ).toBe('@bigcommerce/catalyst-makeswift@1.5.0'); + }); + + test('missing catalyst.ref (pre-LTRAC-466): infers @ under --yes', async () => { + expect( + await resolveBaseRef( + projectWith({ name: '@bigcommerce/catalyst-makeswift', version: '1.6.3' }), + undefined, + true, + ), + ).toBe('@bigcommerce/catalyst-makeswift@1.6.3'); + }); + + test('missing catalyst.ref + unknown package name: defaults to catalyst-core family', async () => { + expect( + await resolveBaseRef( + projectWith({ name: 'acme-storefront', version: '1.6.3' }), + undefined, + true, + ), + ).toBe('@bigcommerce/catalyst-core@1.6.3'); + }); +}); + describe('computeBaseSimilarity', () => { test('returns 1.0 when all base files match exactly', async () => { const root = await mkTmp(); diff --git a/packages/catalyst/src/cli/commands/upgrade.ts b/packages/catalyst/src/cli/commands/upgrade.ts index 479f484e81..8f0b8ea94c 100644 --- a/packages/catalyst/src/cli/commands/upgrade.ts +++ b/packages/catalyst/src/cli/commands/upgrade.ts @@ -1,19 +1,25 @@ +import { Command, Option } from '@commander-js/extra-typings'; +import { confirm } from '@inquirer/prompts'; import { execa } from 'execa'; import { access, copyFile, + cp, mkdir, mkdtemp, readdir, readFile, + rename, rm, writeFile, } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; import { consola } from '../lib/logger'; +import { getTelemetry } from '../lib/telemetry'; const CorePackageJson = z.object({ name: z.string().optional(), @@ -21,6 +27,27 @@ const CorePackageJson = z.object({ catalyst: z.object({ version: z.string(), ref: z.string() }).optional(), }); +// Catalyst versions are git tags on the upstream monorepo (e.g. +// "@bigcommerce/catalyst-core@1.7.0"), NOT npm packages. GitHub serves a +// tarball for any tag via the repos///tarball/ endpoint. +const DEFAULT_REPOSITORY = 'bigcommerce/catalyst'; + +// Moving tags that don't pin a concrete version. When the target is one of +// these we resolve the real version from the downloaded core/package.json so +// catalyst.ref records a stable pin rather than the alias. +const MOVING_TAGS = new Set(['latest', 'canary', 'alpha']); + +// Known Catalyst package families, used to reconstruct a base ref from a +// project's package.json `name` when `catalyst.ref` is missing. +const KNOWN_FAMILIES = new Set([ + '@bigcommerce/catalyst-core', + '@bigcommerce/catalyst-makeswift', + '@bigcommerce/catalyst-b2b-makeswift', + '@bigcommerce/catalyst-b2b', +]); + +const CACHE_DIR = join(homedir(), '.cache', 'catalyst-cli', 'cores'); + // ── small fs helpers ────────────────────────────────────────────────────────── const pathExists = (p: string) => access(p) @@ -332,11 +359,11 @@ export async function mergeCoreTree( env, }).then((r) => r.stdout.trim()); - const [baseTree, oursTree, theirsTree] = await Promise.all([ - writeTree(baseDir, 'base'), - writeTree(catalystRoot, 'ours'), - writeTree(theirsDir, 'theirs'), - ]); + // Run sequentially to avoid concurrent writes to the shared object store + // on Windows, where simultaneous git processes cause EPERM on object files. + const baseTree = await writeTree(baseDir, 'base'); + const oursTree = await writeTree(catalystRoot, 'ours'); + const theirsTree = await writeTree(theirsDir, 'theirs'); const baseCommit = await commitTree(baseTree); const [oursCommit, theirsCommit] = await Promise.all([ @@ -422,7 +449,7 @@ export async function mergeCoreTree( conflicted, }; } finally { - await rm(scratch, { recursive: true, force: true }); + await rm(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); } } @@ -521,5 +548,520 @@ export async function applyIndexState( }); } -// Re-export readResolvedVersion so later PRs can use it without re-importing. -export { readResolvedVersion }; +// ── project (destination) layout resolution ─────────────────────────────────── +// The command is merchant-facing and must support two repo shapes: +// * flat (future) — core/ contents at the repo root (package.json at root) +// * nested (deprecated monorepo clone) — core/package.json under /core +// We locate the package.json carrying the `catalyst` field; if none has it yet +// we fall back to the most likely Catalyst package.json so the auto-detect path +// can still run. +interface Project { + gitRoot: string; + catalystRoot: string; + relDir: string; // "." (flat) or "core" (nested), relative to gitRoot + pkgPath: string; + rawContent: string; + pkg: z.infer; +} + +export async function resolveProject(cwd: string): Promise { + let gitRoot: string; + + try { + gitRoot = (await execa('git', ['rev-parse', '--show-toplevel'], { cwd })).stdout.trim(); + } catch { + return null; + } + + // Probe order favours the nested core/ first (a monorepo clone also has a + // root package.json we don't want), then flat, then the cwd variants. + const candidateDirs = [...new Set([join(gitRoot, 'core'), gitRoot, join(cwd, 'core'), cwd])]; + + const parsed = ( + await Promise.all( + candidateDirs.map(async (dir) => { + const pkgPath = join(dir, 'package.json'); + const raw = await readFile(pkgPath, 'utf-8').catch(() => null); + + if (!raw) return null; + + let pkgJson: unknown; + + try { + pkgJson = JSON.parse(raw); + } catch { + return null; + } + + const result = CorePackageJson.safeParse(pkgJson); + + return result.success ? { dir, pkgPath, raw, pkg: result.data } : null; + }), + ) + ).filter((entry) => entry !== null); + + if (parsed.length === 0) return null; + + // Prefer the package.json that already declares `catalyst`; otherwise fall + // back to one that looks like a Catalyst project (known family name). + const chosen = + parsed.find((p) => p.pkg.catalyst?.ref) ?? + parsed.find((p) => p.pkg.name !== undefined && KNOWN_FAMILIES.has(p.pkg.name)) ?? + parsed[0]; + + return { + gitRoot, + catalystRoot: chosen.dir, + relDir: relative(gitRoot, chosen.dir) || '.', + pkgPath: chosen.pkgPath, + rawContent: chosen.raw, + pkg: chosen.pkg, + }; +} + +// ── tarball download (source side stays the monorepo) ───────────────────────── +export async function downloadCore( + repository: string, + ref: string, + destDir: string, + token?: string, +): Promise { + // Never cache moving tags (latest/canary/alpha) — they'd go stale silently. + const cacheable = !MOVING_TAGS.has(parseRef(ref).version); + // Use a separator before the ref so that repository strings differing only + // by characters that sanitize to '_' (e.g. '/' vs '_') produce distinct keys. + const cacheKey = `${repository.replace(/[^a-zA-Z0-9._-]/g, '_')}__${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`; + const cachePath = join(CACHE_DIR, cacheKey); + + // Guard against a partially-written cache entry: two concurrent downloadCore + // calls for the same ref can race — the first creates cachePath mid-copy, and + // the second sees it as present but gets an incomplete directory. Validate with + // a package.json sentinel (every extracted core/ tree must have one). + if ( + cacheable && + (await pathExists(cachePath)) && + (await pathExists(join(cachePath, 'package.json'))) + ) { + await cp(cachePath, destDir, { recursive: true }); + + return; + } + + // encodeURIComponent turns "@bigcommerce/catalyst-core@1.7.0" into + // "%40bigcommerce%2Fcatalyst-core%401.7.0", which the API accepts. + const url = `https://api.github.com/repos/${repository}/tarball/${encodeURIComponent(ref)}`; + const headers: Record = { + 'User-Agent': 'catalyst-cli', + Accept: 'application/vnd.github+json', + }; + + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(url, { headers }); + + if (!res.ok) { + let hint = ''; + + if (res.status === 404) hint = ` — tag "${ref}" not found in ${repository}`; + if (res.status === 403) hint = ' — GitHub rate limit hit; set GITHUB_TOKEN to raise it'; + + throw new Error(`GitHub returned ${res.status} for ${ref}${hint}`); + } + + const tarballPath = `${destDir}.tgz`; + const rawDir = `${destDir}.raw`; + + await writeFile(tarballPath, Buffer.from(await res.arrayBuffer())); + await mkdir(rawDir, { recursive: true }); + // --strip-components=1 drops the "bigcommerce-catalyst-/" top dir. + await execa('tar', ['-xzf', tarballPath, '-C', rawDir, '--strip-components=1']); + + // Source is expected to be the monorepo (core/ inside); fall back to the + // extracted root for a hypothetical flat-source tag (insurance, not a target). + const coreDir = join(rawDir, 'core'); + const sourceDir = (await pathExists(coreDir)) ? coreDir : rawDir; + + await rename(sourceDir, destDir); + await rm(tarballPath, { force: true }); + await rm(rawDir, { recursive: true, force: true }); + + if (cacheable) { + await mkdir(CACHE_DIR, { recursive: true }); + await cp(destDir, cachePath, { recursive: true }).catch(() => { + /* best-effort cache; ignore failures */ + }); + } +} + +// ── base-ref resolution / auto-detect ───────────────────────────────────────── +// Returns the base ref to diff from, auto-detecting when catalyst.ref is absent. +export async function resolveBaseRef( + project: Project, + fromOption: string | undefined, + assumeYes: boolean, +): Promise { + if (project.pkg.catalyst?.ref) return project.pkg.catalyst.ref; + if (fromOption) return fromOption; + + // Older projects mirror the Catalyst version in `version`; the package `name` + // tells us the family. Propose the inferred ref and let the merchant confirm. + const family = + project.pkg.name && KNOWN_FAMILIES.has(project.pkg.name) + ? project.pkg.name + : '@bigcommerce/catalyst-core'; + const guess = `${family}@${project.pkg.version}`; + + consola.warn(`No \`catalyst.ref\` found in package.json. Inferred base: ${guess}`); + + if (assumeYes) return guess; + + const ok = await confirm({ message: `Upgrade from ${guess}?`, default: true }).catch(() => false); + + if (!ok) { + consola.info('Re-run with `--from ` to set the base version explicitly.'); + + return null; + } + + return guess; +} + +// ── summary output ──────────────────────────────────────────────────────────── +function printSummary(result: MergeResult, relDir: string, stampedPkg: boolean): void { + // package.json is excluded from the conflict list when the stamp resolved it. + const unresolved = result.conflicted.filter((f) => !(stampedPkg && f === 'package.json')); + + const parts = [ + `${result.applied.length} updated`, + `${result.added.length} added`, + `${result.deleted.length} removed`, + ]; + + if (unresolved.length) parts.push(`${unresolved.length} with conflicts`); + + consola.log(`\n${parts.join(', ')}`); + + if (unresolved.length) { + const files = unresolved.map((f) => ` ${f}`).join('\n'); + + consola.warn( + `\nStaged the clean changes. ${unresolved.length} file(s) need conflict resolution (the <<>> markers):\n${files}\n\nResolve them, then: git add ${relDir} && git commit`, + ); + } else { + consola.success('Staged all changes — review with `git diff --cached`, then commit.'); + } +} + +export const upgrade = new Command('upgrade') + .configureHelp({ showGlobalOptions: true }) + .aliases([ + 'up', + // These are hidden aliases + 'upgrayedd', + 'ugrad', + ]) + .description('Upgrade your Catalyst project to a newer version by applying a 3-way merge.') + .argument('[version]', 'Target version to upgrade to (default: latest)') + .addOption( + new Option( + '--ref ', + 'Full git tag to upgrade to, e.g. an integration family like @bigcommerce/catalyst-makeswift@1.7.0. Overrides [version].', + ), + ) + .addOption(new Option('--from ', 'Base ref to upgrade from (when catalyst.ref is missing)')) + .option('--dry-run', 'Generate and display the diff without applying it') + .option('--yes', 'Skip confirmation prompts (e.g. inferred base ref)') + .addOption( + new Option( + '--strategy ', + 'Merge engine: tree (git merge-tree, full fidelity), per-file (git merge-file, no-history fallback), or auto (tree when git >= 2.38, else per-file)', + ) + .choices(['auto', 'tree', 'per-file'] as const) + .default('auto' as const) + .hideHelp(), + ) + .addOption( + new Option('--repository ', 'GitHub repository to pull versions from').default( + DEFAULT_REPOSITORY, + ), + ) + .addHelpText( + 'after', + ` +Examples: + # Upgrade to the latest Catalyst version + $ catalyst upgrade + + # Upgrade to a specific version + $ catalyst upgrade 1.8.0 + + # Preview what would change without applying + $ catalyst upgrade --dry-run + + # Upgrade to an integration family (makeswift, b2b-makeswift, ...) + $ catalyst upgrade --ref @bigcommerce/catalyst-makeswift@1.7.0 + +Conflicts are written as standard <<>> markers — the upgrade +never aborts. Versions are git tags on the repository (not npm). Set GITHUB_TOKEN +to raise the GitHub API rate limit.`, + ) + // eslint-disable-next-line complexity + .action(async (version, options) => { + // ── 1. Resolve the merchant project (flat or nested layout) ─────────── + const project = await resolveProject(process.cwd()); + + if (!project) { + consola.error( + 'Run `catalyst upgrade` from inside a Catalyst git repository (flat core/ repo or a project containing core/).', + ); + process.exit(1); + } + + const { gitRoot, catalystRoot, relDir, pkgPath } = project; + + // ── 2. Resolve base + target refs ───────────────────────────────────── + const baseRef = await resolveBaseRef(project, options.from, options.yes ?? false); + + if (!baseRef) process.exit(1); + + let basePackage: string; + let upstreamRef: string; + let upstreamTagVersion: string; + + try { + ({ packageName: basePackage } = parseRef(baseRef)); + upstreamRef = options.ref ?? `${basePackage}@${version ?? 'latest'}`; + ({ version: upstreamTagVersion } = parseRef(upstreamRef)); + } catch { + consola.error( + `Invalid ref format — expected @ (e.g. @bigcommerce/catalyst-core@1.7.0). Got: "${baseRef}"`, + ); + process.exit(1); + } + + if (upstreamRef === baseRef) { + consola.success('Already up to date.'); + + return; + } + + // ── 3. Require a clean working tree at the catalyst root (preview exempt) ─ + // The merge writes in place, so pre-existing uncommitted edits would be + // indistinguishable from the upgrade. Committing/stashing first gives the + // merchant a clean point to diff and roll back from. + if (!options.dryRun) { + const statusArgs = relDir === '.' ? [] : ['--', relDir]; + const status = await execa('git', ['status', '--porcelain', ...statusArgs], { + cwd: gitRoot, + reject: false, + }); + + if (status.stdout.trim()) { + const where = relDir === '.' ? 'The repo' : `${relDir}/`; + + consola.error( + `${where} has uncommitted changes. Commit or stash them before upgrading, so the upgrade is isolated and reversible:\n git add ${relDir} && git commit -m "snapshot before upgrade"\n # or: git stash`, + ); + process.exit(1); + } + } + + // ── 3b. Backfill catalyst.ref when it was inferred ──────────────────── + // If catalyst.ref was absent and the user confirmed an inferred base (not + // an explicit --from), write the field and stage it so it travels with the + // upgrade commit. Runs after the clean-tree check so we only add to an + // already-clean index. --from is excluded: the user already knows the base + // and shouldn't get an extra staged change they didn't ask for. + if (!project.pkg.catalyst?.ref && !options.from && !options.dryRun) { + const { version: baseVersion } = parseRef(baseRef); + const rawPkg = await readFile(pkgPath, 'utf-8'); + const parsedPkg = z.record(z.string(), z.unknown()).parse(JSON.parse(rawPkg)); + + parsedPkg.catalyst = { version: baseVersion, ref: baseRef }; + await writeFile(pkgPath, `${JSON.stringify(parsedPkg, null, 2)}\n`); + await execa('git', ['add', '--', pkgPath], { cwd: gitRoot }); + consola.success(`Added catalyst.ref → ${baseRef}`); + } + + const token = process.env.GITHUB_TOKEN; + const tmpDir = await mkdtemp(join(tmpdir(), 'catalyst-upgrade-')); + + try { + const baseDir = join(tmpDir, 'base'); + const theirsDir = join(tmpDir, 'theirs'); + const emptyFile = join(tmpDir, '.empty'); + + await writeFile(emptyFile, ''); + + const downloadSpinner = yoctoSpinner().start(`Downloading ${baseRef} and ${upstreamRef}...`); + + try { + await Promise.all([ + downloadCore(options.repository, baseRef, baseDir, token), + downloadCore(options.repository, upstreamRef, theirsDir, token), + ]); + downloadSpinner.success('Downloaded both versions.'); + } catch (err) { + downloadSpinner.error('Download failed'); + throw err; + } + + // When the base was auto-inferred (no catalyst.ref, no --from), validate + // the guess by checking how many base files are unmodified in the project. + // A correct base scores ~70-80%+; a wrong guess (e.g. merchant manually + // bumped their version field without actually upgrading) scores much lower. + if (!project.pkg.catalyst?.ref && !options.from) { + const similarity = await computeBaseSimilarity(baseDir, catalystRoot); + const pct = Math.round(similarity * 100); + + if (similarity < 0.5) { + consola.warn( + `Low confidence in inferred base ${baseRef} (${pct}% of base files match your project). If the merge result looks off, re-run with \`--from \` to set the base explicitly.`, + ); + } + } + + // For moving tags (latest/canary/alpha) we don't know the real version + // until the tarball lands, so read it from the downloaded package.json. + // For concrete tags the version is already in the tag itself. + const resolvedVersion = MOVING_TAGS.has(upstreamTagVersion) + ? await readResolvedVersion(theirsDir) + : upstreamTagVersion; + const newRef = MOVING_TAGS.has(upstreamTagVersion) + ? `${basePackage}@${resolvedVersion}` + : upstreamRef; + + // ── 4. Dry run: show the unified diff and stop ────────────────────── + if (options.dryRun) { + const diffSpinner = yoctoSpinner().start('Generating diff...'); + const diff = await execa( + 'git', + ['diff', '--no-index', '--binary', '--diff-algorithm=histogram', 'base', 'theirs'], + { cwd: tmpDir, reject: false, stripFinalNewline: false }, + ); + + if ((diff.exitCode ?? 0) > 1) { + diffSpinner.error('Failed to generate diff'); + throw new Error(diff.stderr); + } + + if (!diff.stdout.trim()) { + diffSpinner.success('No differences between versions — already up to date.'); + + return; + } + + const fileCount = (diff.stdout.match(/^diff --git /gm) ?? []).length; + + diffSpinner.success(`Diff ready — ${fileCount} file(s) affected.`); + consola.log('\nDiff preview (--dry-run, not applied):\n'); + consola.log(diff.stdout); + + return; + } + + // ── 5. Apply via 3-way merge (whole-tree by default, per-file fallback) ─ + const strategy = await resolveStrategy(options.strategy); + const mergeSpinner = yoctoSpinner().start(`Merging changes (${strategy})...`); + const result = + strategy === 'tree' + ? await mergeCoreTree(baseDir, theirsDir, catalystRoot) + : await mergeCorePerFile(baseDir, theirsDir, catalystRoot, emptyFile); + const total = + result.applied.length + + result.added.length + + result.deleted.length + + result.conflicted.length; + + if (total === 0) { + mergeSpinner.success('No differences between versions — already up to date.'); + + return; + } + + if (result.conflicted.length) { + mergeSpinner.warning('Merged with conflicts — resolve the markers, then commit.'); + } else { + mergeSpinner.success('Merged cleanly.'); + } + + // ── 6. Stamp catalyst.ref (skip if package.json itself conflicted) ── + const patchedRaw = await readFile(pkgPath, 'utf-8'); + + let stampedPkg = false; + + try { + const patchedPkg = z.record(z.string(), z.unknown()).parse(JSON.parse(patchedRaw)); + + patchedPkg.catalyst = { version: resolvedVersion, ref: newRef }; + await writeFile(pkgPath, `${JSON.stringify(patchedPkg, null, 2)}\n`); + stampedPkg = true; + consola.success(`catalyst.ref updated → ${newRef}`); + } catch { + // package.json has conflict markers (scripts, deps, etc.). The catalyst + // field was added cleanly by ours and should sit outside the conflict + // blocks — so replace just that field in-place, leaving all other conflict + // markers intact for the merchant to resolve normally. + const catalystReplacement = JSON.stringify( + { version: resolvedVersion, ref: newRef }, + null, + 2, + ) + .split('\n') + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join('\n'); + + // Use a function replacer to prevent $-interpolation in the replacement string + // (e.g. $& or $1 in a ref/version value would silently corrupt the output). + const updated = patchedRaw.replace( + /"catalyst":\s*\{[^}]*\}/, + () => `"catalyst": ${catalystReplacement}`, + ); + + if (updated !== patchedRaw) { + await writeFile(pkgPath, updated); + // stampedPkg stays false — the file still has conflict markers in other + // sections, so it must stay as a UU unmerged entry so the editor shows + // the merge UI. Only the catalyst field was resolved in place. + consola.success(`catalyst.ref updated → ${newRef}`); + consola.info( + 'package.json still has conflicts in other sections — resolve them, then: git add package.json', + ); + } else { + consola.warn( + `package.json has conflicts — after resolving, add:\n "catalyst": { "version": "${resolvedVersion}", "ref": "${newRef}" }`, + ); + } + } + + // ── 7. Stage the clean changes; mark conflicts as real unmerged entries ─ + // Staging is a convenience; the merge already landed on disk, so never let + // a git quirk here fail the whole upgrade. + try { + await applyIndexState(gitRoot, relDir, baseDir, theirsDir, result, stampedPkg); + } catch (err) { + consola.warn( + `Couldn't auto-stage the changes (${err instanceof Error ? err.message : String(err)}). Your files are merged on disk — run \`git add ${relDir}\` yourself.`, + ); + } + + const gitVersion = await execa('git', ['--version']) + .then((r) => r.stdout.trim()) + .catch(() => 'unknown'); + + await getTelemetry().track('upgrade', { + strategy, + gitVersion, + dryRun: Boolean(options.dryRun), + applied: result.applied.length, + added: result.added.length, + deleted: result.deleted.length, + conflicts: result.conflicted.length, + hasConflicts: result.conflicted.length > 0, + }); + + printSummary(result, relDir, stampedPkg); + } finally { + await rm(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); + } + }); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 0103375bc6..c43dbec6b5 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -17,6 +17,7 @@ import { logs } from './commands/logs'; import { project } from './commands/project'; import { start } from './commands/start'; import { telemetry } from './commands/telemetry'; +import { upgrade } from './commands/upgrade'; import { version } from './commands/version'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; @@ -91,6 +92,7 @@ program .addCommand(env) .addCommand(channel) .addCommand(auth) + .addCommand(upgrade) .addCommand(telemetry) .hook('preAction', telemetryPreHook) .hook('postAction', telemetryPostHook); From ae5397061085830debae2f48c79031c17f7f5db4 Mon Sep 17 00:00:00 2001 From: Parth Shah <48393781+parthshahp@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:41:49 -0400 Subject: [PATCH 81/90] TRAC-499: feat - implement query logs in catalyst cli (#3068) * TRAC-499: feat - implement query logs in catalyst cli * TRAC-499: test - implement tests for query logs --- packages/catalyst/README.md | 1 + .../catalyst/src/cli/commands/logs.spec.ts | 246 +++++++++- packages/catalyst/src/cli/commands/logs.ts | 133 ++++- .../src/cli/lib/observability.spec.ts | 463 ++++++++++++++++++ .../catalyst/src/cli/lib/observability.ts | 273 +++++++++++ packages/catalyst/tests/mocks/handlers.ts | 26 + 6 files changed, 1129 insertions(+), 13 deletions(-) create mode 100644 packages/catalyst/src/cli/lib/observability.spec.ts create mode 100644 packages/catalyst/src/cli/lib/observability.ts diff --git a/packages/catalyst/README.md b/packages/catalyst/README.md index 76eadf49fd..5ecfc6f561 100644 --- a/packages/catalyst/README.md +++ b/packages/catalyst/README.md @@ -29,6 +29,7 @@ For example: ```bash pnpm exec /packages/catalyst/dist/cli.js project list pnpm exec /packages/catalyst/dist/cli.js logs tail +pnpm exec /packages/catalyst/dist/cli.js logs query --start 2026-06-01T00:00:00Z --end 2026-06-02T00:00:00Z pnpm exec /packages/catalyst/dist/cli.js deploy ``` diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts index 67757b0c7f..f748ab2aa4 100644 --- a/packages/catalyst/src/cli/commands/logs.spec.ts +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -140,6 +140,7 @@ describe('command configuration', () => { expect.objectContaining({ flags: '--access-token ' }), expect.objectContaining({ flags: '--api-host ' }), expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--format ', defaultValue: 'default' }), ]), ); }); @@ -584,7 +585,231 @@ describe('credential resolution', () => { }); describe('query subcommand', () => { - test('exits with error as not yet implemented', async () => { + const start = '2026-06-01T00:00:00Z'; + const end = '2026-06-02T00:00:00Z'; + + const queryArgs = (extra: string[] = []) => [ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + '--start', + start, + '--end', + end, + ...extra, + ]; + + test('prints formatted log lines and a count footer', async () => { + await program.parseAsync(queryArgs()); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('/cart')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('[TypeError]')); + expect(consola.info).toHaveBeenCalledWith('1 entry shown (oldest first, times in UTC).'); + // TODO(TRAC-934): no next-page hint is printed while pagination is removed. + expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('More available')); + }); + + test('warns when the result fills --limit exactly', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [ + { id: '2', timestamp: '2026-06-01T13:00:00Z', level: 'info', messages: ['newer'] }, + { id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['older'] }, + ], + }), + ), + ); + + await program.parseAsync(queryArgs(['--limit', '2'])); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('Limit of 2 reached')); + }); + + test('does not warn when the result is below --limit', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['only'] }], + }), + ), + ); + + await program.parseAsync(queryArgs(['--limit', '5'])); + + expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('reached')); + }); + + test('prints entries oldest-first', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [ + { id: '2', timestamp: '2026-06-01T13:00:00Z', level: 'info', messages: ['newer'] }, + { id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['older'] }, + ], + meta: { + cursor_pagination: { count: 2, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs()); + + const logged = vi.mocked(consola.log).mock.calls.map(([line]) => String(line)); + const olderIndex = logged.findIndex((line) => line.includes('older')); + const newerIndex = logged.findIndex((line) => line.includes('newer')); + + expect(olderIndex).toBeGreaterThanOrEqual(0); + expect(olderIndex).toBeLessThan(newerIndex); + }); + + test('--since queries a relative window ending now', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', + ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [], + meta: { + cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }); + }, + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + '--since', + '1h', + ]); + + const sentStart = Date.parse(captured?.get('start') ?? ''); + const sentEnd = Date.parse(captured?.get('end') ?? ''); + + expect(sentEnd - sentStart).toBe(60 * 60 * 1000); + expect(Math.abs(Date.now() - sentEnd)).toBeLessThan(60 * 1000); + }); + + test('reads project UUID from project.json when --project-uuid is omitted', async () => { + config.set('projectUuid', projectUuid); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--start', + start, + '--end', + end, + ]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('/cart')); + }); + + test('outputs one raw JSON entry per line with --format json', async () => { + await program.parseAsync(queryArgs(['--format', 'json'])); + + // NDJSON to stdout (like `tail --format json`), no footer chrome. + expect(stdoutWriteMock).toHaveBeenCalledWith(expect.stringContaining('"is_exception":true')); + expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('entry shown')); + }); + + test('includes request details with --format request', async () => { + await program.parseAsync(queryArgs(['--format', 'request'])); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('GET /cart (500)')); + }); + + test('reports when no entries are found', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [], + meta: { + cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs()); + + expect(consola.info).toHaveBeenCalledWith( + 'No log entries found for the given window and filters.', + ); + }); + + test('forwards filters as query params', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', + ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [], + meta: { + cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }); + }, + ), + ); + + await program.parseAsync( + queryArgs([ + '--method', + 'GET', + '--status-code', + '500', + '--url-like', + '/cart', + '--level-min', + 'warn', + '--limit', + '10', + ]), + ); + + expect(captured?.get('method')).toBe('GET'); + expect(captured?.get('status_code')).toBe('500'); + expect(captured?.get('url:like')).toBe('/cart'); + expect(captured?.get('level:min')).toBe('warn'); + expect(captured?.get('limit')).toBe('10'); + expect(captured?.get('after')).toBeNull(); + }); + + test('exits with error when no project UUID can be resolved', async () => { await program.parseAsync([ 'node', 'catalyst', @@ -594,9 +819,22 @@ describe('query subcommand', () => { storeHash, '--access-token', accessToken, + '--start', + start, + '--end', + end, ]); - expect(consola.error).toHaveBeenCalledWith('The query command is not yet implemented.'); + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Project UUID is required')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('exits with an error on an invalid time window', async () => { + await program.parseAsync(queryArgs(['--end', '2026-05-01T00:00:00Z'])); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('--start must be before or equal to --end'), + ); expect(exitMock).toHaveBeenCalledWith(1); }); @@ -607,9 +845,7 @@ describe('query subcommand', () => { delete process.env.CATALYST_STORE_HASH; delete process.env.CATALYST_ACCESS_TOKEN; - await expect(program.parseAsync(['node', 'catalyst', 'logs', 'query'])).rejects.toThrow( - 'Missing credentials', - ); + await program.parseAsync(['node', 'catalyst', 'logs', 'query', '--start', start, '--end', end]); if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index 9e8b8252d5..7c3ee25a55 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -1,9 +1,10 @@ -import { Command, Option } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; import { colorize } from 'consola/utils'; import { z } from 'zod'; import { UnauthorizedError } from '../lib/auth-errors'; import { consola } from '../lib/logger'; +import { formatLogEntry, LOG_LEVELS, queryLogs, resolveTimeWindow } from '../lib/observability'; import { getProjectConfig } from '../lib/project-config'; import { resolveCredentials } from '../lib/resolve-credentials'; import { @@ -347,26 +348,142 @@ Examples: } }); +// Validates a numeric flag client-side so typos fail instantly with a clear +// message instead of sending NaN (or an out-of-range value) to the API. +const parseIntInRange = (flag: string, min: number, max: number) => (value: string) => { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new InvalidArgumentError(`${flag} must be an integer between ${min} and ${max}.`); + } + + return parsed; +}; + const query = new Command('query') .configureHelp({ showGlobalOptions: true }) .description('Query historical logs from your deployed application.') .addHelpText( 'after', ` -Example: - $ catalyst logs query`, +Specify a time window with \`--since\` (relative to now) or \`--start\`/\`--end\` +(ISO-8601 timestamps or Unix epoch seconds, UTC). The window may not exceed +7 days. Entries print oldest-first; timestamps are UTC. + +Examples: + # Last hour of logs + $ catalyst logs query --since 1h + + # Everything from today (UTC) + $ catalyst logs query --start 2026-06-11T00:00:00Z + + # Errors only for a specific path + $ catalyst logs query --since 24h --level-min error --url-like /cart + + # Errors with request details (method, URL, status) + $ catalyst logs query --since 1h --level-min error --format request + + # Raw JSON (NDJSON) for piping to other tools + $ catalyst logs query --since 2d --format json`, ) .addOption(storeHashOption()) .addOption(accessTokenOption()) .addOption(apiHostOption()) .addOption(projectUuidOption()) - .action((options) => { - const config = getProjectConfig(); + .addOption( + new Option( + '--since ', + 'Relative window ending at --end (default: now), e.g. 30m, 6h, 2d (units: s, m, h, d).', + ).conflicts('start'), + ) + .option('--start