From a45ab4346c27e8cc60d6ce64fb597f22dbde2243 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 1 Jul 2026 17:08:52 -0500 Subject: [PATCH 01/35] =?UTF-8?q?Release:=20Catalyst=20CLI=201.0.0=20and?= =?UTF-8?q?=20create-catalyst=202.0.0=20(merge=20alpha=20=E2=86=92=20canar?= =?UTF-8?q?y)=20(#3077)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add initial changeset for @bigcommerce/catalyst * chore: add pre version setup files * fix: optimize catalyst deployments * rename variables to be prefixed with CATALYST_ * 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`. * 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. * fix(cli): change --secret from variadic to repeatable * feat(cli): map ignition error codes to actionable messages (#2879) * feat(cli): map ignition error codes to actionable messages * write and read access token and store hash from project json file * fix linting * fix linting * added changeset * updated deploy tests to use vitest stubbing for environment variables * check valid options first in deploy command * remove default loading of environment variable files * added changeset * add .bigcommerce directory to core .gitignore file * add changeset * add comment and new line in gitignore * remove changeset * feat(cli): add whoami command (#2881) * 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 * feat(cli): add auth login and auth logout commands (#2887) OAuth device code flow for browser-based authentication. Stores credentials in .bigcommerce/project.json. * 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. * feat(cli): remove --root-dir flag from project create and link commands (#2892) * feat(cli): remove dev command (#2893) users should run next dev directly instead * 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. * add --env-file flag allowing user to pass path to environment variable file to load * rebase on alpha * added changeset * rename variable to fix linting issue * fix linting * remove changeset * throw error on failed load of env file * add arg parsing for env path * fix linting * add tests for --env-path flag * fix linting * feat(cli): remove framework config and simplify build command (#2895) * fix: show readable error when project creation errors with 422 (#2938) * CATALYST-1718: feat(cli) - Add catalyst logs command (#2921) * 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. * fix(cli): pin @opennextjs/cloudflare peer dependency to 1.17.3 (#2949) * 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 * chore: version @bigcommerce/catalyst@1.0.0-alpha.1 Co-Authored-By: Claude * 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 * 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 * refactor(cli): rename "trace id" to "correlation id" (#2962) * feat(cli): auto-detect environment variables as deploy secrets (#2965) * chore: add changeset for auto-detect deploy secrets (#2972) * chore: version @bigcommerce/catalyst@1.0.0-alpha.3 Co-Authored-By: Claude Opus 4.6 (1M context) * 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) * fix: update catalyst cli with new client id * 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 * 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 * TRAC-137: Add native hosting option in catalyst CLI (#3003) * TRAC-614: Add project delete command to catalyst CLI (#3004) * 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 * 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 * feat(cli): show deployment hostnames in project list output (#2988) * feat(cli): add `channel update` command to update channel site url (#3001) * 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 * feat(cli): make catalyst auth login and project create onboarding interactive (#3026) * 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 * LTRAC-440: fix(cli) - Autoreconnect log stream when API proxy stalls (#3027) 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 * fix(cli): clean dist directory before each build (#3030) 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 * LTRAC-440: ref(cli) - Extract pumpUntilRotation from tailLogs (#3029) 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 * fix(cli): strip @vercel/otel hook in Commerce Hosting setup (#3028) * 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 * 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 * LTRAC-442: feat(cli) - Persist deployment env vars via `catalyst env` (#3039) 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 * 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) * 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 * chore: version @bigcommerce/catalyst@1.0.0-alpha.5 Co-Authored-By: Claude Opus 4.8 (1M context) * 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 * LTRAC-908: feat(cli) - Add `catalyst channel link` command (#3051) * LTRAC-908: feat(cli) - Add `catalyst channel connect` command Add a `connect` subcommand under `catalyst channel` that connects an existing local checkout to a BigCommerce channel and writes its credentials to .env.local — useful when a teammate clones a Catalyst repo (where .env.local is gitignored) and needs to regenerate it from the channel. Resolves credentials from flags/env, the persisted project config, or an interactive device-code login (persisting the result), then selects a channel (or takes --channel-id), fetches its channel-init data, and writes .env.local in the cwd. Supports repeatable --env KEY=VALUE overrides. Supersedes the dropped plan to port create-catalyst's standalone `init`: the catalyst CLI is native-hosting only and `catalyst create` already connects a channel for new projects, so the only residual need — bootstrapping .env.local for an existing checkout — lives under `channel`. Refs LTRAC-908 Co-Authored-By: Claude * 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 * 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 * chore: version @bigcommerce/catalyst@1.0.0-alpha.6 Co-Authored-By: Claude Opus 4.8 (1M context) * 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 * 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) * 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) * 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) * 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 * LTRAC-910: ref(cli) - Reduce create-catalyst to a thin wrapper over `catalyst create` (#3053) * LTRAC-910: ref(cli) - Reduce create-catalyst to a thin wrapper over `catalyst create` create-catalyst now delegates to `@bigcommerce/catalyst`: its entry resolves the catalyst bin from its own dependency tree and spawns `catalyst create`, forwarding all args (stdio inherited, exit code and signals propagated). All scaffolding logic now lives in @bigcommerce/catalyst. - Add @bigcommerce/catalyst as a workspace dependency; drop the ~20 runtime deps the old in-package commands needed. - Delete the duplicated commands/, utils/, prompts/, and hooks/. - Bump the tsup target to node24 so esbuild preserves `import.meta.url`. Package name and `bin` are unchanged, so `pnpm create catalyst` / `npx create-catalyst` keep working. The dropped init/integration/telemetry subcommands are superseded by `catalyst channel link`, (integration dropped), and `catalyst telemetry`. Refs LTRAC-910 Co-Authored-By: Claude * LTRAC-910: chore - Add changeset for create-catalyst thin wrapper Refs LTRAC-910 Co-Authored-By: Claude --------- Co-authored-by: Claude * LTRAC-988: Add custom domain command (#3070) * LTRAC-988: feat(cli) - Add custom domain command Add a domains command group with a create-domain API client and wait support for pending domain verification. Refs LTRAC-988 * LTRAC-988: fix(cli) - Clarify domain API server errors Include the HTTP status and correlation ID when domain API requests fail with a server-side response so 502 Bad Gateway errors are actionable. Refs LTRAC-988 * LTRAC-988: fix(cli) - Avoid duplicate correlation ID Keep domain API server error messages concise because the CLI fatal error handler already prints the correlation ID for support. Refs LTRAC-988 * LTRAC-989: feat(cli) - List custom domains (#3071) Add domain listing support with optional domain and verification-status filters so users can scan configured Native Hosting domains from the CLI. Refs LTRAC-989 * LTRAC-990: feat(cli) - Show custom domain status (#3072) Add a domains status command that fetches one domain, renders its color-coded state, and can wait for pending verification to finish. Refs LTRAC-990 * LTRAC-991: feat(cli) - Remove custom domain (#3073) Add a domains remove command that checks current status, confirms active domain removal, and deletes the domain through the Native Hosting API. Refs LTRAC-991 * chore(cli) - Consolidate CLI changesets into single 1.0.0 release note Collapse the 11 individual @bigcommerce/catalyst changesets accumulated across the alpha prerelease series into one comprehensive major changeset so the stable 1.0.0 changelog reads as a single release announcement. Refresh the command surface to include create, channel, env, domains, and upgrade. No change to the resulting version bump (still major -> 1.0.0). Co-Authored-By: Claude Opus 4.8 (1M context) * chore(cli) - Exit changeset pre mode for stable release Run `changeset pre exit` so the Version Packages step produces stable versions (@bigcommerce/catalyst 1.0.0, @bigcommerce/create-catalyst 2.0.0) instead of continuing the 1.0.0-alpha.* prerelease series. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli) - Update program-status copy from alpha to beta The Infrastructure API 403 error messages told users to reach out 'if you are part of the alpha'. The Native Hosting program is now in beta, so update the copy across project, domains, and logs error paths (and the matching test assertions). Co-Authored-By: Claude Opus 4.8 (1M context) * chore(cli) - Collapse subcommands to top-level groups in 1.0.0 changelog Use `catalyst project`, `catalyst auth`, `catalyst logs`, and `catalyst channel` in the release-note command table instead of listing each subcommand row, for a more concise changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Chancellor Clark Co-authored-by: jamesqquick Co-authored-by: Matthew Volk Co-authored-by: Jordan Arldt Co-authored-by: Claude Co-authored-by: Parth Shah <48393781+parthshahp@users.noreply.github.com> --- .changeset/catalyst-cli-1.0.0.md | 49 + .changeset/config.json | 3 +- .../ltrac-910-create-catalyst-thin-wrapper.md | 5 + .changeset/pre.json | 22 + core/.gitignore | 3 + packages/catalyst/AGENTS.md | 96 + packages/catalyst/CHANGELOG.md | 89 + packages/catalyst/README.md | 35 +- packages/catalyst/package.json | 18 +- .../catalyst/src/cli/commands/auth.spec.ts | 313 ++ packages/catalyst/src/cli/commands/auth.ts | 241 + .../catalyst/src/cli/commands/build.spec.ts | 61 +- packages/catalyst/src/cli/commands/build.ts | 224 +- .../catalyst/src/cli/commands/channel.spec.ts | 427 ++ packages/catalyst/src/cli/commands/channel.ts | 252 ++ .../catalyst/src/cli/commands/create.spec.ts | 531 +++ packages/catalyst/src/cli/commands/create.ts | 483 ++ .../catalyst/src/cli/commands/deploy.spec.ts | 633 ++- packages/catalyst/src/cli/commands/deploy.ts | 364 +- .../catalyst/src/cli/commands/dev.spec.ts | 36 - packages/catalyst/src/cli/commands/dev.ts | 36 - .../catalyst/src/cli/commands/domains.spec.ts | 632 +++ packages/catalyst/src/cli/commands/domains.ts | 317 ++ .../catalyst/src/cli/commands/env.spec.ts | 115 + packages/catalyst/src/cli/commands/env.ts | 118 + .../catalyst/src/cli/commands/logs.spec.ts | 899 ++++ packages/catalyst/src/cli/commands/logs.ts | 493 +++ .../catalyst/src/cli/commands/project.spec.ts | 1039 ++++- packages/catalyst/src/cli/commands/project.ts | 489 ++- .../catalyst/src/cli/commands/start.spec.ts | 130 +- packages/catalyst/src/cli/commands/start.ts | 112 +- .../catalyst/src/cli/commands/telemetry.ts | 23 +- .../src/cli/commands/upgrade.action.spec.ts | 343 ++ .../cli/commands/upgrade.integration.spec.ts | 406 ++ .../catalyst/src/cli/commands/upgrade.spec.ts | 528 +++ packages/catalyst/src/cli/commands/upgrade.ts | 1067 +++++ packages/catalyst/src/cli/commands/version.ts | 7 + packages/catalyst/src/cli/hooks/telemetry.ts | 30 +- packages/catalyst/src/cli/index.spec.ts | 88 +- packages/catalyst/src/cli/index.ts | 59 +- .../catalyst/src/cli/lib/auth-errors.spec.ts | 24 + packages/catalyst/src/cli/lib/auth-errors.ts | 31 + packages/catalyst/src/cli/lib/auth.spec.ts | 140 + packages/catalyst/src/cli/lib/auth.ts | 82 + .../src/cli/lib/channel-site-flow.spec.ts | 320 ++ .../catalyst/src/cli/lib/channel-site-flow.ts | 139 + .../catalyst/src/cli/lib/channels.spec.ts | 133 + packages/catalyst/src/cli/lib/channels.ts | 255 ++ .../src/cli/lib/commerce-hosting.spec.ts | 939 ++++ .../catalyst/src/cli/lib/commerce-hosting.ts | 457 ++ .../src/cli/lib/deployment-errors.spec.ts | 50 + .../catalyst/src/cli/lib/deployment-errors.ts | 18 + .../src/cli/lib/detect-package-manager.ts | 22 + packages/catalyst/src/cli/lib/domains.ts | 167 + .../catalyst/src/cli/lib/env-config.spec.ts | 70 + packages/catalyst/src/cli/lib/env-config.ts | 66 + .../catalyst/src/cli/lib/extract-catalyst.ts | 90 + .../catalyst/src/cli/lib/init-git-repo.ts | 23 + .../src/cli/lib/install-dependencies.ts | 23 + .../src/cli/lib}/localization.ts | 23 +- packages/catalyst/src/cli/lib/login.ts | 152 + .../src/cli/lib/observability.spec.ts | 463 ++ .../catalyst/src/cli/lib/observability.ts | 273 ++ .../src/cli/lib/project-config.spec.ts | 26 +- .../catalyst/src/cli/lib/project-config.ts | 32 +- .../src/cli/lib/project-state.spec.ts | 164 + .../catalyst/src/cli/lib/project-state.ts | 67 + packages/catalyst/src/cli/lib/project.ts | 147 +- .../src/cli/lib/resolve-credentials.spec.ts | 81 + .../src/cli/lib/resolve-credentials.ts | 25 + .../src/cli/lib/rewrite-core-package.ts | 118 + .../src/cli/lib/setup-core-project.spec.ts | 105 + .../src/cli/lib/setup-core-project.ts | 40 + .../catalyst/src/cli/lib/shared-options.ts | 47 + .../src/cli/lib/sort-package-json.spec.ts | 81 + .../catalyst/src/cli/lib/sort-package-json.ts | 32 + .../catalyst/src/cli/lib/telemetry.spec.ts | 59 + packages/catalyst/src/cli/lib/telemetry.ts | 47 +- packages/catalyst/src/cli/lib/user-config.ts | 36 + .../src/cli/lib/wrangler-config.spec.ts | 18 +- .../catalyst/src/cli/lib/wrangler-config.ts | 28 +- .../src/cli/lib}/write-env.ts | 3 + packages/catalyst/src/cli/program.ts | 82 +- .../catalyst/templates/open-next.config.ts | 10 +- packages/catalyst/templates/public_headers | 2 + packages/catalyst/tests/mocks/handlers.ts | 185 +- packages/catalyst/tsconfig.json | 1 + packages/catalyst/tsup.config.ts | 1 + packages/catalyst/vitest.setup.ts | 31 +- packages/create-catalyst/package.json | 30 +- .../create-catalyst/src/commands/create.ts | 598 --- packages/create-catalyst/src/commands/init.ts | 198 - .../src/commands/integration.ts | 111 - .../create-catalyst/src/commands/telemetry.ts | 11 - .../create-catalyst/src/hooks/telemetry.ts | 64 - packages/create-catalyst/src/index.ts | 57 +- .../src/{utils => }/node-version.spec.ts | 0 .../src/prompts/multi-select/helpers.ts | 60 - .../src/prompts/multi-select/index.ts | 1 - .../src/prompts/multi-select/multi-select.ts | 218 - .../src/prompts/multi-select/types.ts | 59 - packages/create-catalyst/src/utils/auth.ts | 70 - .../create-catalyst/src/utils/checkout-ref.ts | 48 - packages/create-catalyst/src/utils/cli-api.ts | 52 - .../src/utils/clone-catalyst.ts | 51 - packages/create-catalyst/src/utils/config.ts | 107 - .../src/utils/has-github-ssh.ts | 30 - packages/create-catalyst/src/utils/https.ts | 34 - .../src/utils/install-dependencies.ts | 15 - .../src/utils/is-exec-exception.ts | 5 - packages/create-catalyst/src/utils/login.ts | 112 - packages/create-catalyst/src/utils/parse.ts | 14 - .../src/utils/reset-branch-to-ref.ts | 17 - .../create-catalyst/src/utils/spinner.spec.ts | 29 - packages/create-catalyst/src/utils/spinner.ts | 11 - .../src/utils/telemetry/index.ts | 44 - .../src/utils/telemetry/telemetry.ts | 119 - .../create-catalyst/src/utils/user-agent.ts | 62 - packages/create-catalyst/tsup.config.ts | 6 +- pnpm-lock.yaml | 3869 ++++++----------- 120 files changed, 16676 insertions(+), 5670 deletions(-) create mode 100644 .changeset/catalyst-cli-1.0.0.md create mode 100644 .changeset/ltrac-910-create-catalyst-thin-wrapper.md create mode 100644 .changeset/pre.json create mode 100644 packages/catalyst/AGENTS.md create mode 100644 packages/catalyst/CHANGELOG.md create mode 100644 packages/catalyst/src/cli/commands/auth.spec.ts create mode 100644 packages/catalyst/src/cli/commands/auth.ts 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/commands/create.spec.ts create mode 100644 packages/catalyst/src/cli/commands/create.ts delete mode 100644 packages/catalyst/src/cli/commands/dev.spec.ts delete mode 100644 packages/catalyst/src/cli/commands/dev.ts create mode 100644 packages/catalyst/src/cli/commands/domains.spec.ts create mode 100644 packages/catalyst/src/cli/commands/domains.ts 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/commands/logs.spec.ts create mode 100644 packages/catalyst/src/cli/commands/logs.ts create mode 100644 packages/catalyst/src/cli/commands/upgrade.action.spec.ts create mode 100644 packages/catalyst/src/cli/commands/upgrade.integration.spec.ts create mode 100644 packages/catalyst/src/cli/commands/upgrade.spec.ts create mode 100644 packages/catalyst/src/cli/commands/upgrade.ts create mode 100644 packages/catalyst/src/cli/lib/auth-errors.spec.ts create mode 100644 packages/catalyst/src/cli/lib/auth-errors.ts create mode 100644 packages/catalyst/src/cli/lib/auth.spec.ts create mode 100644 packages/catalyst/src/cli/lib/auth.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 create mode 100644 packages/catalyst/src/cli/lib/channels.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/deployment-errors.spec.ts create mode 100644 packages/catalyst/src/cli/lib/deployment-errors.ts create mode 100644 packages/catalyst/src/cli/lib/detect-package-manager.ts create mode 100644 packages/catalyst/src/cli/lib/domains.ts create mode 100644 packages/catalyst/src/cli/lib/env-config.spec.ts create mode 100644 packages/catalyst/src/cli/lib/env-config.ts create mode 100644 packages/catalyst/src/cli/lib/extract-catalyst.ts create mode 100644 packages/catalyst/src/cli/lib/init-git-repo.ts create mode 100644 packages/catalyst/src/cli/lib/install-dependencies.ts rename packages/{create-catalyst/src/utils => catalyst/src/cli/lib}/localization.ts (59%) create mode 100644 packages/catalyst/src/cli/lib/login.ts create mode 100644 packages/catalyst/src/cli/lib/observability.spec.ts create mode 100644 packages/catalyst/src/cli/lib/observability.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/resolve-credentials.spec.ts create mode 100644 packages/catalyst/src/cli/lib/resolve-credentials.ts create mode 100644 packages/catalyst/src/cli/lib/rewrite-core-package.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/shared-options.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/telemetry.spec.ts create mode 100644 packages/catalyst/src/cli/lib/user-config.ts rename packages/{create-catalyst/src/utils => catalyst/src/cli/lib}/write-env.ts (64%) create mode 100644 packages/catalyst/templates/public_headers delete mode 100644 packages/create-catalyst/src/commands/create.ts delete mode 100644 packages/create-catalyst/src/commands/init.ts delete mode 100644 packages/create-catalyst/src/commands/integration.ts delete mode 100644 packages/create-catalyst/src/commands/telemetry.ts delete mode 100644 packages/create-catalyst/src/hooks/telemetry.ts rename packages/create-catalyst/src/{utils => }/node-version.spec.ts (100%) delete mode 100644 packages/create-catalyst/src/prompts/multi-select/helpers.ts delete mode 100644 packages/create-catalyst/src/prompts/multi-select/index.ts delete mode 100644 packages/create-catalyst/src/prompts/multi-select/multi-select.ts delete mode 100644 packages/create-catalyst/src/prompts/multi-select/types.ts delete mode 100644 packages/create-catalyst/src/utils/auth.ts delete mode 100644 packages/create-catalyst/src/utils/checkout-ref.ts delete mode 100644 packages/create-catalyst/src/utils/cli-api.ts delete mode 100644 packages/create-catalyst/src/utils/clone-catalyst.ts delete mode 100644 packages/create-catalyst/src/utils/config.ts delete mode 100644 packages/create-catalyst/src/utils/has-github-ssh.ts delete mode 100644 packages/create-catalyst/src/utils/https.ts delete mode 100644 packages/create-catalyst/src/utils/install-dependencies.ts delete mode 100644 packages/create-catalyst/src/utils/is-exec-exception.ts delete mode 100644 packages/create-catalyst/src/utils/login.ts delete mode 100644 packages/create-catalyst/src/utils/parse.ts delete mode 100644 packages/create-catalyst/src/utils/reset-branch-to-ref.ts delete mode 100644 packages/create-catalyst/src/utils/spinner.spec.ts delete mode 100644 packages/create-catalyst/src/utils/spinner.ts delete mode 100644 packages/create-catalyst/src/utils/telemetry/index.ts delete mode 100644 packages/create-catalyst/src/utils/telemetry/telemetry.ts delete mode 100644 packages/create-catalyst/src/utils/user-agent.ts diff --git a/.changeset/catalyst-cli-1.0.0.md b/.changeset/catalyst-cli-1.0.0.md new file mode 100644 index 0000000000..f06558a9b3 --- /dev/null +++ b/.changeset/catalyst-cli-1.0.0.md @@ -0,0 +1,49 @@ +--- +"@bigcommerce/catalyst": major +--- + +Introducing the Catalyst CLI (`@bigcommerce/catalyst`) — a single command-line tool for scaffolding, building, and deploying your Catalyst storefront to BigCommerce's Native Hosting infrastructure. + +### Highlights + +- **Scaffold a storefront** — `catalyst create` downloads a clean, standalone project (flattened from `core/`, `workspace:` dependencies resolved to published versions, fresh git repo) via tarball extraction and connects it to your BigCommerce store. The package manager is auto-detected from `npm_config_user_agent`. +- **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 & channel management** — Create, link, and list BigCommerce infrastructure projects with `catalyst project`, and connect storefront channels with `catalyst channel`. +- **Build & deploy** — `catalyst build` runs the OpenNext Cloudflare build pipeline (deriving the Wrangler `compatibility_date` dynamically) and generates deployment artifacts. `catalyst deploy` bundles, uploads, and deploys your storefront with real-time progress streaming. Pass runtime secrets with `--secret KEY=VALUE`; environment variables are auto-detected as deploy secrets. +- **Persisted deployment env vars** — Manage deployment environment variables across deploys with `catalyst env` (list and remove; values are masked). +- **Custom domains** — Add, list, check the status of, and remove custom domains for a Native Hosting project with `catalyst domains`. +- **Local preview** — `catalyst start` launches a local Cloudflare Workers preview of your built storefront via the OpenNext adapter. +- **Live & historical logs** — `catalyst logs tail` streams real-time application logs with color-coded levels and auto-reconnect; `catalyst logs query` retrieves historical logs. +- **In-place upgrades** — `catalyst upgrade` upgrades a project to a newer version via a resilient 3-way merge (`git merge-tree`, falling back to per-file `git merge-file`), producing resolvable conflict markers instead of ever aborting. +- **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 correlation IDs for support. Opt out anytime with `catalyst telemetry disable`. + +### Commands + +| Command | Description | +|---------|-------------| +| `catalyst create` | Scaffold and connect a Catalyst storefront to your BigCommerce store | +| `catalyst auth` | Authenticate, sign out, and verify stored credentials | +| `catalyst project` | Create, link, and list infrastructure projects | +| `catalyst channel` | Connect a storefront channel to your project | +| `catalyst build` | Build your Catalyst project for deployment | +| `catalyst deploy` | Build and deploy to BigCommerce Native Hosting | +| `catalyst env` | Manage persisted deployment environment variables | +| `catalyst domains` | Manage custom domains for a Native Hosting project | +| `catalyst start` | Start a local Cloudflare Workers preview | +| `catalyst logs` | Stream live logs and query historical logs | +| `catalyst upgrade` | Upgrade a project to a newer version via 3-way merge | +| `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@latest @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/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/ltrac-910-create-catalyst-thin-wrapper.md b/.changeset/ltrac-910-create-catalyst-thin-wrapper.md new file mode 100644 index 0000000000..c6e85c0f35 --- /dev/null +++ b/.changeset/ltrac-910-create-catalyst-thin-wrapper.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/create-catalyst": major +--- + +Reduce `create-catalyst` to a thin wrapper that delegates to `catalyst create`. `pnpm create catalyst` / `npx create-catalyst` still scaffold a project — now by invoking `@bigcommerce/catalyst` under the hood — so the scaffolding UX is unchanged. The standalone `init`, `integration`, and `telemetry` subcommands are removed; use `catalyst channel link` and `catalyst telemetry` from the consolidated CLI instead. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..ef97b2c135 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,22 @@ +{ + "mode": "exit", + "tag": "alpha", + "initialVersions": { + "@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": [ + "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", + "project-list-deployed-url", + "wise-worms-hear" + ] +} diff --git a/core/.gitignore b/core/.gitignore index f4086e9252..12fd47d6ed 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -54,3 +54,6 @@ build-config.json # OpenNext .open-next .wrangler + +# Catalyst CLI config +.bigcommerce 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/CHANGELOG.md b/packages/catalyst/CHANGELOG.md new file mode 100644 index 0000000000..7aca6f92e9 --- /dev/null +++ b/packages/catalyst/CHANGELOG.md @@ -0,0 +1,89 @@ +# @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 + +- [#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 + +- [`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 + +- [#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 + +- 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 + +- 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 correlation 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 + +- [`acee114`](https://github.com/bigcommerce/catalyst/commit/acee114ca0ee7428e33b1db28a5b3b18914cde4b) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Alpha version of the CLI diff --git a/packages/catalyst/README.md b/packages/catalyst/README.md index ed8f949d92..5ecfc6f561 100644 --- a/packages/catalyst/README.md +++ b/packages/catalyst/README.md @@ -1,3 +1,36 @@ # @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 logs query --start 2026-06-01T00:00:00Z --end 2026-06-02T00:00:00Z +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/package.json b/packages/catalyst/package.json index 9697588b0e..a78731543a 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.6", "type": "module", "bin": { "catalyst": "dist/cli.js" @@ -9,7 +9,6 @@ "dist", "templates" ], - "private": true, "scripts": { "dev": "tsup --watch", "typecheck": "tsc --noEmit", @@ -23,21 +22,32 @@ "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", "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", + "tar": "^7.5.7", "yocto-spinner": "^1.0.0", "zod": "^4.0.5" }, "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", + "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.15.30", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", @@ -49,6 +59,6 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "@opennextjs/cloudflare": "^1.8.0" + "@opennextjs/cloudflare": "1.17.3" } } 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..314dfc4f27 --- /dev/null +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -0,0 +1,313 @@ +import { confirm, input, password } from '@inquirer/prompts'; +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) })); +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; +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(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); + }); + + test('reports an invalid or expired token 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( + 'Not logged in: your access token is invalid or has expired. Run `catalyst auth login`.', + ); + 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('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' }), + ), + ); + + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); + passwordMock.mockResolvedValueOnce('manual-access-token'); + + 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); + + const config = getProjectConfig(); + + expect(config.get('storeHash')).toBe('manual-store-hash'); + expect(config.get('accessToken')).toBe('manual-access-token'); + }); + + test('exits cleanly when user declines manual login fallback', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + confirmMock.mockResolvedValueOnce(false); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + 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); + }); + + 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' }), + ), + ); + + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); + passwordMock.mockResolvedValueOnce('bad-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('Could not validate credentials'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + 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' }), + ), + ); + + 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); + }); + + 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' }), + ), + ); + + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); + passwordMock.mockResolvedValueOnce(' '); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Access token is required')); + 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 new file mode 100644 index 0000000000..acf7f2e6e0 --- /dev/null +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -0,0 +1,241 @@ +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'; +import { getProjectConfig } from '../lib/project-config'; +import { loginUrlOption } from '../lib/shared-options'; + +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', + }, + }); + + assertAuthorized(response); + + 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; +} + +const whoami = new Command('whoami') + .configureHelp({ showGlobalOptions: true }) + .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 ', + '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(), + ) + .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( + '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); + + 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 (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}`); + } + + process.exit(1); + } + }); + +const login = new Command('login') + .configureHelp({ showGlobalOptions: true }) + .description( + '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 interactively (browser, with manual fallback) + $ catalyst auth login + + # Login with existing credentials (skips interactive flow) + $ catalyst auth login --store-hash --access-token `, + ) + .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(loginUrlOption()) + .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 credentials = await runInteractiveLogin(options.loginUrl, options.apiHost); + + config.set('storeHash', credentials.storeHash); + config.set('accessToken', credentials.accessToken); + + consola.success(`Logged in to store ${credentials.storeHash}.`); + process.exit(0); + } catch (error) { + if (error instanceof LoginAbortedError) { + consola.info( + 'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.', + ); + process.exit(0); + + return; + } + + const message = error instanceof Error ? error.message : String(error); + + consola.error(`Login failed: ${message}`); + process.exit(1); + } + }); + +const logout = new Command('logout') + .configureHelp({ showGlobalOptions: true }) + .description('Remove stored credentials for the current project.') + .addHelpText( + 'after', + ` +Example: + $ catalyst auth logout`, + ) + .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') + .configureHelp({ showGlobalOptions: true }) + .description('Manage authentication for the BigCommerce CLI.') + .addCommand(whoami) + .addCommand(login) + .addCommand(logout); diff --git a/packages/catalyst/src/cli/commands/build.spec.ts b/packages/catalyst/src/cli/commands/build.spec.ts index 06f5525693..ecdb46ea87 100644 --- a/packages/catalyst/src/cli/commands/build.spec.ts +++ b/packages/catalyst/src/cli/commands/build.spec.ts @@ -1,44 +1,63 @@ import { Command } from 'commander'; import { execa } from 'execa'; -import { join } from 'node:path'; -import { expect, test, vi } from 'vitest'; +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); -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), -})); +beforeAll(() => { + consola.wrapAll(); +}); -vi.mock('execa', () => ({ - execa: vi.fn(() => Promise.resolve({ stdout: '' })), - __esModule: true, -})); +beforeEach(() => { + consola.mockTypes(() => vi.fn()); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); 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' }), - ]), + expect.arrayContaining([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']); +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( - join('node_modules', '.bin', 'next'), - ['build', '--debug'], - expect.objectContaining({ - stdio: 'inherit', - cwd: process.cwd(), - }), + '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 94898ba888..8d26534f4c 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -1,130 +1,132 @@ import { Command, Option } from 'commander'; import { execa } from 'execa'; -import { existsSync } from 'node:fs'; -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'; 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'; +const WRANGLER_VERSION = '4.90.0'; + +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); + + // 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( + 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. - .argument( - '[next-build-options...]', - 'Next.js `build` options (see: https://nextjs.org/docs/app/api-reference/cli/next#next-build-options)', + .configureHelp({ showGlobalOptions: true }) + .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 ', 'Project UUID to be included in the deployment configuration.', - ).env('BIGCOMMERCE_PROJECT_UUID'), - ) - .addOption( - new Option('--framework ', 'The framework to use for the build.').choices([ - 'nextjs', - 'catalyst', - ]), + ).env('CATALYST_PROJECT_UUID'), ) - .action(async (nextBuildOptions, options) => { - const coreDir = process.cwd(); - - try { - 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 ${coreDir}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['build', ...nextBuildOptions], { - stdio: 'inherit', - cwd: coreDir, - }); - } - - if (framework === 'catalyst') { - const openNextOutDir = join(coreDir, '.open-next'); - const bigcommerceDistDir = join(coreDir, '.bigcommerce', 'dist'); - - 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 .', - ); - } - - const wranglerConfig = getWranglerConfig(projectUuid, 'PLACEHOLDER_KV_ID'); - - 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, - }); - } - } catch (error) { - consola.error(error); - process.exit(1); + .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'); + + 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); }); 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..04cf558cd0 --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -0,0 +1,427 @@ +import { confirm, select } from '@inquirer/prompts'; +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'; +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'; + +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; +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'); + }); + + 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', () => { + 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, + }, + }); + }, + ), + ); + + mockSelect.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(mockSelect).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); + + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(mockSelect).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 }, + }); + }, + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + '--channel-id', + '5', + '--hostname', + 'override.example', + ]); + + expect(mockSelect).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 + mockConfirm.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 }), + ), + ); + + mockSelect.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`'); + }); +}); + +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' } }, + }); + }), + ); + + mockSelect.mockResolvedValueOnce(2); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(mockSelect).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 new file mode 100644 index 0000000000..2c6313c18f --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -0,0 +1,252 @@ +import { select } from '@inquirer/prompts'; +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 { 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( + "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); + }); + +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; + } + + 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), + })), + }); + + 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(`Next steps:\n\n ${colorize('yellow', 'pnpm run dev')}`); + + 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.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts new file mode 100644 index 0000000000..596d0df93e --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -0,0 +1,531 @@ +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 { 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'; + +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', () => ({ + checkbox: vi.fn(), + input: vi.fn(), + 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/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/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 extractCatalyst'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(extractCatalyst).toHaveBeenCalled(); + expect(rewriteCorePackage).toHaveBeenCalled(); + expect(detectPackageManager).toHaveBeenCalled(); + expect(setupCoreProject).toHaveBeenCalled(); + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).toHaveBeenCalled(); + expect(initGitRepo).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 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', + '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 [gitInitOrder] = vi.mocked(initGitRepo).mock.invocationCallOrder; + + expect(writeEnvOrder).toBeLessThan(installOrder); + expect(installOrder).toBeLessThan(gitInitOrder); + }); +}); + +describe('failure handling', () => { + test('mid-flow failure surfaces cleanup warning when projectDir exists', async () => { + // 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(extractCatalyst).mockImplementationOnce(() => { + mkdirSync(projectDir, { recursive: true }); + + return Promise.resolve(); + }); + 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 () => { + // 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([ + '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('extract failed before creating directory'); + + 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(extractCatalyst).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(extractCatalyst).not.toHaveBeenCalled(); + }); +}); + +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..ff6c07d413 --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.ts @@ -0,0 +1,483 @@ +import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings'; +import { checkbox, 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 { + channelPlatformLabel, + checkChannelEligibility, + createChannel, + fetchAvailableChannels, + getChannelInit, + sortChannelsByPlatform, +} from '../lib/channels'; +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'; +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 localeChoices = availableLocales + .filter(({ value }) => value !== storefrontLocale) + .map(({ name, value }) => ({ name, value, description: value })); + + 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({ + 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 channels = await fetchAvailableChannels(storeHash, accessToken, apiHost); + + const existingChannel = await select({ + message: 'Which channel would you like to use?', + choices: sortChannelsByPlatform(channels).map((ch) => ({ + name: ch.name, + value: ch, + description: `Channel Platform: ${channelPlatformLabel(ch.platform)}`, + })), + }); + + 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); + } +} + +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 ', + 'Extract a specific ref (tag, branch, or commit) from the source repository', + '@bigcommerce/catalyst-core@latest', + ) + .option('--repository ', 'GitHub repository to extract 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 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); + + return; + } + + throw error; + } + } + + 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 extraction so credential checks + // and prompts happen up-front. We defer the file mutations + // (`setupCommerceHosting`) until after extraction. + 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}'`); + + 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 { + 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) { + await setupCommerceHosting({ + projectDir, + projectUuid: commerceHostingProject.uuid, + storeHash, + accessToken, + }); + } + + // Write env before install — matters for postinstall scripts that may + // resolve env-driven config. + writeEnv(projectDir, envVars); + + await installDependencies(projectDir, packageManager); + initGitRepo(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}'`); + + const steps = [`cd ${projectName} && ${packageManager} run dev`]; + + if (useCommerceHosting) { + steps.push( + `Run 'cd ${projectName} && ${packageManager} 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 22e7c81ddf..83675a9dd7 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -1,7 +1,8 @@ +import { confirm, input, select } from '@inquirer/prompts'; 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, @@ -17,10 +18,15 @@ 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'; import { createDeployment, deploy, @@ -31,8 +37,49 @@ 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) => { + const actual = await 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; @@ -71,6 +118,9 @@ beforeAll(async () => { beforeEach(() => { process.chdir(tmpDir); + // Default the transformation-guard confirm to "yes" so tests that don't + // exercise it proceed past the guard. + vi.mocked(confirm).mockResolvedValue(true); }); afterEach(() => { @@ -94,8 +144,9 @@ 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' }), ]), ); }); @@ -268,7 +319,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...'); @@ -276,6 +329,127 @@ 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); + + // 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']); + + 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); + }); + + 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); + + // 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']); + + 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); + }); + + 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: [] }), + ), + ); + + // 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); + }); + + 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: [] }), + ), + ); + + // 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', + ); + + 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); + }); +}); + test('--dry-run skips upload and deployment', async () => { await program.parseAsync([ 'node', @@ -304,6 +478,58 @@ 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'); + + vi.stubEnv('CATALYST_STORE_HASH', undefined); + + await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( + 'Missing credentials', + ); + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + + vi.unstubAllEnvs(); +}); + +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'); + + vi.stubEnv('CATALYST_ACCESS_TOKEN', undefined); + + await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( + 'Missing credentials', + ); + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + + vi.unstubAllEnvs(); +}); + test('reads from env options', () => { const envVariables = parseEnvironmentVariables([ 'BIGCOMMERCE_STORE_HASH=123', @@ -324,6 +550,405 @@ 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([ + '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 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.', + ); + + 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 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.', + ); + + process.chdir(tmpDir); + await rm(join(resolvedDir, '.bigcommerce'), { recursive: true }); + 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(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: tmpDir, + projectUuid, + storeHash, + accessToken, + }); + expect(installDependencies).toHaveBeenCalledWith(tmpDir); + }); + + test('exits gracefully when user declines to run setup', async () => { + vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); + 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. + 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(); + }); +}); + +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, + }, + }); + }, + ), + ); + + // 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'])); + + 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 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 + // 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.mocked(select) + .mockResolvedValueOnce(2) // channel + .mockImplementationOnce((config) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const cfg = config as unknown as { choices: Array<{ name: string; value: string }> }; + + hostnameChoices = cfg.choices; + + return Object.assign(Promise.resolve('example.com'), { cancel: () => undefined }); + }); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(hostnameChoices?.[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.mocked(select) + .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 eee50458f6..bee338f4d6 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -1,15 +1,41 @@ +import { confirm } from '@inquirer/prompts'; 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'; import { z } from 'zod'; +import { assertAuthorized } from '../lib/auth-errors'; +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; +import { + cleanupCloudflareIncompatibilities, + NoLinkedProjectError, + selectOrCreateInfrastructureProject, + 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'; -import { Telemetry } from '../lib/telemetry'; - -const telemetry = new Telemetry(); +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'; const stepsEnum = z.enum([ 'initializing', @@ -44,6 +70,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 +88,7 @@ const DeploymentStatusSchema = z.object({ progress: z.number(), }) .nullable(), + deployment_hostnames: z.array(z.string()).optional(), error: z .object({ code: z.number(), @@ -106,11 +142,14 @@ export const generateUploadSignature = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, }, body: JSON.stringify({}), }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`Failed to fetch upload signature: ${response.status} ${response.statusText}`); } @@ -150,16 +189,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, }; }); }; @@ -182,6 +217,7 @@ export const createDeployment = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, }, body: JSON.stringify({ project_uuid: projectUuid, @@ -191,6 +227,8 @@ export const createDeployment = async ( }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`Failed to create deployment: ${response.status} ${response.statusText}`); } @@ -208,7 +246,7 @@ export const getDeploymentStatus = async ( storeHash: string, accessToken: string, apiHost: string, -) => { +): Promise => { consola.info('Fetching deployment status...'); const spinner = yoctoSpinner().start('Fetching...'); @@ -221,10 +259,13 @@ export const getDeploymentStatus = async ( 'X-Auth-Token': accessToken, Accept: 'text/event-stream', Connection: 'keep-alive', + 'X-Correlation-Id': getTelemetry().correlationId, }, }, ); + assertAuthorized(response); + if (!response.ok) { throw new Error(`Failed to open event stream: ${response.status} ${response.statusText}`); } @@ -237,6 +278,7 @@ export const getDeploymentStatus = async ( const decoder = new TextDecoder(); let done = false; + let deploymentHostname: string | undefined; while (!done) { // eslint-disable-next-line no-await-in-loop @@ -250,6 +292,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); @@ -262,12 +305,18 @@ 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) { spinner.text = STEPS[data.event.step]; } + + if (data.deployment_hostnames && data.deployment_hostnames.length > 0) { + deploymentHostname = data.deployment_hostnames[0]; + } }); } @@ -275,98 +324,259 @@ export const getDeploymentStatus = async ( } spinner.success('Deployment completed successfully.'); + + if (deploymentHostname) { + consola.success( + `View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`, + ); + } + + return deploymentHostname; +}; + +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', + 'X-Correlation-Id': getTelemetry().correlationId, + }, + }, + ); + + assertAuthorized(response); + + 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') + .configureHelp({ showGlobalOptions: true }) .description('Deploy your application to Cloudflare.') - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ) - .env('BIGCOMMERCE_STORE_HASH') - .makeOptionMandatory(), + .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=`, ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ) - .env('BIGCOMMERCE_ACCESS_TOKEN') - .makeOptionMandatory(), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), + .addOption(storeHashOption()) + .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( - '--project-uuid ', - 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('BIGCOMMERCE_PROJECT_UUID'), - ) - .addOption( - new Option( - '--secret ', - 'Secrets to set for the deployment. Format: SECRET_1=FOO SECRET_2=BAR', - ), + '--secret ', + '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]); + }), ) .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(); + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + const telemetry = getTelemetry(); + + 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); + } - await telemetry.identify(options.storeHash); + throw error; + } + }; - const projectUuid = options.projectUuid ?? config.get('projectUuid'); + if (linkedProjectUuid) { + const existing = await fetchProject( + linkedProjectUuid, + storeHash, + accessToken, + options.apiHost, + ); - 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 (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(); - await generateBundleZip(); + projectUuid = selected.uuid; + config.set('projectUuid', projectUuid); + consola.success(`Linked project "${selected.name}".`); + } - 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'); + // 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 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."); process.exit(0); } - const uploadSignature = await generateUploadSignature( - options.storeHash, - options.accessToken, - options.apiHost, - ); + const projectDir = process.cwd(); + + 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(process.cwd()); + } + + if (options.prebuilt) { + const distDir = join(process.cwd(), '.bigcommerce', 'dist'); - await uploadBundleZip(uploadSignature.upload_url); + 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(); - const environmentVariables = parseEnvironmentVariables(options.secret); + 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'); - const { deployment_uuid: deploymentUuid } = await createDeployment( + process.exit(0); + } + + const uploadSignature = await generateUploadSignature(storeHash, accessToken, options.apiHost); + + await uploadBundleZip(uploadSignature.upload_url); + + // 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, + uploadSignature.upload_uuid, + storeHash, + accessToken, + options.apiHost, + environmentVariables, + ); + + const deploymentHostname = await getDeploymentStatus( + deploymentUuid, + storeHash, + accessToken, + options.apiHost, + ); + + if (!options.updateSiteUrl) { + return; + } + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost: options.apiHost, projectUuid, - uploadSignature.upload_uuid, - options.storeHash, - options.accessToken, - options.apiHost, - environmentVariables, + 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)}`, ); - - await getDeploymentStatus( - deploymentUuid, - options.storeHash, - options.accessToken, - options.apiHost, + 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.', ); - } catch (error) { - consola.error(error); - process.exit(1); } }); 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/dev.ts b/packages/catalyst/src/cli/commands/dev.ts deleted file mode 100644 index 2d9119a24c..0000000000 --- a/packages/catalyst/src/cli/commands/dev.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Command } from 'commander'; -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 - .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) => { - 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?`, - ); - } - - await execa(nextBin, ['dev', ...options], { - stdio: 'inherit', - cwd: process.cwd(), - }); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); - process.exit(1); - } - }); diff --git a/packages/catalyst/src/cli/commands/domains.spec.ts b/packages/catalyst/src/cli/commands/domains.spec.ts new file mode 100644 index 0000000000..a29f2e3584 --- /dev/null +++ b/packages/catalyst/src/cli/commands/domains.spec.ts @@ -0,0 +1,632 @@ +import { confirm } from '@inquirer/prompts'; +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 { createDomain, deleteDomain, getDomain, listDomains } from '../lib/domains'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; + +import { domains, formatDomain, formatDomainStatus, waitForDomainVerification } from './domains'; + +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), +})); + +const confirmMock = vi.mocked(confirm); + +let exitMock: 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'; +const apiHost = 'api.bigcommerce.com'; +const domain = 'www.example.com'; + +function writeCredentials() { + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + config.set('projectUuid', projectUuid); +} + +beforeAll(async () => { + process.env.CATALYST_TELEMETRY_DISABLED = '1'; + + 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(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + delete process.env.CATALYST_TELEMETRY_DISABLED; + + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +describe('command configuration', () => { + test('domains has add, list, status, and remove subcommands', () => { + expect(domains).toBeInstanceOf(Command); + expect(domains.name()).toBe('domains'); + expect(domains.description()).toBe( + 'Manage custom domains for the current Native Hosting project.', + ); + + const add = domains.commands.find((command) => command.name() === 'add'); + const list = domains.commands.find((command) => command.name() === 'list'); + const status = domains.commands.find((command) => command.name() === 'status'); + const remove = domains.commands.find((command) => command.name() === 'remove'); + + expect(add).toBeDefined(); + expect(add?.description()).toBe('Add a custom domain to the current Native Hosting project.'); + expect(add?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--wait' }), + ]), + ); + + expect(list).toBeDefined(); + expect(list?.description()).toBe('List custom domains for the current Native Hosting project.'); + expect(list?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--domain ' }), + expect.objectContaining({ flags: '--status ' }), + ]), + ); + + expect(status).toBeDefined(); + expect(status?.description()).toBe( + 'Show the status of a custom domain on the current Native Hosting project.', + ); + expect(status?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--wait' }), + ]), + ); + + expect(remove).toBeDefined(); + expect(remove?.description()).toBe( + 'Remove a custom domain from the current Native Hosting project.', + ); + expect(remove?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--force' }), + ]), + ); + }); +}); + +describe('formatDomainStatus', () => { + test('formats known domain statuses', () => { + expect(formatDomainStatus('pending')).toContain('pending'); + expect(formatDomainStatus('verified')).toContain('active'); + expect(formatDomainStatus('failed')).toContain('failed'); + expect(formatDomainStatus('unknown')).toContain('unknown'); + }); +}); + +describe('formatDomain', () => { + test('formats a domain line with the display status', () => { + expect( + formatDomain({ + domain, + project_uuid: projectUuid, + verification_status: 'verified', + }), + ).toContain(`${domain} `); + expect( + formatDomain({ + domain, + project_uuid: projectUuid, + verification_status: 'verified', + }), + ).toContain('active'); + }); +}); + +describe('domain API client', () => { + test('creates a domain with the expected request body', async () => { + let capturedBody: unknown; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + async ({ request }) => { + capturedBody = await request.json(); + + return HttpResponse.json( + { + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + }, + { status: 201 }, + ); + }, + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }); + expect(capturedBody).toEqual({ domain }); + }); + + test('surfaces disabled API responses', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => new HttpResponse(null, { status: 403 }), + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).rejects.toThrow('Infrastructure Domains API not enabled'); + }); + + test('surfaces V3 validation errors', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + title: 'Domain could not be added.', + errors: { domain: 'Enter a valid domain.' }, + }, + { status: 422 }, + ), + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).rejects.toThrow('Domain could not be added. (domain: Enter a valid domain.)'); + }); + + test('falls back to status text when a client error response is not a V3 body', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => new HttpResponse(null, { status: 400, statusText: 'Bad Request' }), + ), + ); + + await expect(getDomain(domain, projectUuid, storeHash, accessToken, apiHost)).rejects.toThrow( + 'Failed to fetch domain: Bad Request', + ); + }); + + test('adds status and correlation details for server errors', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + status: 502, + title: 'Bad Gateway', + errors: {}, + }, + { status: 502, statusText: 'Bad Gateway' }, + ), + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).rejects.toThrow( + 'Failed to add domain: 502 Bad Gateway. This is a server-side response from the Domains API.', + ); + }); + + test('lists domains for a project', async () => { + await expect(listDomains(projectUuid, storeHash, accessToken, apiHost)).resolves.toEqual([ + { + domain: 'www.example.com', + project_uuid: projectUuid, + verification_status: 'pending', + }, + { + domain: 'shop.example.com', + project_uuid: projectUuid, + verification_status: 'verified', + }, + ]); + }); + + test('passes list filters to the API', async () => { + let capturedSearchParams: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + ({ request }) => { + capturedSearchParams = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [ + { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + ], + }); + }, + ), + ); + + await expect( + listDomains(projectUuid, storeHash, accessToken, apiHost, { + domains: [domain], + verificationStatus: 'pending', + }), + ).resolves.toEqual([ + { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + ]); + expect(capturedSearchParams?.get('domain:in')).toBe(domain); + expect(capturedSearchParams?.get('verification_status')).toBe('pending'); + }); + + test('deletes a domain', async () => { + let deletedDomain: string | readonly string[] | undefined; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + ({ params }) => { + deletedDomain = params.domain; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + await expect( + deleteDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).resolves.toBeUndefined(); + expect(deletedDomain).toBe(domain); + }); +}); + +describe('waitForDomainVerification', () => { + test('returns immediately when the domain is already verified', async () => { + await expect( + waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + intervalMs: 0, + }), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'verified', + }); + }); + + test('polls pending domains until they leave pending status', async () => { + let requests = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => { + requests += 1; + + return HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: requests === 1 ? 'pending' : 'failed', + }, + }); + }, + ), + ); + + await expect( + waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + intervalMs: 0, + }), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'failed', + }); + expect(requests).toBe(2); + }); + + test('returns the latest pending status after the timeout expires', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => + HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + }), + ), + ); + + await expect( + waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + timeoutMs: 0, + }), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }); + }); +}); + +describe('add command', () => { + test('adds a domain to the linked project', async () => { + writeCredentials(); + + await domains.parseAsync(['add', domain], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Adding domain ${domain}...`); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} added.`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(domain)); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('pending')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('can wait for domain verification after adding', async () => { + writeCredentials(); + + await domains.parseAsync(['add', domain, '--wait'], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + }); +}); + +describe('list command', () => { + test('lists domains for the linked project', async () => { + writeCredentials(); + + await domains.parseAsync(['list'], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith('Fetching domains...'); + expect(consola.success).toHaveBeenCalledWith('Domains fetched.'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('www.example.com')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('pending')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('shop.example.com')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('passes filters from options', async () => { + let capturedSearchParams: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + ({ request }) => { + capturedSearchParams = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [ + { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + ], + }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['list', '--domain', domain, '--status', 'pending'], { from: 'user' }); + + expect(capturedSearchParams?.get('domain:in')).toBe(domain); + expect(capturedSearchParams?.get('verification_status')).toBe('pending'); + }); + + test('reports when no domains are configured', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => HttpResponse.json({ data: [] }), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['list'], { from: 'user' }); + + expect(consola.info).toHaveBeenCalledWith('No custom domains found.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); +}); + +describe('status command', () => { + test('shows a domain status for the linked project', async () => { + writeCredentials(); + + await domains.parseAsync(['status', domain], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Fetching status for ${domain}...`); + expect(consola.success).toHaveBeenCalledWith('Domain status fetched.'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(domain)); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('can wait for a pending domain to leave pending status', async () => { + let requests = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => { + requests += 1; + + return HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: requests === 1 ? 'pending' : 'verified', + }, + }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['status', domain, '--wait'], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(requests).toBe(2); + }); +}); + +describe('remove command', () => { + test('confirms before removing an active domain', async () => { + confirmMock.mockResolvedValue(true); + + writeCredentials(); + + await domains.parseAsync(['remove', domain], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Fetching status for ${domain}...`); + expect(confirmMock).toHaveBeenCalledWith({ + message: `Remove active domain ${domain}? Traffic may stop routing to this project.`, + default: false, + }); + expect(consola.start).toHaveBeenCalledWith(`Removing domain ${domain}...`); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} removed.`); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('does not remove an active domain when confirmation is declined', async () => { + let deleteRequests = 0; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => { + deleteRequests += 1; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + confirmMock.mockResolvedValue(false); + + writeCredentials(); + + await domains.parseAsync(['remove', domain], { from: 'user' }); + + expect(consola.info).toHaveBeenCalledWith('Aborted. No domain was removed.'); + expect(deleteRequests).toBe(0); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('skips confirmation with --force', async () => { + confirmMock.mockResolvedValue(false); + + writeCredentials(); + + await domains.parseAsync(['remove', domain, '--force'], { from: 'user' }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} removed.`); + }); + + test('does not prompt before removing a pending domain', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => + HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + }), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['remove', domain], { from: 'user' }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} removed.`); + }); +}); diff --git a/packages/catalyst/src/cli/commands/domains.ts b/packages/catalyst/src/cli/commands/domains.ts new file mode 100644 index 0000000000..600c0dfa9b --- /dev/null +++ b/packages/catalyst/src/cli/commands/domains.ts @@ -0,0 +1,317 @@ +import { confirm } from '@inquirer/prompts'; +import { Command, Option } from 'commander'; +import { colorize } from 'consola/utils'; + +import { + createDomain, + deleteDomain, + Domain, + DomainStatus, + DomainStatusFilter, + getDomain, + listDomains, +} from '../lib/domains'; +import { consola } from '../lib/logger'; +import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + resolveProjectUuid, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; + +const WAIT_INTERVAL_MS = 5000; +const WAIT_TIMEOUT_MS = 5 * 60 * 1000; +const DOMAIN_STATUS_FILTERS: DomainStatusFilter[] = ['pending', 'verified', 'failed']; + +const STATUS_COLORS: Record[0]> = { + pending: 'yellow', + verified: 'green', + failed: 'red', + unknown: 'gray', +}; + +const STATUS_LABELS: Record = { + pending: 'pending', + verified: 'active', + failed: 'failed', + unknown: 'unknown', +}; + +interface DomainCommandContext { + projectUuid: string; + storeHash: string; + accessToken: string; + apiHost: string; +} + +interface DomainCommandOptions { + storeHash?: string; + accessToken?: string; + apiHost: string; + projectUuid?: string; +} + +interface WaitForDomainVerificationOptions extends DomainCommandContext { + domain: string; + intervalMs?: number; + timeoutMs?: number; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function resolveDomainCommandContext(options: DomainCommandOptions): DomainCommandContext { + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + const projectUuid = resolveProjectUuid(options); + + return { + projectUuid, + storeHash, + accessToken, + apiHost: options.apiHost, + }; +} + +export function formatDomainStatus(status: DomainStatus): string { + return colorize(STATUS_COLORS[status], STATUS_LABELS[status]); +} + +export function formatDomain(domain: Domain): string { + return `${domain.domain} ${formatDomainStatus(domain.verification_status)}`; +} + +export async function waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + intervalMs = WAIT_INTERVAL_MS, + timeoutMs = WAIT_TIMEOUT_MS, +}: WaitForDomainVerificationOptions): Promise { + const startedAt = Date.now(); + let current = await getDomain(domain, projectUuid, storeHash, accessToken, apiHost); + + while (current.verification_status === 'pending' && Date.now() - startedAt < timeoutMs) { + // eslint-disable-next-line no-await-in-loop + await sleep(intervalMs); + // eslint-disable-next-line no-await-in-loop + current = await getDomain(domain, projectUuid, storeHash, accessToken, apiHost); + } + + return current; +} + +const add = new Command('add') + .configureHelp({ showGlobalOptions: true }) + .description('Add a custom domain to the current Native Hosting project.') + .argument('', 'Custom domain to add.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains add www.example.com + + # Wait until the domain leaves pending verification + $ catalyst domains add www.example.com --wait`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--wait', 'Poll until domain verification completes or times out.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start(`Adding domain ${domain}...`); + + let result = await createDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + consola.success(`Domain ${result.domain} added.`); + + if (options.wait && result.verification_status === 'pending') { + consola.start(`Waiting for ${result.domain} to verify...`); + result = await waitForDomainVerification({ domain: result.domain, ...context }); + } + + consola.log(formatDomain(result)); + process.exit(0); + }); + +const list = new Command('list') + .configureHelp({ showGlobalOptions: true }) + .description('List custom domains for the current Native Hosting project.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains list + + # Show pending domains only + $ catalyst domains list --status pending`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--domain ', 'Only show a specific domain.') + .addOption( + new Option('--status ', 'Filter by verification status.').choices( + DOMAIN_STATUS_FILTERS, + ), + ) + .action(async (options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start('Fetching domains...'); + + const result = await listDomains( + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + { + domains: options.domain ? [options.domain] : undefined, + verificationStatus: options.status, + }, + ); + + consola.success('Domains fetched.'); + + if (result.length === 0) { + consola.info('No custom domains found.'); + process.exit(0); + + return; + } + + result.forEach((item) => consola.log(formatDomain(item))); + process.exit(0); + }); + +const showStatus = new Command('status') + .configureHelp({ showGlobalOptions: true }) + .description('Show the status of a custom domain on the current Native Hosting project.') + .argument('', 'Custom domain to check.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains status www.example.com + + # Wait until the domain leaves pending verification + $ catalyst domains status www.example.com --wait`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--wait', 'Poll until domain verification completes or times out.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start(`Fetching status for ${domain}...`); + + let result = await getDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + consola.success('Domain status fetched.'); + + if (options.wait && result.verification_status === 'pending') { + consola.start(`Waiting for ${result.domain} to verify...`); + result = await waitForDomainVerification({ domain: result.domain, ...context }); + } + + consola.log(formatDomain(result)); + process.exit(0); + }); + +const remove = new Command('remove') + .configureHelp({ showGlobalOptions: true }) + .description('Remove a custom domain from the current Native Hosting project.') + .argument('', 'Custom domain to remove.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains remove www.example.com + + # Skip confirmation for an active domain + $ catalyst domains remove www.example.com --force`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--force', 'Skip the confirmation prompt before removing an active domain.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start(`Fetching status for ${domain}...`); + + const current = await getDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + if (current.verification_status === 'verified' && !options.force) { + const confirmed = await confirm({ + message: `Remove active domain ${current.domain}? Traffic may stop routing to this project.`, + default: false, + }); + + if (!confirmed) { + consola.info('Aborted. No domain was removed.'); + process.exit(0); + + return; + } + } + + consola.start(`Removing domain ${current.domain}...`); + + await deleteDomain( + current.domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + consola.success(`Domain ${current.domain} removed.`); + process.exit(0); + }); + +export const domains = new Command('domains') + .configureHelp({ showGlobalOptions: true }) + .description('Manage custom domains for the current Native Hosting project.') + .addCommand(add) + .addCommand(list) + .addCommand(showStatus) + .addCommand(remove); 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/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts new file mode 100644 index 0000000000..f748ab2aa4 --- /dev/null +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -0,0 +1,899 @@ +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 { 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'; +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(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', () => { + 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 ' }), + expect.objectContaining({ flags: '--format ', defaultValue: 'default' }), + ]), + ); + }); +}); + +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 a re-auth error 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( + 'catalyst auth login', + ); + }); +}); + +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 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; + + // 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('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', () => { + 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', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--start', + start, + '--end', + end, + ]); + + 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); + }); + + 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 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; + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + 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..7c3ee25a55 --- /dev/null +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -0,0 +1,493 @@ +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 { + 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}`); + } +}; + +type TimeoutRaceResult = { kind: 'value'; value: T } | { kind: 'timeout' }; + +// Races a promise against a timer so a hung `reader.read()` doesn't block the +// 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, +): 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, + 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', + }, + }, + ); + + // 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}`, + isFatalStatusCode(response.status), + ); + } + + const reader = response.body?.getReader(); + + if (!reader) { + throw new StreamError('Failed to read log stream.', true); + } + + 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, + 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); + + retries = 0; + + // eslint-disable-next-line no-await-in-loop + const rotation = await pumpUntilRotation(reader, format, connectionTtlMs); + + 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; + } + + 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') + .configureHelp({ showGlobalOptions: true }) + .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()) + .addOption(accessTokenOption()) + .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 { + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await telemetry.identify(storeHash); + + const projectUuid = resolveProjectUuid(options); + + await tailLogs(projectUuid, storeHash, accessToken, options.apiHost, options.format); + } catch (error) { + consola.error(error); + process.exit(1); + } + }); + +// 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', + ` +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()) + .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