diff --git a/.changeset/angry-seas-march.md b/.changeset/angry-seas-march.md new file mode 100644 index 000000000..df5ff7fad --- /dev/null +++ b/.changeset/angry-seas-march.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/create-catalyst": minor +--- + +add `integration` command to help with developing native Catalyst integrations diff --git a/packages/create-catalyst/README.md b/packages/create-catalyst/README.md index 097e04d5c..9d48ce30d 100644 --- a/packages/create-catalyst/README.md +++ b/packages/create-catalyst/README.md @@ -34,3 +34,47 @@ pnpm create @bigcommerce/catalyst@latest init ```sh yarn create @bigcommerce/catalyst@latest init ``` + +### Develop a native integration for a new Catalyst project + +> **Note:** Currently there aren't any anticipated use cases that require the usage of the `integration` command outside of the Catalyst monorepo. Therefore, `pnpm` is assumed to be the most common package manager that this command will be executed with. + +```sh +pnpm create @bigcommerce/catalyst@latest integration +``` + +#### How to develop a native integration for new Catalyst projects + +If you are interested in developing a native integration for Catalyst, you can use the `integration` command documented above. + +First, you'll need to write the code that makes your integration work with Catalyst. Begin by forking the [`bigcommerce/catalyst` repository](https://github.com/bigcommerce/catalyst), clone the fork locally, and [follow the steps to get started with local monorepo development](https://www.catalyst.dev/docs/monorepo). Be sure to add the original `bigcommerce/catalyst` repository as a remote (often named `upstream`) so that you can pull in latest updates pushed to `bigcommerce/catalyst:main`. + +As a note, all integrations should be based off the latest `@bigcommerce/catalyst-core` release tag. This is because new Catalyst projects are created based off that tag, and if your integration is compatible with the latest tag, then it will be compatible with all Catalyst projects created from that tag. + +Next, create a branch based off the latest `@bigcommerce/catalyst-core` release tag and name it following the convention `integrations/your-integration-name`. This branch is where you'll be building your integration into Catalyst, and the naming convention helps us organize all integration branches inside the Catalyst monorepo. + +```bash +git checkout -b integrations/your-integration-name @bigcommerce/catalyst-core@X.X.X +``` + +> [!IMPORTANT] +> +> #### Things to consider when building integrations: +> +> - In order to ensure your integration applies cleanly to new Catalyst projects, your integration should be 100% contained within the `core` folder of the monorepo. With the exception of installing packages inside of `core` (which in turn modifies the root `pnpm-lock.yaml` file), none of your integration code should live outside of the `core` folder. +> - If your integration requires environment variables to work, be sure to add those environment variables to `core/.env.example`. This allows the `integrate` CLI to track which environment variables are required for the integration to work. + +When you've finished building your integration, commit your changes to the branch, and then run the following command: + +```bash +pnpm create @bigcommerce/catalyst@latest integration --integration-name="Your Integration Name" --source-branch=integrations/your-integration-name +``` + +The command above will create a new folder in your working tree called `integrations/your-integration-name/` with two files: `integration.patch` and `manifest.json`. You'll want to create a PR to merge just this created folder into `main`: + +```bash +git checkout main && +git checkout -b integrations/your-integration-name-patch +``` + +Once that branch is created, commit your changes, push it to your fork, and open a pull request from your remote branch into `bigcommerce/catalyst:main`. Once your branch is merged into main, the CLI will register your new integration for users to choose from when creating a new Catalyst project. diff --git a/packages/create-catalyst/src/commands/integration.ts b/packages/create-catalyst/src/commands/integration.ts new file mode 100644 index 000000000..f8c5884f4 --- /dev/null +++ b/packages/create-catalyst/src/commands/integration.ts @@ -0,0 +1,120 @@ +import { Command } from '@commander-js/extra-typings'; +import { exec as execCb } from 'child_process'; +import { outputFileSync, writeJsonSync } from 'fs-extra/esm'; +import kebabCase from 'lodash.kebabcase'; +import { coerce, compare } from 'semver'; +import { promisify } from 'util'; +import { z } from 'zod'; + +const exec = promisify(execCb); + +interface Manifest { + name: string; + dependencies: { + add: string[]; + }; + devDependencies: { + add: string[]; + }; + environmentVariables: string[]; +} + +export const integration = new Command('integration') + .argument('', 'Formatted name of the integration') + .option('--commit-hash ', 'Override integration source branch with a specific commit hash') + .action(async (integrationNameRaw, options) => { + // @todo check for integration name conflicts + const integrationName = z.string().transform(kebabCase).parse(integrationNameRaw); + + const manifest: Manifest = { + name: integrationName, + dependencies: { add: [] }, + devDependencies: { add: [] }, + environmentVariables: [], + }; + + await exec('git fetch --tags'); + + const { stdout: headRefStdOut } = await exec('git rev-parse --abbrev-ref HEAD'); + let [sourceRef] = headRefStdOut.split('\n'); + + if (options.commitHash) { + sourceRef = options.commitHash; + } + + const { stdout: catalystTags } = await exec('git tag --list @bigcommerce/catalyst-core@\\*'); + const [latestCoreTag] = catalystTags + .split('\n') + .filter(Boolean) + .sort((a, b) => { + const versionA = coerce(a.replace('@bigcommerce/catalyst-core@', '')); + const versionB = coerce(b.replace('@bigcommerce/catalyst-core@', '')); + + if (versionA && versionB) { + return compare(versionA, versionB); + } + + return 0; + }) + .reverse(); + + const PackageDependenciesSchema = z.object({ + dependencies: z.object({}).passthrough(), + devDependencies: z.object({}).passthrough(), + }); + + const getPackageDeps = async (ref: string) => { + const { stdout } = await exec(`git show ${ref}:core/package.json`); + + return PackageDependenciesSchema.parse(JSON.parse(stdout)); + }; + + const integrationJson = await getPackageDeps(sourceRef); + const latestCoreTagJson = await getPackageDeps(latestCoreTag); + + const diffObjectKeys = (a: Record, b: Record) => { + return Object.keys(a).filter((key) => !Object.keys(b).includes(key)); + }; + + manifest.dependencies.add = diffObjectKeys( + integrationJson.dependencies, + latestCoreTagJson.dependencies, + ); + manifest.devDependencies.add = diffObjectKeys( + integrationJson.devDependencies, + latestCoreTagJson.devDependencies, + ); + + const { stdout: envVarDiff } = await exec( + `git diff ${latestCoreTag}...${sourceRef} -- core/.env.example`, + ); + + if (envVarDiff.length > 0) { + const envVars: string[] = []; + const lines = envVarDiff.split('\n'); + const addedEnvVarPattern = /^\+([A-Z_]+)=/; + + lines.forEach((line) => { + const match = line.match(addedEnvVarPattern); + + if (match) { + envVars.push(match[1]); + } + }); + + if (envVars.length > 0) { + manifest.environmentVariables = envVars; + } + } + + const { stdout: integrationDiff } = await exec( + `git diff ${latestCoreTag}...${sourceRef} -- ':(exclude)core/package.json' ':(exclude)pnpm-lock.yaml'`, + ); + + outputFileSync(`integrations/${integrationName}/integration.patch`, integrationDiff); + writeJsonSync(`integrations/${integrationName}/manifest.json`, manifest, { + spaces: 2, + }); + + console.log('Integration created successfully.'); + }); diff --git a/packages/create-catalyst/src/index.ts b/packages/create-catalyst/src/index.ts index aa80f15df..16b63ba00 100644 --- a/packages/create-catalyst/src/index.ts +++ b/packages/create-catalyst/src/index.ts @@ -7,6 +7,7 @@ import PACKAGE_INFO from '../package.json'; import { create } from './commands/create'; import { init } from './commands/init'; +import { integration } from './commands/integration'; console.log(chalk.cyanBright(`\n◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.version}\n`)); @@ -15,6 +16,7 @@ program .version(PACKAGE_INFO.version) .description('A command line tool to create a new Catalyst project.') .addCommand(create, { isDefault: true }) - .addCommand(init); + .addCommand(init) + .addCommand(integration); program.parse(process.argv);