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 1/3] 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 2/3] 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 3/3] 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