From 7da98fa47d488ed1b4c9faf4a4f6887717053ae0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:10:18 +0000 Subject: [PATCH 01/26] chore(release): bump version to 2.0.1 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89587e0..734fda1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@emulsify/cli", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.0.0", + "version": "2.0.1", "license": "GPL-2.0", "dependencies": { "@inquirer/prompts": "^8.5.2", diff --git a/package.json b/package.json index 0e3611b..d473208 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.0.0", + "version": "2.0.1", "description": "Command line interface for Emulsify", "repository": "git@github.com:emulsify-ds/emulsify-cli.git", "author": "Patrick Coffey ", From 3cb28a43b8b44f29eed0250ea2842a7532fa35c5 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 15:27:01 -0500 Subject: [PATCH 02/26] fix: make repository tag lookup safe --- src/util/getRepositoryLatestTag.ts | 155 ++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 15 deletions(-) diff --git a/src/util/getRepositoryLatestTag.ts b/src/util/getRepositoryLatestTag.ts index 174fa8e..0e81dc7 100644 --- a/src/util/getRepositoryLatestTag.ts +++ b/src/util/getRepositoryLatestTag.ts @@ -1,23 +1,148 @@ -import { simpleGit } from 'simple-git'; +import { execFile } from 'child_process'; -const getRepositoryLatestTag = async (repoUrl: string): Promise => { - const git = simpleGit(); - try { - await git.init(); +type ParsedTag = { + tag: string; + major: number; + minor: number; + patch: number; + prerelease?: string; +}; + +const tagPattern = + /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +function getRemoteTagRefs(repoUrl: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + ['ls-remote', '--tags', '--refs', repoUrl], + { encoding: 'utf8' }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr.trim() || error.message)); + return; + } + + resolve(stdout); + }, + ); + }); +} + +function parseTagRef(line: string): ParsedTag | undefined { + const [, ref] = line.trim().split(/\s+/); + const tagRefPrefix = 'refs/tags/'; + if (!ref?.startsWith(tagRefPrefix)) { + return; + } + + const tag = ref.slice(tagRefPrefix.length); + const tagParts = tag.match(tagPattern); + if (!tagParts) { + return; + } + + const [, major, minor, patch, prerelease] = tagParts; + return { + tag, + major: Number(major), + minor: Number(minor), + patch: Number(patch), + prerelease, + }; +} + +function comparePrerelease(left?: string, right?: string): number { + if (!left && !right) { + return 0; + } + if (!left) { + return 1; + } + if (!right) { + return -1; + } + + const leftParts = left.split('.'); + const rightParts = right.split('.'); + const length = Math.max(leftParts.length, rightParts.length); + + for (let i = 0; i < length; i += 1) { + const leftPart = leftParts[i]; + const rightPart = rightParts[i]; + + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + + const leftIsNumber = /^\d+$/.test(leftPart); + const rightIsNumber = /^\d+$/.test(rightPart); + + if (leftIsNumber && rightIsNumber) { + const numberComparison = Number(leftPart) - Number(rightPart); + if (numberComparison !== 0) { + return numberComparison; + } + continue; + } + + if (leftIsNumber) { + return -1; + } + if (rightIsNumber) { + return 1; + } - // If this gets run twice somehow, don't break, just try again. - try { - await git.removeRemote('origin'); - } catch (error) { - // honestly no big deal if there is nothing to remove. + const stringComparison = leftPart.localeCompare(rightPart); + if (stringComparison !== 0) { + return stringComparison; } - await git.addRemote('origin', repoUrl); - await git.fetch(); - const tags = await git.tags(); - return tags.latest || ''; + } + + return 0; +} + +function compareTags(left: ParsedTag, right: ParsedTag): number { + const versionComparison = + left.major - right.major || + left.minor - right.minor || + left.patch - right.patch; + + return ( + versionComparison || comparePrerelease(left.prerelease, right.prerelease) + ); +} + +const getRepositoryLatestTag = async (repoUrl: string): Promise => { + let refs: string; + try { + refs = await getRemoteTagRefs(repoUrl); } catch (error) { - throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Unable to read tags from repository ${repoUrl}: ${message}`, + ); } + + const usableTags = refs + .split('\n') + .map(parseTagRef) + .filter((tag): tag is ParsedTag => Boolean(tag)); + + if (usableTags.length === 0) { + throw new Error( + `No usable SemVer tags were found in repository ${repoUrl}.`, + ); + } + + const latest = usableTags.reduce((currentLatest, tag) => + compareTags(tag, currentLatest) > 0 ? tag : currentLatest, + ); + + return latest.tag; }; export default getRepositoryLatestTag; From 27d32eea311aaad85a9465488db097f0bc0dbe79 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 15:28:55 -0500 Subject: [PATCH 03/26] test: cover safe repository tag lookup --- src/util/getRepositoryLatestTag.test.ts | 268 ++++++++++++++++++++---- 1 file changed, 225 insertions(+), 43 deletions(-) diff --git a/src/util/getRepositoryLatestTag.test.ts b/src/util/getRepositoryLatestTag.test.ts index 966dfe3..62c7337 100644 --- a/src/util/getRepositoryLatestTag.test.ts +++ b/src/util/getRepositoryLatestTag.test.ts @@ -1,73 +1,255 @@ import getRepositoryLatestTag from './getRepositoryLatestTag.js'; +import { execFile } from 'child_process'; import { simpleGit } from 'simple-git'; -jest.mock('simple-git'); +jest.mock('child_process', () => ({ + execFile: jest.fn(), +})); +jest.mock('simple-git', () => ({ + simpleGit: jest.fn(), +})); -describe('getRepositoryLatestTag', () => { - let gitMock: any; +const execFileMock = execFile as unknown as jest.Mock; +const simpleGitMock = simpleGit as unknown as jest.Mock; +const repoUrl = 'git@github.com:emulsify-ds/compound.git'; + +type ExecFileCallback = ( + error: Error | null, + stdout: string, + stderr: string, +) => void; + +function tagRef(tag: string): string { + return `1e9c710cde438444fe181d0f1dbc5d106dcaeedf\trefs/tags/${tag}`; +} + +function mockLsRemoteSuccess(output: string): void { + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: { encoding: string }, + callback: ExecFileCallback, + ) => { + callback(null, output, ''); + }, + ); +} + +function mockLsRemoteFailure(stderr: string): void { + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: { encoding: string }, + callback: ExecFileCallback, + ) => { + callback(new Error('Command failed'), '', stderr); + }, + ); +} +describe('getRepositoryLatestTag', () => { beforeEach(() => { - gitMock = { - init: jest.fn().mockReturnThis(), - addRemote: jest.fn().mockReturnThis(), - removeRemote: jest.fn().mockReturnThis(), - fetch: jest.fn().mockReturnThis(), - tags: jest.fn().mockResolvedValue({ latest: '1.5.0' }), - }; - (simpleGit as jest.Mock).mockReturnValue(gitMock); jest.clearAllMocks(); }); - it('Can get latest tag from repository url', async () => { + it('parses git ls-remote tag refs from a repository url', async () => { + expect.assertions(2); + mockLsRemoteSuccess(tagRef('v1.5.0')); + + const latest = await getRepositoryLatestTag(repoUrl); + + expect(latest).toBe('v1.5.0'); + expect(execFileMock).toHaveBeenCalledWith( + 'git', + ['ls-remote', '--tags', '--refs', repoUrl], + { encoding: 'utf8' }, + expect.any(Function), + ); + }); + + it('selects v2.1.0 over v2.0.9', async () => { + expect.assertions(1); + + mockLsRemoteSuccess([tagRef('v2.0.9'), tagRef('v2.1.0')].join('\n')); + + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0'); + }); + + it('selects 2.1.0 over 2.0.9', async () => { + expect.assertions(1); + + mockLsRemoteSuccess([tagRef('2.0.9'), tagRef('2.1.0')].join('\n')); + + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('2.1.0'); + }); + + it('handles mixed v and non-v tags', async () => { expect.assertions(1); - gitMock.tags.mockResolvedValueOnce({ latest: '1.5.0' }); - const latest = await getRepositoryLatestTag( - 'git@github.com:emulsify-ds/compound.git', + + mockLsRemoteSuccess( + [tagRef('2.0.10'), tagRef('v2.1.0'), tagRef('v2.0.9')].join('\n'), ); - expect(latest).toBe('1.5.0'); + + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0'); }); - it('Can return empty if no latest tag is found', async () => { + it('ignores malformed lines and non-tag refs', async () => { expect.assertions(1); - gitMock.tags.mockResolvedValueOnce({ latest: '' }); - const latest = await getRepositoryLatestTag( - 'git@github.com:emulsify-ds/compoun.git', + + mockLsRemoteSuccess( + [ + '', + 'malformed', + '1e9c710cde438444fe181d0f1dbc5d106dcaeedf\trefs/heads/main', + tagRef('v2.1.0'), + ].join('\n'), ); - expect(latest).toBe(''); + + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0'); }); - it('should handle errors during init', async () => { - gitMock.init.mockRejectedValueOnce(new Error('init error')); - const url = 'git@github.com:emulsify-ds/compound.git'; - await expect(getRepositoryLatestTag(url)).rejects.toThrow('init error'); + it('ignores non-SemVer tags', async () => { + expect.assertions(1); + + mockLsRemoteSuccess( + [ + tagRef('release-3.0.0'), + tagRef('latest'), + tagRef('2.0'), + tagRef('v2.1.0'), + tagRef('v2.0.9'), + ].join('\n'), + ); + + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0'); }); - it('should handle errors during addRemote', async () => { + it('selects stable releases over matching prereleases', async () => { + expect.assertions(2); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha.1'), tagRef('v2.1.0')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0'); + + mockLsRemoteSuccess( + [tagRef('v2.1.0'), tagRef('v2.1.0-alpha.1')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0'); + }); + + it('orders prerelease identifiers by SemVer precedence', async () => { + expect.assertions(6); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha.1'), tagRef('v2.1.0-alpha.2')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe( + 'v2.1.0-alpha.2', + ); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha.1'), tagRef('v2.1.0-alpha.1.1')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe( + 'v2.1.0-alpha.1.1', + ); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha.1.1'), tagRef('v2.1.0-alpha.1')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe( + 'v2.1.0-alpha.1.1', + ); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha.beta'), tagRef('v2.1.0-alpha.1')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe( + 'v2.1.0-alpha.beta', + ); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha.1'), tagRef('v2.1.0-alpha.beta')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe( + 'v2.1.0-alpha.beta', + ); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha.beta'), tagRef('v2.1.0-alpha.rc')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe( + 'v2.1.0-alpha.rc', + ); + }); + + it('keeps the first tag when equivalent versions are found', async () => { + expect.assertions(2); + + mockLsRemoteSuccess([tagRef('v2.1.0'), tagRef('2.1.0')].join('\n')); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0'); + + mockLsRemoteSuccess( + [tagRef('v2.1.0-alpha'), tagRef('2.1.0-alpha')].join('\n'), + ); + await expect(getRepositoryLatestTag(repoUrl)).resolves.toBe('v2.1.0-alpha'); + }); + + it('throws when no usable SemVer tags are found', async () => { expect.assertions(1); - gitMock.addRemote.mockRejectedValueOnce(new Error('addRemote error')); - const url = 'git@github.com:emulsify-ds/compound.git'; - await expect(getRepositoryLatestTag(url)).rejects.toThrow( - 'addRemote error', + + mockLsRemoteSuccess( + [tagRef('release-3.0.0'), tagRef('latest'), tagRef('2.0')].join('\n'), + ); + + await expect(getRepositoryLatestTag(repoUrl)).rejects.toThrow( + `No usable SemVer tags were found in repository ${repoUrl}.`, ); }); - it('should handle errors during removeRemote', async () => { - gitMock.removeRemote.mockRejectedValueOnce(new Error('removeRemote error')); - const latest = await getRepositoryLatestTag( - 'git@github.com:emulsify-ds/compound.git', + it('throws a helpful error when the repository cannot be read', async () => { + expect.assertions(1); + + mockLsRemoteFailure('fatal: could not read from remote repository'); + + await expect(getRepositoryLatestTag(repoUrl)).rejects.toThrow( + `Unable to read tags from repository ${repoUrl}: fatal: could not read from remote repository`, ); - expect(latest).toBe('1.5.0'); }); - it('should handle errors during fetch', async () => { - gitMock.fetch.mockRejectedValueOnce(new Error('fetch error')); - const url = 'git@github.com:emulsify-ds/compound.git'; - await expect(getRepositoryLatestTag(url)).rejects.toThrow('fetch error'); + it('uses the command error message when stderr is empty', async () => { + expect.assertions(1); + + mockLsRemoteFailure(''); + + await expect(getRepositoryLatestTag(repoUrl)).rejects.toThrow( + `Unable to read tags from repository ${repoUrl}: Command failed`, + ); }); - it('should handle errors during tags', async () => { - gitMock.tags.mockRejectedValueOnce(new Error('tags error')); - const url = 'git@github.com:emulsify-ds/compound.git'; - await expect(getRepositoryLatestTag(url)).rejects.toThrow('tags error'); + it('does not use simple-git current-directory mutations', async () => { + expect.assertions(6); + const gitMock = { + init: jest.fn(), + removeRemote: jest.fn(), + addRemote: jest.fn(), + fetch: jest.fn(), + tags: jest.fn(), + }; + simpleGitMock.mockReturnValue(gitMock); + + mockLsRemoteSuccess(tagRef('v1.5.0')); + + await getRepositoryLatestTag(repoUrl); + + expect(simpleGitMock).not.toHaveBeenCalled(); + expect(gitMock.init).not.toHaveBeenCalled(); + expect(gitMock.removeRemote).not.toHaveBeenCalled(); + expect(gitMock.addRemote).not.toHaveBeenCalled(); + expect(gitMock.fetch).not.toHaveBeenCalled(); + expect(gitMock.tags).not.toHaveBeenCalled(); }); }); From 2ce2ffdfec994403e0303349c2d09096d02e495a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 15:39:19 -0500 Subject: [PATCH 04/26] fix: deduplicate available systems --- src/util/system/getAvailableSystems.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/util/system/getAvailableSystems.ts b/src/util/system/getAvailableSystems.ts index 62712c7..37f1d60 100644 --- a/src/util/system/getAvailableSystems.ts +++ b/src/util/system/getAvailableSystems.ts @@ -23,10 +23,5 @@ export default async function getAvailableSystems(): Promise< repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', platforms: ['none', 'drupal'], }, - { - name: 'emulsify-ui-kit', - repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', - platforms: ['drupal'], - }, ]; } From 22eef88af79f6ed2eaf74b9f0ae99582a3679255 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 15:40:19 -0500 Subject: [PATCH 05/26] test: cover available system uniqueness --- src/handlers/systemList.test.ts | 27 +++++++++++++++++++-- src/util/system/getAvailableSystems.test.ts | 14 +++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/handlers/systemList.test.ts b/src/handlers/systemList.test.ts index d2736db..9991721 100644 --- a/src/handlers/systemList.test.ts +++ b/src/handlers/systemList.test.ts @@ -4,11 +4,34 @@ import log from '../lib/log.js'; import systemList from './systemList.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; +const logMock = log as jest.Mock; + describe('systemList', () => { + beforeEach(() => { + logMock.mockClear(); + }); + it('can list all available out-of-the-box systems', async () => { - expect.assertions(1); + expect.assertions(5); const systems = await getAvailableSystems(); await systemList(); - expect(log).toHaveBeenCalledTimes(systems.length); + const loggedMessages = logMock.mock.calls.map(([, message]) => message); + + expect(logMock).toHaveBeenCalledTimes(systems.length); + expect(logMock).toHaveBeenNthCalledWith( + 1, + 'info', + 'compound - https://github.com/emulsify-ds/compound.git', + ); + expect(logMock).toHaveBeenNthCalledWith( + 2, + 'info', + 'emulsify-ui-kit - https://github.com/emulsify-ds/emulsify-ui-kit.git', + ); + expect(loggedMessages).toEqual([ + 'compound - https://github.com/emulsify-ds/compound.git', + 'emulsify-ui-kit - https://github.com/emulsify-ds/emulsify-ui-kit.git', + ]); + expect(new Set(loggedMessages).size).toBe(loggedMessages.length); }); }); diff --git a/src/util/system/getAvailableSystems.test.ts b/src/util/system/getAvailableSystems.test.ts index a2cb24f..a9ad72f 100644 --- a/src/util/system/getAvailableSystems.test.ts +++ b/src/util/system/getAvailableSystems.test.ts @@ -14,11 +14,15 @@ describe('getAvailableSystems', () => { repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', platforms: ['none', 'drupal'], }, - { - name: 'emulsify-ui-kit', - repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', - platforms: ['drupal'], - }, ]); }); + + it('returns unique system names', async () => { + expect.assertions(2); + const systems = await getAvailableSystems(); + const systemNames = systems.map(({ name }) => name); + + expect(systemNames).toEqual(['compound', 'emulsify-ui-kit']); + expect(new Set(systemNames).size).toBe(systemNames.length); + }); }); From 2a4f0a2ea00b315cbb2cdbbfd7bfd14739777540 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 15:44:36 -0500 Subject: [PATCH 06/26] ci: add pull request validation workflow --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e8043b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: [develop, main] + +permissions: + contents: read + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.x' + cache: npm + - name: Install + run: npm ci + - name: Build + run: npm run build + - name: Typecheck + run: npm run type + - name: Test + run: npm test + - name: Pack + run: npm pack --dry-run From d25b3defa7ddd7d760e62c2ebc13bef92f1b990c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 15:52:03 -0500 Subject: [PATCH 07/26] docs: update cli command usage --- README.md | 138 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 126 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index aab24c1..a1299d7 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ [![Emulsify Design System](https://user-images.githubusercontent.com/409903/170579210-327abcdd-2c98-4922-87bb-36446a4cc013.svg)](https://www.emulsify.info/) ![npm](https://img.shields.io/npm/dm/@emulsify/cli?style=flat-square) -# emulsify-cli +# Emulsify CLI -Command line interface for Emulsify. +Command line interface for Emulsify projects. ## Installation -This project is deployed to [npm](https://www.npmjs.com/package/@emulsify/cli). In order to use this CLI, install it as a global dependency: +Emulsify CLI requires Node 24 or newer. + +Install Emulsify CLI globally from [npm](https://www.npmjs.com/package/@emulsify/cli): ```bash npm install -g @emulsify/cli @@ -15,11 +17,113 @@ npm install -g @emulsify/cli ## Usage -For more information on how to use emulsify-cli, please see the [usage documentation](https://www.emulsify.info/docs/supporting-projects/emulsify-cli/emulsify-cli-usage). +Run `emulsify --help` for the current command list. + +### Commands + +| Command | Alias | Description | +| ----------------------------------- | ----------------------------- | ----------------------------------------------------------------- | +| `emulsify init [name] [path]` | | Initializes an Emulsify project from a starter. | +| `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | +| `emulsify system install [name]` | | Installs a system in the current Emulsify project. | +| `emulsify component list` | `emulsify component ls` | Lists components available from the installed system and variant. | +| `emulsify component install [name]` | `emulsify component i [name]` | Installs one component from the installed system and variant. | +| `emulsify component create [name]` | `emulsify component c [name]` | Creates a local component in the current Emulsify project. | + +### Initialize A Project + +`emulsify init [name] [path]` clones a starter, writes the project configuration, installs dependencies, runs the starter init hook when present, and removes the starter Git history. + +Options: + +- `--machineName `: Sets the machine-friendly project name. When omitted, Emulsify CLI derives it from the project name. +- `--starter `: Uses a specific starter repository. +- `--checkout `: Checks out a specific starter commit, branch, or tag. +- `--platform `: Sets the project platform when auto-detection is unavailable or should be overridden. For Drupal projects, pass `drupal`. +- `--yes`: Accepts default init values for missing options without prompting. + +Current starter repositories: + +- `https://github.com/emulsify-ds/emulsify-starter` +- `https://github.com/emulsify-ds/emulsify-drupal-starter` + +Examples: + +```bash +emulsify init "My Project" ./projects +emulsify init "My Theme" ./web/themes/custom --platform drupal --yes +emulsify init "My Project" ./projects --starter https://github.com/emulsify-ds/emulsify-starter --checkout main +``` + +### Systems + +`emulsify system list` lists the built-in systems that Emulsify CLI can install. `emulsify system ls` is the same command. + +```bash +emulsify system list +emulsify system ls +``` + +`emulsify system install [name]` installs a system in the current Emulsify project. Use `compound` to install the built-in Compound system. The command installs required components by default. + +Options: + +- `--repository `: Installs a system from a specific Git repository. +- `--checkout `: Checks out a specific system commit, branch, or tag. This is required when `--repository` is used. +- `--all`: Installs all available components from the system instead of only required components. -### Customizing component templates +Examples: + +```bash +emulsify system install compound +emulsify system install compound --all +emulsify system install --repository https://github.com/example/example-system.git --checkout v1.0.0 +``` -Projects can override the built-in `component create` templates by adding files under `.cli/templates/` at the Emulsify project root. Overrides replace only the known artifacts that the CLI already generates; they do not add extra files or change which files are created. +### Components + +`emulsify component list` lists components available from the installed system and variant. `emulsify component ls` is the same command. + +```bash +emulsify component list +emulsify component ls +``` + +`emulsify component install [name]` installs one component from the installed system and variant. `emulsify component i [name]` is the same command. + +Options: + +- `--force`: Replaces an installed component. +- `--all`: Installs all available components instead of one named component. + +Examples: + +```bash +emulsify component install button +emulsify component i card --force +emulsify component install --all +``` + +`emulsify component create [name]` creates a local component in the current Emulsify project. `emulsify component c [name]` is the same command. + +Options: + +- `--directory `: Sets the variant structure directory where the component is created. +- `--format `: Sets the component format. Supported values are `default` and `sdc`. +- `--yes`: Replaces an existing component without an overwrite confirmation prompt. + +In non-interactive environments, pass both `--directory` and `--format`. + +Examples: + +```bash +emulsify component create card --directory base --format default +emulsify component create teaser --directory molecules --format sdc --yes +``` + +### Component Template Overrides + +Projects can override the built-in `component create` templates by adding component template override files under `.cli/templates/` at the Emulsify project root. Overrides replace only the known artifacts that Emulsify CLI already generates; they do not add extra files or change which files are created. Default component overrides: @@ -36,13 +140,23 @@ SDC component overrides: - `.cli/templates/sdc/component.js` - `.cli/templates/sdc/component.stories.js` -Override files can use double-brace tokens. Supported tokens are `{{ filename }}`, `{{ className }}`, `{{ camelName }}`, `{{ snakeName }}`, `{{ humanName }}`, `{{ directory }}`, and `{{ format }}`. Unknown tokens are left unchanged and logged as warnings. +Override files can use double-brace tokens: + +- `{{ filename }}` +- `{{ className }}` +- `{{ camelName }}` +- `{{ snakeName }}` +- `{{ humanName }}` +- `{{ directory }}` +- `{{ format }}` + +For each generated artifact, Emulsify CLI first checks for the matching override file. If the override is missing, the built-in template is used. If the override exists but is empty, the built-in template is used and a warning is logged. Unknown tokens are left unchanged and logged as warnings. Partial override sets are supported, so a project can override only `component.twig` and keep the built-in SCSS, data, and story templates. Extra arbitrary files are not generated by component template overrides. -For each generated artifact, the CLI first checks for the matching override file. If the override is missing or empty, the built-in template is used. Partial override sets are supported, so a project can override only `component.twig` and keep the built-in SCSS, data, and story templates. Arbitrary extra files and template ejection helpers are future work. +For more documentation, see the [Emulsify CLI usage documentation](https://www.emulsify.info/docs/supporting-projects/emulsify-cli/emulsify-cli-usage). ## Development -Emulsify-cli is developed using TypeScript. You can find all of the source files in the `src` directory, which is organized in the following manner: +Emulsify CLI is developed using TypeScript. You can find all of the source files in the `src` directory, which is organized in the following manner: - `src/index.ts` - uses Commander to compose the CLI. - `src/handlers` - contains all functions that handle CLI commands, such as `emulsify init`. @@ -54,7 +168,7 @@ Emulsify-cli is developed using TypeScript. You can find all of the source files ### Setup -- Install the version of node as specified in this project's `.nvmrc` file. If you are using nvm, simply run `nvm use`. +- Install the version of Node specified in this project's `.nvmrc` file. If you are using nvm, run `nvm use`. - Clone this repository into a directory of your choosing. - In the directory, run `npm install`. - Build the project: `npm run build`, or watch: `npm run watch`. @@ -63,7 +177,7 @@ Emulsify-cli is developed using TypeScript. You can find all of the source files ### Scripts - `npm run build`: builds the project based on the configuration in `tsconfig.dist.json`. -- `npm run build-schema-types`: Compiles the json-schema definitions within this project into ts types. +- `npm run build-schema-types`: Compiles the JSON Schema definitions within this project into TypeScript types. - `npm run watch`: watches the codebase, and re-compiles every time a change is made. - `npm run format`: uses prettier to format all ts files within the codebase. - `npm run lint`: runs the Jest test suite via the `test` script. @@ -74,7 +188,7 @@ Emulsify-cli is developed using TypeScript. You can find all of the source files ## Deployment -This project is automatically built and deployed to NPM via the release GitHub Actions workflow. Pushes to `main` run `npm run build` and then `npm run semantic-release`. +This project is automatically built and deployed to npm via the release GitHub Actions workflow. Pushes to `main` run `npm run build` and then `npm run semantic-release`. ### Contributors From 8ed5814ae2bb2b9bb6f69332f6fd014a68cba11f Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 15:53:02 -0500 Subject: [PATCH 08/26] docs: add website cli update handoff --- docs/emulsify-info-cli-updates.md | 146 ++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/emulsify-info-cli-updates.md diff --git a/docs/emulsify-info-cli-updates.md b/docs/emulsify-info-cli-updates.md new file mode 100644 index 0000000..014127b --- /dev/null +++ b/docs/emulsify-info-cli-updates.md @@ -0,0 +1,146 @@ +# Emulsify CLI Website Updates + +Copy-ready updates for the emulsify.info Emulsify CLI usage page. + +## Installation + +Emulsify CLI requires Node 24 or newer. + +Install Emulsify CLI globally with npm: + +```bash +npm install -g @emulsify/cli +``` + +## Commands + +| Command | Alias | Description | +| ----------------------------------- | ----------------------------- | ----------------------------------------------------------------- | +| `emulsify init [name] [path]` | | Initializes an Emulsify project from a starter. | +| `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | +| `emulsify system install [name]` | | Installs a system in the current Emulsify project. | +| `emulsify component list` | `emulsify component ls` | Lists components available from the installed system and variant. | +| `emulsify component install [name]` | `emulsify component i [name]` | Installs one component from the installed system and variant. | +| `emulsify component create [name]` | `emulsify component c [name]` | Creates a local component in the current Emulsify project. | + +## Initialize A Project + +`emulsify init [name] [path]` clones a starter, writes the project configuration, installs dependencies, runs the starter init hook when present, and removes the starter Git history. + +Options: + +- `--machineName `: Sets the machine-friendly project name. When omitted, Emulsify CLI derives it from the project name. +- `--starter `: Uses a specific starter repository. +- `--checkout `: Checks out a specific starter commit, branch, or tag. +- `--platform `: Sets the project platform when auto-detection is unavailable or should be overridden. For Drupal projects, pass `drupal`. +- `--yes`: Accepts default init values for missing options without prompting. + +Current starter repositories: + +- `https://github.com/emulsify-ds/emulsify-starter` +- `https://github.com/emulsify-ds/emulsify-drupal-starter` + +Examples: + +```bash +emulsify init "My Project" ./projects +emulsify init "My Theme" ./web/themes/custom --platform drupal --yes +emulsify init "My Project" ./projects --starter https://github.com/emulsify-ds/emulsify-starter --checkout main +``` + +## Systems + +`emulsify system list` lists the built-in systems that Emulsify CLI can install. `emulsify system ls` is the same command. + +```bash +emulsify system list +emulsify system ls +``` + +`emulsify system install [name]` installs a system in the current Emulsify project. Use `compound` to install the built-in Compound system. The command installs required components by default. + +Options: + +- `--repository `: Installs a system from a specific Git repository. +- `--checkout `: Checks out a specific system commit, branch, or tag. This is required when `--repository` is used. +- `--all`: Installs all available components from the system instead of only required components. + +Examples: + +```bash +emulsify system install compound +emulsify system install compound --all +emulsify system install --repository https://github.com/example/example-system.git --checkout v1.0.0 +``` + +## Components + +`emulsify component list` lists components available from the installed system and variant. `emulsify component ls` is the same command. + +```bash +emulsify component list +emulsify component ls +``` + +`emulsify component install [name]` installs one component from the installed system and variant. `emulsify component i [name]` is the same command. + +Options: + +- `--force`: Replaces an installed component. +- `--all`: Installs all available components instead of one named component. + +Examples: + +```bash +emulsify component install button +emulsify component i card --force +emulsify component install --all +``` + +`emulsify component create [name]` creates a local component in the current Emulsify project. `emulsify component c [name]` is the same command. + +Options: + +- `--directory `: Sets the variant structure directory where the component is created. +- `--format `: Sets the component format. Supported values are `default` and `sdc`. +- `--yes`: Replaces an existing component without an overwrite confirmation prompt. + +In non-interactive environments, pass both `--directory` and `--format`. + +Examples: + +```bash +emulsify component create card --directory base --format default +emulsify component create teaser --directory molecules --format sdc --yes +``` + +## Component Template Overrides + +Projects can override the built-in `component create` templates by adding component template override files under `.cli/templates/` at the Emulsify project root. Overrides replace only the known artifacts that Emulsify CLI already generates; they do not add extra files or change which files are created. + +Default component overrides: + +- `.cli/templates/default/component.twig` +- `.cli/templates/default/component.scss` +- `.cli/templates/default/component.yml` +- `.cli/templates/default/component.stories.js` + +SDC component overrides: + +- `.cli/templates/sdc/component.twig` +- `.cli/templates/sdc/component.scss` +- `.cli/templates/sdc/component.component.yml` +- `.cli/templates/sdc/component.js` +- `.cli/templates/sdc/component.stories.js` + +Override files can use double-brace tokens: + +- `{{ filename }}` +- `{{ className }}` +- `{{ camelName }}` +- `{{ snakeName }}` +- `{{ humanName }}` +- `{{ directory }}` +- `{{ format }}` + +For each generated artifact, Emulsify CLI first checks for the matching override file. If the override is missing, the built-in template is used. If the override exists but is empty, the built-in template is used and a warning is logged. Unknown tokens are left unchanged and logged as warnings. Partial override sets are supported, so a project can override only `component.twig` and keep the built-in SCSS, data, and story templates. Extra arbitrary files are not generated by component template overrides. From 5a7748d2585d8e57894cc46f61eacd27bc1577eb Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:00:00 -0500 Subject: [PATCH 09/26] feat: add safe project path resolver --- src/util/fs/safeResolveWithin.test.ts | 53 +++++++++++++++++++++++++++ src/util/fs/safeResolveWithin.ts | 50 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/util/fs/safeResolveWithin.test.ts create mode 100644 src/util/fs/safeResolveWithin.ts diff --git a/src/util/fs/safeResolveWithin.test.ts b/src/util/fs/safeResolveWithin.test.ts new file mode 100644 index 0000000..ecf77b1 --- /dev/null +++ b/src/util/fs/safeResolveWithin.test.ts @@ -0,0 +1,53 @@ +import safeResolveWithin from './safeResolveWithin.js'; + +describe('safeResolveWithin', () => { + const root = '/workspace/project'; + + it('accepts normalized paths that remain inside the root', () => { + expect( + safeResolveWithin( + root, + './components/../components/00-base/button', + 'Component destination', + ), + ).toBe('/workspace/project/components/00-base/button'); + }); + + it('accepts target path segments that remain inside the root', () => { + expect( + safeResolveWithin( + root, + ['components', '00-base', 'button'], + 'Component destination', + ), + ).toBe('/workspace/project/components/00-base/button'); + }); + + it('rejects path traversal outside the root', () => { + expect(() => + safeResolveWithin(root, '../../outside', 'Component destination'), + ).toThrow( + 'Component destination "../../outside" resolves to "/outside", which is outside the expected root "/workspace/project".', + ); + }); + + it('rejects absolute paths outside the root', () => { + expect(() => + safeResolveWithin(root, '/tmp/outside', 'Asset destination'), + ).toThrow( + 'Asset destination "/tmp/outside" resolves to "/tmp/outside", which is outside the expected root "/workspace/project".', + ); + }); + + it('rejects the root itself by default', () => { + expect(() => safeResolveWithin(root, '.', 'Asset destination')).toThrow( + 'Asset destination "." resolves to the expected root "/workspace/project", but a path inside the root is required.', + ); + }); + + it('allows the root itself only when explicitly requested', () => { + expect( + safeResolveWithin(root, '.', 'Project root', { allowRoot: true }), + ).toBe('/workspace/project'); + }); +}); diff --git a/src/util/fs/safeResolveWithin.ts b/src/util/fs/safeResolveWithin.ts new file mode 100644 index 0000000..253e16d --- /dev/null +++ b/src/util/fs/safeResolveWithin.ts @@ -0,0 +1,50 @@ +import { isAbsolute, relative, resolve } from 'path'; + +type SafeResolveWithinOptions = { + allowRoot?: boolean; +}; + +function formatTarget(target: string | string[]): string { + return Array.isArray(target) ? target.join('/') : target; +} + +/** + * Resolve a target path and ensure it stays within an expected root. + * + * @param rootPath absolute or relative root directory path. + * @param target path string or path segments to resolve from the root. + * @param label user-facing label for error messages. + * @param options.allowRoot whether the target may resolve exactly to the root. + * @returns absolute resolved target path. + */ +export default function safeResolveWithin( + rootPath: string, + target: string | string[], + label: string, + { allowRoot = false }: SafeResolveWithinOptions = {}, +): string { + const root = resolve(rootPath); + const resolvedTarget = Array.isArray(target) + ? resolve(root, ...target) + : resolve(root, target); + const targetLabel = formatTarget(target); + const relativePath = relative(root, resolvedTarget); + + if (relativePath === '') { + if (allowRoot) { + return resolvedTarget; + } + + throw new Error( + `${label} "${targetLabel}" resolves to the expected root "${root}", but a path inside the root is required.`, + ); + } + + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + throw new Error( + `${label} "${targetLabel}" resolves to "${resolvedTarget}", which is outside the expected root "${root}".`, + ); + } + + return resolvedTarget; +} From ce5caf712e3d524a9d14bbf566afe1e98e762df1 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:01:51 -0500 Subject: [PATCH 10/26] fix: guard component file destinations --- src/util/project/generateComponent.test.ts | 31 ++++++++++++++++ src/util/project/generateComponent.ts | 28 ++++++++++---- .../project/installComponentFromCache.test.ts | 37 ++++++++++++++++++- src/util/project/installComponentFromCache.ts | 9 ++++- 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index f43397c..0fe59aa 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -229,6 +229,37 @@ describe('generateComponent', () => { ); }); + it('rejects unsafe structure directories before writing or removing files', async () => { + expect.assertions(3); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(true); + + await expect( + generateComponent( + { + ...variant, + structureImplementations: [ + { + name: 'base', + directory: '../../outside', + }, + ], + } as EmulsifyVariant, + 'link', + { + directory: 'base', + format: 'default', + yes: true, + }, + ), + ).rejects.toThrow( + 'Component structure directory "../../outside" resolves to "/home/uname/Projects/cornflake/web/themes/outside", which is outside the expected root "/home/uname/Projects/cornflake/web/themes/custom/themename".', + ); + + expect(removeMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + it('should cancel component creation if user declines overwrite', async () => { expect.assertions(2); (select as jest.Mock).mockResolvedValueOnce('default'); // format diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index c201f25..d49e830 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -3,12 +3,13 @@ import type { CreateComponentHandlerOptions } from '@emulsify-cli/handlers'; import { select, confirm } from '@inquirer/prompts'; import { promises as fs } from 'fs'; -import { join, dirname } from 'path'; +import { dirname } from 'path'; import { pathExists, remove } from 'fs-extra'; import { cyan, green, bold, yellow } from 'colorette'; import log from '../../lib/log.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +import safeResolveWithin from '../fs/safeResolveWithin.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; import deriveComponentNames from '../deriveComponentNames.js'; import resolveComponentTemplate from './resolveComponentTemplate.js'; @@ -96,6 +97,7 @@ export default async function generateComponent( 'Unable to find an Emulsify project to create the component into.', ); } + const projectRoot = dirname(path); // Prompts are only used for interactive TTY sessions; CI must provide flags so // the command never waits for input it cannot receive. @@ -141,14 +143,23 @@ export default async function generateComponent( } // Calculate the parent path based on the path to the Emulsify project and the component's structure. - const parentPath = join(dirname(path), structure.directory); + const parentPath = safeResolveWithin( + projectRoot, + structure.directory, + 'Component structure directory', + { allowRoot: true }, + ); if (!(await pathExists(parentPath))) { // Create the component's parent directory. await fs.mkdir(parentPath, { recursive: true }); } // Calculate the destination path (always kebab-case folder name). - const destination = join(dirname(path), structure.directory, filename); + const destination = safeResolveWithin( + projectRoot, + [structure.directory, filename], + 'Component destination', + ); // If the component already exists within the project, // ask the user if they want to replace it. @@ -244,16 +255,19 @@ export default async function generateComponent( // the byte-for-byte built-in builders for each known generated artifact. const templateFile = (await resolveComponentTemplate( - dirname(path), + projectRoot, format, artifact.logicalName, templateVars, )) ?? artifact.build(); - await fs.writeFile( - join(destination, artifact.destinationName), - templateFile, + const artifactDestination = safeResolveWithin( + projectRoot, + [structure.directory, filename, artifact.destinationName], + 'Component file destination', ); + + await fs.writeFile(artifactDestination, templateFile); } return log( diff --git a/src/util/project/installComponentFromCache.test.ts b/src/util/project/installComponentFromCache.test.ts index 759f0b1..011aa38 100644 --- a/src/util/project/installComponentFromCache.test.ts +++ b/src/util/project/installComponentFromCache.test.ts @@ -11,8 +11,17 @@ const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( '/home/username/Projects/drupal-project/web/themes/custom/themename/project.emulsify.json', ); const pathExistsMock = (pathExists as jest.Mock).mockResolvedValue(false); +const copyItemMock = copyItemFromCache as jest.Mock; describe('installComponentFromCache', () => { + beforeEach(() => { + jest.clearAllMocks(); + findFileMock.mockReturnValue( + '/home/username/Projects/drupal-project/web/themes/custom/themename/project.emulsify.json', + ); + pathExistsMock.mockResolvedValue(false); + }); + const system = { name: 'compound', } as EmulsifySystem; @@ -71,6 +80,32 @@ describe('installComponentFromCache', () => { ); }); + it('rejects unsafe component destinations before checking or copying files', async () => { + expect.assertions(3); + + await expect( + installComponentFromCache( + system, + { + ...variant, + structureImplementations: [ + { + name: 'base', + directory: '../../outside', + }, + ], + } as EmulsifyVariant, + 'link', + true, + ), + ).rejects.toThrow( + 'Component destination "../../outside/link" resolves to "/home/username/Projects/drupal-project/web/themes/outside/link", which is outside the expected root "/home/username/Projects/drupal-project/web/themes/custom/themename".', + ); + + expect(pathExistsMock).not.toHaveBeenCalled(); + expect(copyItemMock).not.toHaveBeenCalled(); + }); + it('throws an error if the component is already installed, and force is false', async () => { expect.assertions(1); pathExistsMock.mockResolvedValueOnce(true); @@ -84,7 +119,7 @@ describe('installComponentFromCache', () => { it('copies the component from the cached item into the correct destination', async () => { expect.assertions(1); await installComponentFromCache(system, variant, 'link'); - expect(copyItemFromCache as jest.Mock).toHaveBeenCalledWith( + expect(copyItemMock).toHaveBeenCalledWith( 'systems', ['compound', './components/00-base', 'link'], '/home/username/Projects/drupal-project/web/themes/custom/themename/components/00-base/link', diff --git a/src/util/project/installComponentFromCache.ts b/src/util/project/installComponentFromCache.ts index dbfb3eb..ccf836c 100644 --- a/src/util/project/installComponentFromCache.ts +++ b/src/util/project/installComponentFromCache.ts @@ -1,8 +1,9 @@ import { pathExists } from 'fs-extra'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; -import { join, dirname } from 'path'; +import { dirname } from 'path'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +import safeResolveWithin from '../fs/safeResolveWithin.js'; import copyItemFromCache from '../cache/copyItemFromCache.js'; /** @@ -37,7 +38,11 @@ export function getComponentDestination( ); } - return join(dirname(projectConfigPath), structure.directory, component.name); + return safeResolveWithin( + dirname(projectConfigPath), + [structure.directory, component.name], + 'Component destination', + ); } /** From 7c77ee901e3bd6ca7ed7c566cbb44a7eba61e2d0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:02:36 -0500 Subject: [PATCH 11/26] fix: guard general asset destinations --- .../installGeneralAssetsFromCache.test.ts | 27 ++++++++++++++++++- .../project/installGeneralAssetsFromCache.ts | 10 +++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/util/project/installGeneralAssetsFromCache.test.ts b/src/util/project/installGeneralAssetsFromCache.test.ts index abd5bd2..1f57c41 100644 --- a/src/util/project/installGeneralAssetsFromCache.test.ts +++ b/src/util/project/installGeneralAssetsFromCache.test.ts @@ -13,8 +13,13 @@ const copyItemMock = (copyItemFromCache as jest.Mock).mockResolvedValue(true); describe('installGeneralAssetsFromCache', () => { beforeEach(() => { - copyItemMock.mockClear(); + jest.clearAllMocks(); + findFileMock.mockReturnValue( + '/home/username/Projects/drupal-project/web/themes/custom/themename/project.emulsify.json', + ); + copyItemMock.mockResolvedValue(true); }); + const system = { name: 'compound', } as EmulsifySystem; @@ -45,6 +50,26 @@ describe('installGeneralAssetsFromCache', () => { ); }); + it('rejects unsafe general asset destination paths before copying files', async () => { + expect.assertions(2); + + await expect( + installGeneralAssetsFromCache(system, { + directories: [ + { + name: 'unsafe', + path: './components/unsafe', + destinationPath: '../../outside', + }, + ], + } as EmulsifyVariant), + ).rejects.toThrow( + 'General asset destination "../../outside" resolves to "/home/username/Projects/drupal-project/web/themes/outside", which is outside the expected root "/home/username/Projects/drupal-project/web/themes/custom/themename".', + ); + + expect(copyItemMock).not.toHaveBeenCalled(); + }); + it('copies all general files and directories into the Emulsify project', async () => { expect.assertions(2); await installGeneralAssetsFromCache(system, variant); diff --git a/src/util/project/installGeneralAssetsFromCache.ts b/src/util/project/installGeneralAssetsFromCache.ts index ed80606..4aaacd9 100644 --- a/src/util/project/installGeneralAssetsFromCache.ts +++ b/src/util/project/installGeneralAssetsFromCache.ts @@ -1,7 +1,8 @@ import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; -import { join, dirname } from 'path'; +import { dirname } from 'path'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +import safeResolveWithin from '../fs/safeResolveWithin.js'; import copyItemFromCache from '../cache/copyItemFromCache.js'; import catchLater from '../catchLater.js'; @@ -24,11 +25,16 @@ export default async function installGeneralAssetsFromCache( 'Unable to find an Emulsify project to install assets into.', ); } + const projectRoot = dirname(path); const assets = [...(variant.directories || []), ...(variant.files || [])]; const promises = []; for (const asset of assets) { - const destination = join(dirname(path), asset.destinationPath); + const destination = safeResolveWithin( + projectRoot, + asset.destinationPath, + 'General asset destination', + ); promises.push( catchLater( copyItemFromCache( From c32a1634dbfc67406a110af2b124633a6652d1b6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:06:24 +0000 Subject: [PATCH 12/26] chore(release): bump version to 2.1.0 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 734fda1..bfccc1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@emulsify/cli", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.0.1", + "version": "2.1.0", "license": "GPL-2.0", "dependencies": { "@inquirer/prompts": "^8.5.2", diff --git a/package.json b/package.json index d473208..9263086 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.0.1", + "version": "2.1.0", "description": "Command line interface for Emulsify", "repository": "git@github.com:emulsify-ds/emulsify-cli.git", "author": "Patrick Coffey ", From 9aa7f0f1aa1f251ba2ab6ab96bfe73eef1b449b9 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:07:51 -0500 Subject: [PATCH 13/26] fix: execute hook scripts without shell strings --- src/util/fs/executeScript.ts | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/util/fs/executeScript.ts b/src/util/fs/executeScript.ts index 26569e8..3e86836 100644 --- a/src/util/fs/executeScript.ts +++ b/src/util/fs/executeScript.ts @@ -1,4 +1,6 @@ -import { exec } from 'child_process'; +import { execFile } from 'child_process'; +import { dirname } from 'path'; + /** * Takes a path to a script, and executes it. * @@ -8,12 +10,28 @@ export default async function executeScript( scriptPath: string, ): Promise { return new Promise((resolve, reject) => { - exec(scriptPath, (error, stdout, stderr) => { - if (error) { - return reject(error); - } + execFile( + process.execPath, + [scriptPath], + { + // Run from the hook directory so hook-relative file operations do not + // depend on the shell location that invoked the CLI. + cwd: dirname(scriptPath), + encoding: 'utf8', + }, + (error, stdout, stderr) => { + if (error) { + const output = stderr.trim() || error.message; + + return reject( + new Error( + `Unable to execute hook script "${scriptPath}": ${output}`, + ), + ); + } - resolve(stdout ? stdout : stderr); - }); + resolve(stdout || stderr || ''); + }, + ); }); } From 7021b0cd6d22efd3cc0f4c891ebccdcfb9f87333 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:08:45 -0500 Subject: [PATCH 14/26] test: cover hook script execution --- src/util/fs/executeScript.test.ts | 108 ++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/src/util/fs/executeScript.test.ts b/src/util/fs/executeScript.test.ts index 02f372b..81b3ea2 100644 --- a/src/util/fs/executeScript.test.ts +++ b/src/util/fs/executeScript.test.ts @@ -1,40 +1,106 @@ -// @ts-nocheck -// This file is escaping ts checks for now, because the child_process.exec -// mock fn is selecting a specific exec overload that is incorrect. -// @TODO: dig into this and figure out how to get typescript to use -// the correct overload. -import childproc from 'child_process'; +import { execFile } from 'child_process'; import executeScript from './executeScript.js'; -const execMock = jest.spyOn(childproc, 'exec'); +jest.mock('child_process', () => ({ + execFile: jest.fn(), +})); + +const execFileMock = execFile as unknown as jest.Mock; + +type ExecFileCallback = ( + error: Error | null, + stdout: string, + stderr: string, +) => void; + +function mockExecFileResult( + error: Error | null, + stdout = '', + stderr = '', +): void { + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: { cwd: string; encoding: string }, + callback: ExecFileCallback, + ) => { + callback(error, stdout, stderr); + }, + ); +} describe('executeScript', () => { - it('can execute a script, and resolve the stdout', async () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('executes a hook script with node and resolves stdout', async () => { expect.assertions(2); - execMock.mockImplementationOnce((_, callback: () => void) => - callback(null, 'done'), + const scriptPath = '/project/.cli/init.js'; + mockExecFileResult(null, 'done'); + + await expect(executeScript(scriptPath)).resolves.toBe('done'); + expect(execFileMock).toHaveBeenCalledWith( + process.execPath, + [scriptPath], + { + cwd: '/project/.cli', + encoding: 'utf8', + }, + expect.any(Function), ); - await expect(executeScript('path.js')).resolves.toBe('done'); - expect(execMock).toHaveBeenCalledWith('path.js', expect.any(Function)); }); - it('can execute a script, and resolve the stderr', async () => { + it('passes a hook path with spaces as an argument instead of a shell string', async () => { expect.assertions(1); - execMock.mockImplementationOnce((_, callback: () => void) => - callback(null, null, 'well, that went poorly'), + const scriptPath = '/project with spaces/.cli/init.js'; + mockExecFileResult(null, 'done'); + + await executeScript(scriptPath); + + expect(execFileMock).toHaveBeenCalledWith( + process.execPath, + [scriptPath], + { + cwd: '/project with spaces/.cli', + encoding: 'utf8', + }, + expect.any(Function), ); - await expect(executeScript('path.js')).resolves.toBe( + }); + + it('resolves stderr when stdout is empty', async () => { + expect.assertions(1); + mockExecFileResult(null, '', 'well, that went poorly'); + + await expect(executeScript('/project/.cli/init.js')).resolves.toBe( 'well, that went poorly', ); }); - it('can execute a script, and reject with an error', async () => { + it('resolves an empty string when stdout and stderr are empty', async () => { + expect.assertions(1); + mockExecFileResult(null); + + await expect(executeScript('/project/.cli/init.js')).resolves.toBe(''); + }); + + it('rejects failed hooks with stderr context', async () => { expect.assertions(1); - execMock.mockImplementationOnce((_, callback: () => void) => - callback(new Error('well, that went SUPER poorly')), + mockExecFileResult(new Error('Command failed'), '', 'hook failed\n'); + + await expect(executeScript('/project/.cli/init.js')).rejects.toThrow( + 'Unable to execute hook script "/project/.cli/init.js": hook failed', ); - await expect(executeScript('path.js')).rejects.toEqual( - Error('well, that went SUPER poorly'), + }); + + it('rejects execution failures with the process error message', async () => { + expect.assertions(1); + mockExecFileResult(new Error('spawn failed')); + + await expect(executeScript('/project/.cli/init.js')).rejects.toThrow( + 'Unable to execute hook script "/project/.cli/init.js": spawn failed', ); }); }); From 73e3c60570f8133a4ef7b55d83b86d1e99a9983c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:15:38 -0500 Subject: [PATCH 15/26] fix: make component dependency resolution cycle safe --- .../project/buildComponentDependencyList.ts | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/src/util/project/buildComponentDependencyList.ts b/src/util/project/buildComponentDependencyList.ts index 6e48d31..eeed2c2 100644 --- a/src/util/project/buildComponentDependencyList.ts +++ b/src/util/project/buildComponentDependencyList.ts @@ -3,23 +3,60 @@ import type { Components } from '@emulsify-cli/config'; export default function buildComponentDependencyList( components: Components, name: string, -) { - const rootComponent = components.filter( - (component) => component.name == name, - ); - if (rootComponent.length == 0) return []; - let finalList = [name]; - const list = rootComponent[0].dependency as string[]; - if (list && list.length > 0) { - list.forEach((componentName: string) => { - finalList = [ - ...new Set( - finalList.concat( - buildComponentDependencyList(components, componentName), - ), - ), - ]; - }); +): string[] { + const componentsByName = new Map(); + for (const component of components) { + if (!componentsByName.has(component.name)) { + componentsByName.set(component.name, component); + } } + + if (!componentsByName.has(name)) return []; + + const finalList: string[] = []; + const visited = new Set(); + const stack: string[] = []; + + function visit(componentName: string, referencedBy?: string): void { + const cycleStart = stack.indexOf(componentName); + if (cycleStart !== -1) { + const cyclePath = [...stack.slice(cycleStart), componentName].join( + ' -> ', + ); + + throw new Error( + `Circular component dependency detected while resolving "${name}": ${cyclePath}.`, + ); + } + + if (visited.has(componentName)) { + return; + } + + const component = componentsByName.get(componentName); + if (!component) { + const dependencyPath = [...stack, componentName].join(' -> '); + const referencedByMessage = referencedBy + ? ` referenced by "${referencedBy}"` + : ''; + + throw new Error( + `Cannot resolve component dependency "${componentName}"${referencedByMessage} while resolving "${name}". Dependency path: ${dependencyPath}.`, + ); + } + + stack.push(componentName); + visited.add(componentName); + finalList.push(componentName); + + for (const dependencyName of component.dependency ?? []) { + visit(dependencyName, componentName); + } + + stack.pop(); + } + + visit(name); + return finalList; } From 9f45d7e489eeb608ac5b195ee445f639435c1a03 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:16:30 -0500 Subject: [PATCH 16/26] test: cover component dependency graph errors --- .../buildComponentDependencyList.test.ts | 89 +++++++++++++------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/src/util/project/buildComponentDependencyList.test.ts b/src/util/project/buildComponentDependencyList.test.ts index d6790f9..ede48fb 100644 --- a/src/util/project/buildComponentDependencyList.test.ts +++ b/src/util/project/buildComponentDependencyList.test.ts @@ -4,63 +4,98 @@ import buildComponentDependencyList from './buildComponentDependencyList.js'; describe('buildComponentDependencyList', () => { const components = [ { - name: 'buttons', + name: 'button', structure: 'base', - dependency: [], + dependency: ['icon'], }, { - name: 'images', + name: 'icon', structure: 'base', - dependency: [], }, { - name: 'links', + name: 'card', structure: 'base', - dependency: [], + dependency: ['teaser'], }, { - name: 'text', + name: 'teaser', structure: 'base', - dependency: ['links'], + dependency: ['image'], }, { - name: 'card', + name: 'image', structure: 'base', - dependency: ['images', 'text', 'links', 'buttons'], }, { - name: 'menus', + name: 'gallery', structure: 'molecules', - dependency: ['images', 'text'], + dependency: ['teaser', 'image'], }, ] as Components; - it('Build list of components without dependency', () => { - expect(buildComponentDependencyList(components, 'buttons')).toEqual([ - 'buttons', + it('returns the root component followed by direct dependencies', () => { + expect(buildComponentDependencyList(components, 'button')).toEqual([ + 'button', + 'icon', ]); }); - it('Build all components dependency for not existing component', () => { + it('returns an empty dependency list for a missing root component', () => { expect(buildComponentDependencyList(components, 'test')).toEqual([]); }); - it('Build all components dependency tree returning flat list without duplicates', () => { + it('returns nested dependencies in deterministic preorder', () => { expect(buildComponentDependencyList(components, 'card')).toEqual([ 'card', - 'images', - 'text', - 'links', - 'buttons', + 'teaser', + 'image', ]); }); - it('Build all components dependency tree with hierarchical dependency', () => { - expect(buildComponentDependencyList(components, 'menus')).toEqual([ - 'menus', - 'images', - 'text', - 'links', + it('returns duplicate nested dependencies only once', () => { + expect(buildComponentDependencyList(components, 'gallery')).toEqual([ + 'gallery', + 'teaser', + 'image', ]); }); + + it('throws a clear error when a dependency is missing', () => { + expect(() => + buildComponentDependencyList( + [ + { + name: 'button', + structure: 'base', + dependency: ['missing'], + }, + ] as Components, + 'button', + ), + ).toThrow( + 'Cannot resolve component dependency "missing" referenced by "button" while resolving "button". Dependency path: button -> missing.', + ); + }); + + it('throws a clear error when dependencies are circular', () => { + expect(() => + buildComponentDependencyList( + [ + { + name: 'a', + structure: 'base', + dependency: ['b'], + }, + { + name: 'b', + structure: 'base', + dependency: ['a'], + }, + ] as Components, + 'a', + ), + ).toThrow( + 'Circular component dependency detected while resolving "a": a -> b -> a.', + ); + }); }); From 21d681eb1529d533351e1c5ef925623f41a05569 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:22:10 -0500 Subject: [PATCH 17/26] fix: report malformed json config files --- src/util/fs/loadJsonFile.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/util/fs/loadJsonFile.ts b/src/util/fs/loadJsonFile.ts index 04ff345..8d548b5 100644 --- a/src/util/fs/loadJsonFile.ts +++ b/src/util/fs/loadJsonFile.ts @@ -11,12 +11,25 @@ export default async function loadJsonFile( path: string, ): Promise { try { - return JSON.parse( - await fs.readFile(path, { - encoding: 'utf-8', - }), - ) as Output; - } catch { - return undefined; + const json = await fs.readFile(path, { + encoding: 'utf-8', + }); + + return JSON.parse(json) as Output; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in "${path}": ${error.message}`); + } + + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return undefined; + } + + throw error; } } From c646be89dd885c7692aba7e79caecffe3e4e9484 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:22:50 -0500 Subject: [PATCH 18/26] test: cover malformed config loading --- src/util/cache/getJsonFromCachedFile.test.ts | 27 +++++++++++++++++++ src/util/fs/loadJsonFile.test.ts | 28 ++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/util/cache/getJsonFromCachedFile.test.ts b/src/util/cache/getJsonFromCachedFile.test.ts index d1a9002..d878c4c 100644 --- a/src/util/cache/getJsonFromCachedFile.test.ts +++ b/src/util/cache/getJsonFromCachedFile.test.ts @@ -17,6 +17,13 @@ const loadJsonMock = (loadJsonFile as jest.Mock).mockResolvedValue({ }); describe('getJsonFromCachedFile', () => { + beforeEach(() => { + jest.clearAllMocks(); + loadJsonMock.mockResolvedValue({ + the: 'json', + }); + }); + it('can load and parse the JSON from a file stored in cache', async () => { expect.assertions(2); await expect( @@ -46,4 +53,24 @@ describe('getJsonFromCachedFile', () => { ), ).resolves.toBe(undefined); }); + + it('throws malformed cached JSON errors with the cached file path', async () => { + expect.assertions(1); + loadJsonMock.mockRejectedValueOnce( + new Error( + 'Invalid JSON in "/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json": Expected property name or \'}\' in JSON', + ), + ); + + await expect( + getJsonFromCachedFile( + 'systems', + ['compound'], + 'branch-name', + 'system.emulsify.json', + ), + ).rejects.toThrow( + 'Invalid JSON in "/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json"', + ); + }); }); diff --git a/src/util/fs/loadJsonFile.test.ts b/src/util/fs/loadJsonFile.test.ts index 0a5c60c..a0fd622 100644 --- a/src/util/fs/loadJsonFile.test.ts +++ b/src/util/fs/loadJsonFile.test.ts @@ -4,6 +4,10 @@ import loadJsonFile from './loadJsonFile.js'; const readFileMock = fs.readFile as jest.Mock; describe('loadJsonFile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('can read and parse json from a file located within the users current path', async () => { expect.assertions(2); readFileMock.mockResolvedValueOnce('{ "field": "value" }'); @@ -17,6 +21,30 @@ describe('loadJsonFile', () => { it('returns void if the file is not found', async () => { expect.assertions(1); + readFileMock.mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'ENOENT' }), + ); + await expect(loadJsonFile('path.json')).resolves.toBe(undefined); }); + + it('throws a clear error if an existing file contains malformed json', async () => { + expect.assertions(1); + readFileMock.mockResolvedValueOnce('{ "field": '); + + await expect(loadJsonFile('path.json')).rejects.toThrow( + 'Invalid JSON in "path.json":', + ); + }); + + it('throws non-missing read errors', async () => { + expect.assertions(1); + readFileMock.mockRejectedValueOnce( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + + await expect(loadJsonFile('path.json')).rejects.toThrow( + 'permission denied', + ); + }); }); From cbb00bb0f350d3f86eeb39fff512ef65ba946536 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:23:29 -0500 Subject: [PATCH 19/26] feat: validate project config schema --- src/util/project/getEmulsifyConfig.ts | 50 ++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/util/project/getEmulsifyConfig.ts b/src/util/project/getEmulsifyConfig.ts index 70acfdb..a5b6def 100644 --- a/src/util/project/getEmulsifyConfig.ts +++ b/src/util/project/getEmulsifyConfig.ts @@ -1,8 +1,51 @@ import type { EmulsifyProjectConfiguration } from '@emulsify-cli/config'; +import { Ajv, type ErrorObject, type ValidateFunction } from 'ajv'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import loadJsonFile from '../fs/loadJsonFile.js'; +let validateProjectConfig: ValidateFunction | undefined; + +async function getProjectConfigValidator(): Promise { + if (!validateProjectConfig) { + const [{ default: projectConfigSchema }, { default: variantSchema }] = + await Promise.all([ + import('../../schemas/emulsifyProjectConfig.json', { + with: { type: 'json' }, + }), + import('../../schemas/variant.json', { with: { type: 'json' } }), + ]); + const ajv = new Ajv({ allErrors: true }); + ajv.addSchema(variantSchema, 'variant.json'); + validateProjectConfig = ajv.compile(projectConfigSchema); + } + + return validateProjectConfig; +} + +function formatProjectConfigError(error: ErrorObject): string { + const location = error.instancePath || '/'; + return `${location} ${error.message}`; +} + +async function validateEmulsifyConfig( + config: unknown, + path: string, +): Promise { + const validate = await getProjectConfigValidator(); + if (!validate(config)) { + const errors = (validate.errors || []) + .map(formatProjectConfigError) + .join('; '); + + throw new Error( + `Invalid Emulsify project configuration in "${path}": ${errors}`, + ); + } + + return config as EmulsifyProjectConfiguration; +} + /** * Finds the Emulsify project configuration, loads, and returns it. * @@ -15,5 +58,10 @@ export default async function getEmulsifyConfig(): Promise(path); + const config = await loadJsonFile(path); + if (config === undefined) { + return undefined; + } + + return validateEmulsifyConfig(config, path); } From cfaeb7f4774d8515fb5f76800c991182335b69c0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:24:15 -0500 Subject: [PATCH 20/26] test: cover project config schema validation --- src/util/project/getEmulsifyConfig.test.ts | 57 ++++++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/src/util/project/getEmulsifyConfig.test.ts b/src/util/project/getEmulsifyConfig.test.ts index b746a13..1960611 100644 --- a/src/util/project/getEmulsifyConfig.test.ts +++ b/src/util/project/getEmulsifyConfig.test.ts @@ -8,15 +8,27 @@ import getEmulsifyConfig from './getEmulsifyConfig.js'; const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( '/projects/project.emulsify.json', ); -(loadJsonFile as jest.Mock).mockResolvedValue({ - emulsify: 'config', -}); +const loadJsonFileMock = loadJsonFile as jest.Mock; +const projectConfig = { + project: { + platform: 'drupal', + name: 'Cornflake', + machineName: 'cornflake', + }, + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter', + }, +}; describe('getEmulsifyConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + findFileMock.mockReturnValue('/projects/project.emulsify.json'); + loadJsonFileMock.mockResolvedValue(projectConfig); + }); + it('can load the Emulsify configuration for the project within the users cwd', async () => { - await expect(getEmulsifyConfig()).resolves.toEqual({ - emulsify: 'config', - }); + await expect(getEmulsifyConfig()).resolves.toEqual(projectConfig); }); it('returns void if no Emulsify config file is found within the users cwd', async () => { @@ -32,14 +44,39 @@ describe('getEmulsifyConfig', () => { }); it('handles errors thrown by loadJsonFile', async () => { - (loadJsonFile as jest.Mock).mockImplementationOnce(() => { + loadJsonFileMock.mockImplementationOnce(() => { throw new Error('loadJsonFile error'); }); await expect(getEmulsifyConfig()).rejects.toThrow('loadJsonFile error'); }); - it('handles invalid JSON structure', async () => { - (loadJsonFile as jest.Mock).mockResolvedValueOnce({}); - await expect(getEmulsifyConfig()).resolves.toEqual({}); + it('reports schema-invalid config missing required project settings', async () => { + loadJsonFileMock.mockResolvedValueOnce({ + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter', + }, + }); + + await expect(getEmulsifyConfig()).rejects.toThrow( + 'Invalid Emulsify project configuration in "/projects/project.emulsify.json": / must have required property \'project\'', + ); + }); + + it('reports schema-invalid variant platform values', async () => { + loadJsonFileMock.mockResolvedValueOnce({ + ...projectConfig, + system: { + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: 'main', + }, + variant: { + platform: 'wordpress', + structureImplementations: [], + }, + }); + + await expect(getEmulsifyConfig()).rejects.toThrow( + 'Invalid Emulsify project configuration in "/projects/project.emulsify.json": /variant/platform must be equal to one of the allowed values', + ); }); }); From 98cf00d1dc89236e8a4972df2cdb38df9368093e Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:34:32 -0500 Subject: [PATCH 21/26] feat: add component create dry run --- src/index.ts | 4 + src/types/handlers.d.ts | 2 + src/util/project/generateComponent.test.ts | 90 ++++++++++++++++++ src/util/project/generateComponent.ts | 103 ++++++++++++++------- 4 files changed, 165 insertions(+), 34 deletions(-) diff --git a/src/index.ts b/src/index.ts index f53fc70..69114d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,6 +124,10 @@ component '-y --yes', 'Skip overwrite confirmation prompts and replace existing components.', ) + .option( + '--dry-run', + 'Preview generated component files without writing or removing files.', + ) .alias('c') .description( "Create a component from within the current project's system and variant", diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index e1e5fdd..3c7226f 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -34,5 +34,7 @@ declare module '@emulsify-cli/handlers' { format?: string; /** Skip overwrite confirmation prompts and replace existing components. */ yes?: boolean; + /** Preview planned component operations without writing, copying, or removing files. */ + dryRun?: boolean; }; } diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index 0fe59aa..8fb6de7 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -46,6 +46,7 @@ const pathExistsMock = (pathExists as jest.Mock).mockResolvedValue(true); const removeMock = remove as jest.Mock; const readFileMock = fs.readFile as jest.Mock; const writeFileMock = fs.writeFile as jest.Mock; +const mkdirMock = fs.mkdir as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; function setStdinIsTTY(value: boolean | undefined) { @@ -175,6 +176,95 @@ describe('generateComponent', () => { ); }); + it('previews a default component without writing files in dry-run mode', async () => { + expect.assertions(6); + setStdinIsTTY(false); + pathExistsMock.mockImplementation((path) => { + const value = String(path); + return ( + !isTemplatePath(value) && !value.endsWith('/components/00-base/card') + ); + }); + + await generateComponent(variant, 'card', { + directory: 'base', + format: 'default', + dryRun: true, + }); + + expect(confirm).not.toHaveBeenCalled(); + expect(removeMock).not.toHaveBeenCalled(); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Dry run: component create "card"'), + ); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining( + '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/card/card.stories.js', + ), + ); + }); + + it('previews an SDC component without writing files in dry-run mode', async () => { + expect.assertions(5); + setStdinIsTTY(false); + pathExistsMock.mockImplementation((path) => { + const value = String(path); + return ( + !isTemplatePath(value) && !value.endsWith('/components/00-base/teaser') + ); + }); + + await generateComponent(variant, 'teaser', { + directory: 'base', + format: 'sdc', + dryRun: true, + }); + + expect(removeMock).not.toHaveBeenCalled(); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Format: sdc'), + ); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining( + '/home/uname/Projects/cornflake/web/themes/custom/themename/components/00-base/teaser/teaser.component.yml', + ), + ); + }); + + it('previews an existing destination without prompting or removing in dry-run mode', async () => { + expect.assertions(5); + setStdinIsTTY(false); + pathExistsMock.mockImplementation((path) => !isTemplatePath(path)); + + await generateComponent(variant, 'link', { + directory: 'base', + format: 'default', + dryRun: true, + }); + + expect(confirm).not.toHaveBeenCalled(); + expect(removeMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Destination exists: yes'), + ); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining( + 'Real run would: prompt before replacing the existing component directory', + ), + ); + }); + it('throws a clear error when a provided format is invalid', async () => { expect.assertions(4); setStdinIsTTY(false); diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index d49e830..768a6f0 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -73,6 +73,7 @@ function getComponentFormat(format: string): ComponentFormat { * @param options.directory string name of the directory where the component should be created. * @param options.format component format to generate. Supported values are "default" and "sdc". * @param options.yes whether to skip overwrite confirmation prompts and replace existing components. + * @param options.dryRun whether to preview generated files without changing the project. * @returns * @throws {Error} if the component name is invalid, the current path is not within an Emulsify project, the requested structure is invalid, or required non-interactive options are missing. */ @@ -149,10 +150,7 @@ export default async function generateComponent( 'Component structure directory', { allowRoot: true }, ); - if (!(await pathExists(parentPath))) { - // Create the component's parent directory. - await fs.mkdir(parentPath, { recursive: true }); - } + const parentExists = await pathExists(parentPath); // Calculate the destination path (always kebab-case folder name). const destination = safeResolveWithin( @@ -164,29 +162,6 @@ export default async function generateComponent( // If the component already exists within the project, // ask the user if they want to replace it. const componentExists = await pathExists(destination); - if (componentExists) { - const shouldReplace = - options.yes || - (canPrompt - ? await confirm({ - message: yellow( - `The component "${humanName}" already exists in ${structure.directory}. Would you like to replace it?`, - ), - default: false, - }) - : false); - - if (!shouldReplace) { - return log('info', `Component creation canceled.`); - } - - // Remove the existing component directory to ensure a clean start. - await remove(destination); - } - - // Create the component directory - await fs.mkdir(destination, { recursive: true }); - const templateVars: ComponentTemplateVars = { filename, className, @@ -250,7 +225,72 @@ export default async function generateComponent( }, ]; - for (const artifact of [...sharedArtifacts, ...formatArtifacts]) { + const artifacts = [...sharedArtifacts, ...formatArtifacts]; + const artifactDestinations = artifacts.map((artifact) => + safeResolveWithin( + projectRoot, + [structure.directory, filename, artifact.destinationName], + 'Component file destination', + ), + ); + + if (options.dryRun) { + const realRunAction = componentExists + ? options.yes + ? 'replace the existing component directory' + : 'prompt before replacing the existing component directory' + : 'create the component directory'; + const generatedFiles = artifactDestinations + .map((filePath) => ` - ${filePath}`) + .join('\n'); + + return log( + 'info', + [ + `Dry run: component create "${filename}"`, + `Format: ${format}`, + `Directory: ${directory}`, + `Structure path: ${structure.directory}`, + `Parent directory: ${parentPath} (${parentExists ? 'exists' : 'would be created'})`, + `Destination: ${destination}`, + `Destination exists: ${componentExists ? 'yes' : 'no'}`, + `Real run would: ${realRunAction}`, + 'Generated files:', + generatedFiles, + 'No files were written, removed, or created.', + ].join('\n'), + ); + } + + if (!parentExists) { + // Create the component's parent directory. + await fs.mkdir(parentPath, { recursive: true }); + } + + if (componentExists) { + const shouldReplace = + options.yes || + (canPrompt + ? await confirm({ + message: yellow( + `The component "${humanName}" already exists in ${structure.directory}. Would you like to replace it?`, + ), + default: false, + }) + : false); + + if (!shouldReplace) { + return log('info', `Component creation canceled.`); + } + + // Remove the existing component directory to ensure a clean start. + await remove(destination); + } + + // Create the component directory + await fs.mkdir(destination, { recursive: true }); + + for (const [index, artifact] of artifacts.entries()) { // Resolve a project override first; missing or empty overrides fall back to // the byte-for-byte built-in builders for each known generated artifact. const templateFile = @@ -260,12 +300,7 @@ export default async function generateComponent( artifact.logicalName, templateVars, )) ?? artifact.build(); - - const artifactDestination = safeResolveWithin( - projectRoot, - [structure.directory, filename, artifact.destinationName], - 'Component file destination', - ); + const artifactDestination = artifactDestinations[index]; await fs.writeFile(artifactDestination, templateFile); } From ddcbab697f490427f4ef4d500d463fea7227110a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:36:35 -0500 Subject: [PATCH 22/26] feat: add component install dry run --- src/handlers/componentInstall.test.ts | 57 ++++++++++ src/handlers/componentInstall.ts | 147 +++++++++++++++++++++++--- src/index.ts | 4 + src/types/handlers.d.ts | 1 + 4 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index 4b3fbac..dfc6ba4 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -278,6 +278,63 @@ describe('componentInstall', () => { ); }); + it('previews a single component install without copying in dry-run mode', async () => { + await componentInstall('card', { dryRun: true }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(copyItemFromCacheMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Dry run: component install "card"'), + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining('/project/components/00-base/card'), + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Real run would: copy component'), + ); + }); + + it('previews dependency installs without copying in dry-run mode', async () => { + await componentInstall('button', { dryRun: true }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(copyItemFromCacheMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Dependencies:\n - icon'), + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining(' - icon (dependency of "button")'), + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining('/project/components/00-base/icon'), + ); + }); + + it('previews existing component destinations without prompting in dry-run mode', async () => { + pathExistsMock.mockResolvedValue(true); + + await componentInstall('card', { dryRun: true }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(copyItemFromCacheMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining('Destination exists: yes'), + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining( + 'Real run would: prompt before replacing or skipping', + ), + ); + }); + it('installs a component when no project config path is found for destination checks', async () => { findFileInCurrentPathMock.mockReturnValueOnce(undefined); diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index 4da3b88..b39244d 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -4,6 +4,7 @@ import log from '../lib/log.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js'; import CliError from '../lib/CliError.js'; import type { InstallComponentHandlerOptions } from '@emulsify-cli/handlers'; +import type { EmulsifyVariant } from '@emulsify-cli/config'; import installComponentFromCache, { getComponentDestination, } from '../util/project/installComponentFromCache.js'; @@ -12,6 +13,95 @@ import catchLater from '../util/catchLater.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; +type ComponentInstallPlanItem = { + name: string; + isDependency: boolean; + destination: string; + exists: boolean; + action: string; +}; + +function getDryRunInstallAction(exists: boolean, force: boolean): string { + if (!exists) { + return 'copy component'; + } + + if (force) { + return 'replace existing destination'; + } + + return 'prompt before replacing or skipping'; +} + +async function buildComponentInstallPlan( + variant: EmulsifyVariant, + componentNames: string[], + rootComponentName: string | undefined, + force: boolean, +): Promise { + const projectConfigPath = findFileInCurrentPath(EMULSIFY_PROJECT_CONFIG_FILE); + if (!projectConfigPath) { + throw new CliError( + 'Unable to find an Emulsify project to preview component installation into.', + ); + } + + const plan: ComponentInstallPlanItem[] = []; + for (const componentName of componentNames) { + const destination = getComponentDestination( + variant, + componentName, + projectConfigPath, + ); + const exists = await pathExists(destination); + + plan.push({ + name: componentName, + isDependency: Boolean( + rootComponentName && componentName !== rootComponentName, + ), + destination, + exists, + action: getDryRunInstallAction(exists, force), + }); + } + + return plan; +} + +function logComponentInstallDryRun( + targetLabel: string, + dependencies: string[], + plan: ComponentInstallPlanItem[], +): void { + const dependencyList = + dependencies.length > 0 + ? dependencies.map((dependency) => ` - ${dependency}`).join('\n') + : ' - none'; + const plannedInstalls = plan + .map((item) => + [ + ` - ${item.name}${item.isDependency ? ` (dependency of "${targetLabel}")` : ''}`, + ` Destination: ${item.destination}`, + ` Destination exists: ${item.exists ? 'yes' : 'no'}`, + ` Real run would: ${item.action}`, + ].join('\n'), + ) + .join('\n'); + + log( + 'info', + [ + `Dry run: component install "${targetLabel}"`, + 'Dependencies:', + dependencyList, + 'Planned component installs:', + plannedInstalls, + 'No files were copied, removed, or overwritten.', + ].join('\n'), + ); +} + /** * Handler for the `component install` command. * @@ -21,7 +111,7 @@ import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; */ export default async function componentInstall( name: string, - { force, all }: InstallComponentHandlerOptions, + { force, all, dryRun }: InstallComponentHandlerOptions, ): Promise { if (!name && !all) { throw new CliError( @@ -36,22 +126,34 @@ export default async function componentInstall( // If all components are to be installed, spawn promises for installing all available components. const components: [string, boolean, Promise][] = []; if (all) { + const componentNames = variantConf.components.map( + (component) => component.name, + ); + if (dryRun) { + const plan = await buildComponentInstallPlan( + variantConf, + componentNames, + undefined, + true, + ); + logComponentInstallDryRun('all components', [], plan); + return; + } + components.push( - ...variantConf.components.map( - (component): [string, boolean, Promise] => [ - component.name, - false, - catchLater( - installComponentFromCache( - systemConf, - variantConf, - component.name, - // Force install all components. - true, - ), + ...componentNames.map((component): [string, boolean, Promise] => [ + component, + false, + catchLater( + installComponentFromCache( + systemConf, + variantConf, + component, + // Force install all components. + true, ), - ], - ), + ), + ]), ); } // If there is only one component to install, add one single promise for the single component. @@ -65,6 +167,21 @@ export default async function componentInstall( `Cannot find the definition for component "${name}".\n\nRun "emulsify component list" to see the full list.`, ); } + + if (dryRun) { + const dependencies = componentsWithDependencies.filter( + (componentName) => componentName !== name, + ); + const plan = await buildComponentInstallPlan( + variantConf, + componentsWithDependencies, + name, + Boolean(force), + ); + logComponentInstallDryRun(name, dependencies, plan); + return; + } + const projectConfigPath = findFileInCurrentPath( EMULSIFY_PROJECT_CONFIG_FILE, ); diff --git a/src/index.ts b/src/index.ts index 69114d0..0a2c3c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -108,6 +108,10 @@ component '-a --all', 'Use this to install all available components, rather than specifying a single component to install', ) + .option( + '--dry-run', + 'Preview component installs without copying or removing files.', + ) .alias('i') .action(componentInstall); component diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 3c7226f..b905eb1 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -25,6 +25,7 @@ declare module '@emulsify-cli/handlers' { export type InstallComponentHandlerOptions = { force?: boolean; all?: boolean; + dryRun?: boolean; }; export type CreateComponentHandlerOptions = { From 4667b9abb4a8a698d21d032015ede0c5b17f3ee0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:37:11 -0500 Subject: [PATCH 23/26] docs: document component dry run usage --- README.md | 5 +++++ docs/emulsify-info-cli-updates.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/README.md b/README.md index a1299d7..c0c6ce0 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,13 @@ Options: - `--force`: Replaces an installed component. - `--all`: Installs all available components instead of one named component. +- `--dry-run`: Previews planned component installs, dependencies, destinations, and overwrite behavior without copying or removing files. Examples: ```bash emulsify component install button +emulsify component install card --dry-run emulsify component i card --force emulsify component install --all ``` @@ -111,6 +113,7 @@ Options: - `--directory `: Sets the variant structure directory where the component is created. - `--format `: Sets the component format. Supported values are `default` and `sdc`. - `--yes`: Replaces an existing component without an overwrite confirmation prompt. +- `--dry-run`: Previews the destination and generated files without writing, removing, or creating files. In non-interactive environments, pass both `--directory` and `--format`. @@ -118,7 +121,9 @@ Examples: ```bash emulsify component create card --directory base --format default +emulsify component create card --directory base --format default --dry-run emulsify component create teaser --directory molecules --format sdc --yes +emulsify component create teaser --directory molecules --format sdc --dry-run ``` ### Component Template Overrides diff --git a/docs/emulsify-info-cli-updates.md b/docs/emulsify-info-cli-updates.md index 014127b..3a1e96c 100644 --- a/docs/emulsify-info-cli-updates.md +++ b/docs/emulsify-info-cli-updates.md @@ -88,11 +88,13 @@ Options: - `--force`: Replaces an installed component. - `--all`: Installs all available components instead of one named component. +- `--dry-run`: Previews planned component installs, dependencies, destinations, and overwrite behavior without copying or removing files. Examples: ```bash emulsify component install button +emulsify component install card --dry-run emulsify component i card --force emulsify component install --all ``` @@ -104,6 +106,7 @@ Options: - `--directory `: Sets the variant structure directory where the component is created. - `--format `: Sets the component format. Supported values are `default` and `sdc`. - `--yes`: Replaces an existing component without an overwrite confirmation prompt. +- `--dry-run`: Previews the destination and generated files without writing, removing, or creating files. In non-interactive environments, pass both `--directory` and `--format`. @@ -111,7 +114,9 @@ Examples: ```bash emulsify component create card --directory base --format default +emulsify component create card --directory base --format default --dry-run emulsify component create teaser --directory molecules --format sdc --yes +emulsify component create teaser --directory molecules --format sdc --dry-run ``` ## Component Template Overrides From 85e34e4d92e5761c0b178342560390e48c1325da Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:43:05 -0500 Subject: [PATCH 24/26] chore: move type-only dependencies to dev dependencies --- package-lock.json | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index bfccc1d..c6c55ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "GPL-2.0", "dependencies": { "@inquirer/prompts": "^8.5.2", - "@types/progress": "^2.0.7", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", @@ -35,6 +34,7 @@ "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", "@types/node": "^25.9.1", + "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.4.2", "json-schema-to-typescript": "^15.0.4", @@ -2843,6 +2843,7 @@ "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -2859,6 +2860,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/progress/-/progress-2.0.7.tgz", "integrity": "sha512-iadjw02vte8qWx7U0YM++EybBha2CQLPGu9iJ97whVgJUT5Zq9MjAPYUnbfRI2Kpehimf1QjFJYxD0t8nqzu5w==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -12020,6 +12022,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index 9263086..9087727 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", "@types/node": "^25.9.1", + "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.4.2", "json-schema-to-typescript": "^15.0.4", @@ -62,7 +63,6 @@ }, "dependencies": { "@inquirer/prompts": "^8.5.2", - "@types/progress": "^2.0.7", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "boxen": "^8.0.1", From 2a7aa9757daa8e1767eb6c597f56316755cd6f97 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:43:28 -0500 Subject: [PATCH 25/26] chore: remove unused dependencies --- package-lock.json | 20 ++++++++++++++++++-- package.json | 1 - 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6c55ad..234da62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "colorette": "^2.0.20", "commander": "^15.0.0", "consola": "^3.4.2", - "cosmiconfig": "^9.0.1", "fs-extra": "^11.3.5", "progress": "^2.0.3", "simple-git": "^3.36.0" @@ -111,6 +110,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -282,6 +282,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3463,6 +3464,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/argv-formatter": { @@ -3777,6 +3779,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4433,6 +4436,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -4834,6 +4838,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4856,6 +4861,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -5517,6 +5523,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -5533,6 +5540,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -5652,6 +5660,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, "license": "MIT" }, "node_modules/is-binary-path": { @@ -7043,12 +7052,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7081,6 +7092,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-to-typescript": { @@ -7159,6 +7171,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/lint-staged": { @@ -10024,6 +10037,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -10036,6 +10050,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -10155,6 +10170,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -11977,7 +11993,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 9087727..a5157ce 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ "colorette": "^2.0.20", "commander": "^15.0.0", "consola": "^3.4.2", - "cosmiconfig": "^9.0.1", "fs-extra": "^11.3.5", "progress": "^2.0.3", "simple-git": "^3.36.0" From 1f8a0625cc31083677b2b83c659b4d9224be9dc6 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 16:43:55 -0500 Subject: [PATCH 26/26] chore: update safe patch dependencies --- package-lock.json | 24 ++++++++++++------------ package.json | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 234da62..0d62621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,15 +32,15 @@ "@semantic-release/release-notes-generator": "^14.1.1", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", - "@types/node": "^25.9.1", + "@types/node": "^25.9.3", "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.4.2", "json-schema-to-typescript": "^15.0.4", "lint-staged": "^17.0.7", "nodemon": "^3.1.14", - "prettier": "^3.8.3", - "semantic-release": "^25.0.3", + "prettier": "^3.8.4", + "semantic-release": "^25.0.5", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^6.0.3" @@ -2841,9 +2841,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10307,9 +10307,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -10785,9 +10785,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", - "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", + "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a5157ce..eccf4fd 100644 --- a/package.json +++ b/package.json @@ -48,15 +48,15 @@ "@semantic-release/release-notes-generator": "^14.1.1", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", - "@types/node": "^25.9.1", + "@types/node": "^25.9.3", "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.4.2", "json-schema-to-typescript": "^15.0.4", "lint-staged": "^17.0.7", "nodemon": "^3.1.14", - "prettier": "^3.8.3", - "semantic-release": "^25.0.3", + "prettier": "^3.8.4", + "semantic-release": "^25.0.5", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^6.0.3"