diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 418a19c..fbb0726 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -26,6 +26,9 @@ The examples below reflect the command definitions in `src/index.ts` and the gen | `emulsify component eject-templates [type]` | | Write editable built-in component templates into the project. | | `emulsify cache clear` | | Clear locally cached system repositories. | +`emulsify components` is an alias for the canonical `emulsify component` +command group, and accepts the same component subcommands and options. + ## `init` ```bash diff --git a/docs/project-initialization.md b/docs/project-initialization.md index 3bedc1b..3b8a1ec 100644 --- a/docs/project-initialization.md +++ b/docs/project-initialization.md @@ -118,6 +118,12 @@ web/app/themes/my-theme If the target already exists, initialization stops with an error and does not overwrite the directory. +## Failed Initialization And Cleanup + +Init atomically creates the target directory before cloning, so it only treats a target created by that command run as owned. If the target already exists or another process creates it during preflight, initialization stops without removing it. If cloning, configuration, dependency installation, the starter hook, or Git metadata cleanup then fails, the error identifies the failed phase and the CLI removes the incomplete target recursively so the same init command can be retried. + +If automatic cleanup also fails, the error includes the target path and asks you to remove it manually before retrying. A target that existed before init started is never removed; the preflight occupied-target check stops before cloning begins. + ## Machine Names If `--machineName` is omitted, the CLI derives one from the project name by removing non-alphanumeric characters, replacing spaces, and lowercasing the result. @@ -171,6 +177,8 @@ If `--checkout` is omitted, the starter repository default branch is cloned. After init, the generated `project.emulsify.json` stores a concrete platform value. It never stores compatibility expressions such as `drupal || wordpress` in `project.platform`. +When a starter already contains `project.emulsify.json`, init preserves its project defaults and other top-level configuration. The requested platform, project name, machine name, and starter repository replace the starter's template identity. This lets starter-owned settings such as Drupal Single Directory Component output remain enabled. + For a Drupal init, the file looks like this: ```json @@ -178,7 +186,8 @@ For a Drupal init, the file looks like this: "project": { "platform": "drupal", "name": "My Theme", - "machineName": "my_theme" + "machineName": "my_theme", + "singleDirectoryComponents": true }, "starter": { "repository": "https://github.com/emulsify-ds/emulsify-drupal-starter" diff --git a/docs/systems.md b/docs/systems.md index bab5558..34e6cbf 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -97,7 +97,7 @@ For a built-in system, the command: 4. Clones the system into the local Emulsify cache. 5. Reads and validates `system.emulsify.json` from the cached system. 6. Selects the reviewed component set in guided mode, or resolves the best compatible variant for `project.platform` in direct mode. `--variant` selects an exact expression in direct mode. -7. Selects essential or all components. +7. Selects essential components and their declared dependencies, or all components. 8. Presents and confirms the review in guided mode. 9. Writes `system` and `variant` entries into `project.emulsify.json`. 10. Installs the selected components and variant-level general files and directories. @@ -111,7 +111,8 @@ emulsify system install emulsify-ui-kit An explicit built-in name bypasses the wizard. This is the form to use in a script or CI job. It selects the best compatible component set automatically and -installs only essential components unless flags override those choices. +installs only essential components and their declared dependencies unless flags +override those choices. Use `--all` to install every component in the selected variant during system installation: diff --git a/package-lock.json b/package-lock.json index 1166636..6db14c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@emulsify/cli", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.4.0", + "version": "2.4.1", "license": "GPL-2.0", "dependencies": { "@inquirer/prompts": "^8.7.0", diff --git a/package.json b/package.json index 73beaea..3edf643 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.4.0", + "version": "2.4.1", "description": "Build and use component systems in Drupal, WordPress, or standalone front ends.", "repository": "git@github.com:emulsify-ds/emulsify-cli.git", "author": "Patrick Coffey ", diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index a81487b..f15feb5 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -116,6 +116,20 @@ describe('componentCreate', () => { ); }); + it('checks for a configured system before prompting for a missing name', async () => { + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + system: undefined, + }); + + await expect(componentCreate(undefined)).rejects.toThrow( + 'You must select and install a system before you can create components.', + ); + + expect(inputMock).not.toHaveBeenCalled(); + expect(generateComponentMock).not.toHaveBeenCalled(); + }); + it('throws when no variant is configured', async () => { getEmulsifyConfigMock.mockResolvedValueOnce({ ...projectConfig, diff --git a/src/handlers/componentCreate.ts b/src/handlers/componentCreate.ts index 1bfed62..cd4f4ea 100644 --- a/src/handlers/componentCreate.ts +++ b/src/handlers/componentCreate.ts @@ -37,19 +37,13 @@ export default async function componentCreate( name: string | void, options: CreateComponentHandlerOptions = {}, ): Promise { - const componentName = name?.trim() - ? name - : await runPrompt({ - prompt: () => - input({ - message: 'Component name:', - validate: validateComponentName, - }), - nonInteractive: { error: MISSING_COMPONENT_NAME_ERROR }, - }); + const providedComponentName = name?.trim() ? name : undefined; // Missing prompt values can be rejected before loading or refreshing the // configured system, keeping CI failures fast and offline. + if (!providedComponentName) { + requireInteractiveTerminal(MISSING_COMPONENT_NAME_ERROR); + } if (!options.type && !options.format) { requireInteractiveTerminal(MISSING_COMPONENT_TYPE_ERROR); } @@ -65,6 +59,17 @@ export default async function componentCreate( }, ); + const componentName = + providedComponentName ?? + (await runPrompt({ + prompt: () => + input({ + message: 'Component name:', + validate: validateComponentName, + }), + nonInteractive: { error: MISSING_COMPONENT_NAME_ERROR }, + })); + try { await generateComponent( variantConf, diff --git a/src/handlers/init.test.ts b/src/handlers/init.test.ts index 6b9ba95..4279b2f 100644 --- a/src/handlers/init.test.ts +++ b/src/handlers/init.test.ts @@ -1,5 +1,6 @@ jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/platform/getPlatformInfo', () => jest.fn()); +jest.mock('../util/fs/loadJsonFile', () => jest.fn()); jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('../util/fs/executeScript', () => jest.fn()); jest.mock('../util/project/installDependencies', () => jest.fn()); @@ -13,6 +14,7 @@ import { input, select } from '@inquirer/prompts'; import ProgressBar from 'progress'; import installDependencies from '../util/project/installDependencies.js'; import getPlatformInfo from '../util/platform/getPlatformInfo.js'; +import loadJsonFile from '../util/fs/loadJsonFile.js'; import writeToJsonFile from '../util/fs/writeToJsonFile.js'; import executeScript from '../util/fs/executeScript.js'; import { @@ -37,6 +39,7 @@ const wordpressThemesDirectory = join(root, 'wp-content', 'themes'); const wordpressTarget = join(wordpressThemesDirectory, 'my-theme'); const existsSyncMock = (fs.existsSync as jest.Mock).mockReturnValue(false); +const mkdirMock = (fs.promises.mkdir as jest.Mock).mockResolvedValue(undefined); const rmMock = (fs.promises.rm as jest.Mock).mockReturnValue(true); const gitCloneMock = git().clone as jest.Mock; const getPlatformInfoMock = (getPlatformInfo as jest.Mock).mockReturnValue({ @@ -46,7 +49,12 @@ const getPlatformInfoMock = (getPlatformInfo as jest.Mock).mockReturnValue({ platformMajorVersion: 1, }); const logMock = log as jest.Mock; +const loadJsonFileMock = (loadJsonFile as jest.Mock).mockResolvedValue( + undefined, +); const writeJsonFileMock = writeToJsonFile as jest.Mock; +const installDependenciesMock = installDependencies as jest.Mock; +const executeScriptMock = executeScript as jest.Mock; const progressMock = { tick: jest.fn(), }; @@ -70,7 +78,13 @@ describe('init', () => { beforeEach(() => { logMock.mockClear(); gitCloneMock.mockClear(); + existsSyncMock.mockClear(); + mkdirMock.mockClear(); + rmMock.mockClear(); + loadJsonFileMock.mockClear(); writeJsonFileMock.mockClear(); + installDependenciesMock.mockClear(); + executeScriptMock.mockClear(); progressMock.tick.mockClear(); inputMock.mockClear(); selectMock.mockClear(); @@ -122,6 +136,44 @@ describe('init', () => { }); }); + it('preserves starter configuration defaults while overriding generated project identity', async () => { + const starterConfig = { + project: { + platform: 'drupal' as const, + name: 'Starter Theme', + machineName: 'starter_theme', + singleDirectoryComponents: true, + description: 'Starter-provided description', + }, + starter: { + repository: 'https://github.com/example/original-starter.git', + }, + assets: { + roots: ['./static'], + rebase: true, + selfContainedOutput: true, + }, + }; + loadJsonFileMock.mockResolvedValueOnce(starterConfig); + + await init(progress)('cornflake'); + + expect(loadJsonFileMock).toHaveBeenCalledWith(defaultConfigPath); + expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { + ...starterConfig, + project: { + ...starterConfig.project, + platform: 'none', + name: 'cornflake', + machineName: 'cornflake', + }, + starter: { + ...starterConfig.starter, + repository: 'https://github.com/emulsify-ds/emulsify-starter', + }, + }); + }); + it('logs Drupal Composer guidance when Drupal is auto-detected', async () => { expect.assertions(4); getPlatformInfoMock.mockReturnValueOnce({ @@ -398,13 +450,116 @@ describe('init', () => { ); }); - it('throws a helpful error if the given Emulsify starter is not clone-able', async () => { - gitCloneMock.mockImplementationOnce(() => { - throw new Error('Does not exist!'); + it('reports the clone phase and removes a partial target when cloning fails', async () => { + gitCloneMock.mockRejectedValueOnce(new Error('Does not exist!')); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `Unable to initialize project while cloning the starter: Error: Does not exist!. Removed the incomplete target "${defaultTarget}".`, + ); + expect(rmMock).toHaveBeenCalledWith(defaultTarget, { + recursive: true, + force: true, + }); + }); + + it('reports the configuration-read phase and rolls back invalid starter configuration', async () => { + loadJsonFileMock.mockRejectedValueOnce( + new Error('Invalid JSON in project.emulsify.json'), + ); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `Unable to initialize project while reading the starter project configuration: Error: Invalid JSON in project.emulsify.json. Removed the incomplete target "${defaultTarget}".`, + ); + expect(rmMock).toHaveBeenCalledWith(defaultTarget, { + recursive: true, + force: true, + }); + }); + + it('reports the configuration-write phase and removes the incomplete target', async () => { + writeJsonFileMock.mockRejectedValueOnce(new Error('config write failed')); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `Unable to initialize project while writing the project configuration: Error: config write failed. Removed the incomplete target "${defaultTarget}".`, + ); + expect(rmMock).toHaveBeenCalledWith(defaultTarget, { + recursive: true, + force: true, + }); + }); + + it('reports the dependency phase and removes the incomplete target', async () => { + installDependenciesMock.mockRejectedValueOnce( + new Error('npm install failed'), + ); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `Unable to initialize project while installing dependencies: Error: npm install failed. Removed the incomplete target "${defaultTarget}".`, + ); + expect(rmMock).toHaveBeenCalledWith(defaultTarget, { + recursive: true, + force: true, + }); + }); + + it('reports the hook phase and removes the incomplete target', async () => { + existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); + executeScriptMock.mockRejectedValueOnce(new Error('hook failed')); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `Unable to initialize project while executing the starter init hook: Error: hook failed. Removed the incomplete target "${defaultTarget}".`, + ); + expect(executeScriptMock).toHaveBeenCalledWith(defaultInitHookPath); + expect(rmMock).toHaveBeenCalledWith(defaultTarget, { + recursive: true, + force: true, + }); + }); + + it('provides manual cleanup guidance when rollback fails', async () => { + installDependenciesMock.mockRejectedValueOnce( + new Error('npm install failed'), + ); + rmMock.mockRejectedValueOnce(new Error('EACCES')); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `Unable to initialize project while installing dependencies: Error: npm install failed. Automatic cleanup of the incomplete target "${defaultTarget}" also failed: Error: EACCES. Remove it manually before retrying.`, + ); + expect(rmMock).toHaveBeenCalledWith(defaultTarget, { + recursive: true, + force: true, }); + }); + + it('rolls back the target when removing starter Git metadata fails', async () => { + rmMock.mockRejectedValueOnce(new Error('Git metadata is locked')); + await expect(init(progress)('cornflake')).rejects.toThrow( - 'Unable to pull down https://github.com/emulsify-ds/emulsify-starter: Error: Does not exist!', + `Unable to initialize project while removing the starter Git metadata: Error: Git metadata is locked. Removed the incomplete target "${defaultTarget}".`, ); + expect(rmMock).toHaveBeenNthCalledWith(1, defaultGitPath, { + recursive: true, + }); + expect(rmMock).toHaveBeenNthCalledWith(2, defaultTarget, { + recursive: true, + force: true, + }); + }); + + it('does not roll back a completed project when success logging fails', async () => { + logMock.mockImplementationOnce(() => { + throw new Error('terminal output failed'); + }); + + await expect(init(progress)('cornflake')).rejects.toThrow( + 'terminal output failed', + ); + expect(rmMock).toHaveBeenCalledTimes(1); + expect(rmMock).toHaveBeenCalledWith(defaultGitPath, { recursive: true }); + expect(rmMock).not.toHaveBeenCalledWith(defaultTarget, { + recursive: true, + force: true, + }); }); it('throws if no target is found or specified', async () => { @@ -428,11 +583,38 @@ describe('init', () => { }); it('throws if the target directory already exists', async () => { - expect.assertions(1); + expect.assertions(4); existsSyncMock.mockReturnValueOnce(true); await expect(init(progress)('cornflake')).rejects.toThrow( `The intended target is already occupied: ${defaultTarget}`, ); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('does not remove a target created by another process after preflight', async () => { + mkdirMock.mockRejectedValueOnce( + Object.assign(new Error('already exists'), { code: 'EEXIST' }), + ); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `The intended target is already occupied: ${defaultTarget}`, + ); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('does not remove an unowned target when reserving it fails', async () => { + mkdirMock.mockRejectedValueOnce( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + + await expect(init(progress)('cornflake')).rejects.toThrow( + `Unable to initialize project while creating the target directory "${defaultTarget}": Error: permission denied`, + ); + expect(gitCloneMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); }); it('should prompt for all info if name is missing', async () => { diff --git a/src/handlers/init.ts b/src/handlers/init.ts index a054961..9bb5ff8 100644 --- a/src/handlers/init.ts +++ b/src/handlers/init.ts @@ -16,6 +16,7 @@ import { } from '../lib/constants.js'; import getPlatformInfo from '../util/platform/getPlatformInfo.js'; import getAvailableStarters from '../util/getAvailableStarters.js'; +import loadJsonFile from '../util/fs/loadJsonFile.js'; import writeToJsonFile from '../util/fs/writeToJsonFile.js'; import strToMachineName from '../util/strToMachineName.js'; import installDependencies from '../util/project/installDependencies.js'; @@ -37,6 +38,43 @@ const PLATFORM_CHOICES = [ 'none', ] as const satisfies readonly Platform[]; +type InitializationPhase = + | 'cloning the starter' + | 'reading the starter project configuration' + | 'writing the project configuration' + | 'installing dependencies' + | 'executing the starter init hook' + | 'removing the starter Git metadata'; + +function isAlreadyExistsError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'EEXIST' + ); +} + +async function rollbackFailedInitialization( + target: string, + phase: InitializationPhase, + error: unknown, +): Promise { + const failure = `Unable to initialize project while ${phase}: ${String(error)}`; + + try { + await fs.rm(target, { recursive: true, force: true }); + + return new CliError( + `${failure}. Removed the incomplete target "${target}".`, + ); + } catch (cleanupError) { + return new CliError( + `${failure}. Automatic cleanup of the incomplete target "${target}" also failed: ${String(cleanupError)}. Remove it manually before retrying.`, + ); + } +} + /** * Handler for the initialization command. * @@ -174,6 +212,25 @@ export default function init(progress: InstanceType) { throw new CliError(`The intended target is already occupied: ${target}`); } + // Reserve the target atomically before cloning so rollback only ever + // removes a directory created by this command run. Git can clone into an + // existing empty directory. + try { + await fs.mkdir(target); + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new CliError( + `The intended target is already occupied: ${target}`, + ); + } + + throw new CliError( + `Unable to initialize project while creating the target directory "${target}": ${String(error)}`, + ); + } + + let phase: InitializationPhase = 'cloning the starter'; + try { progress.tick(10, { message: 'validation complete, cloning starter' }); @@ -189,18 +246,27 @@ export default function init(progress: InstanceType) { : {}, ); - // Construct an Emulsify configuration object. - await writeToJsonFile( - join(target, EMULSIFY_PROJECT_CONFIG_FILE), - { - project: { - platform: platformName, - name: projectName, - machineName, - }, - starter: { repository }, + // Preserve starter-provided settings while replacing the values that + // describe this concrete generated project. + phase = 'reading the starter project configuration'; + const configPath = join(target, EMULSIFY_PROJECT_CONFIG_FILE); + const starterConfig = + await loadJsonFile>(configPath); + + phase = 'writing the project configuration'; + await writeToJsonFile(configPath, { + ...starterConfig, + project: { + ...starterConfig?.project, + platform: platformName, + name: projectName, + machineName, }, - ); + starter: { + ...starterConfig?.starter, + repository, + }, + }); progress.tick(30, { message: @@ -208,6 +274,7 @@ export default function init(progress: InstanceType) { }); // Install project dependencies. + phase = 'installing dependencies'; await installDependencies(target); progress.tick(40, { @@ -221,25 +288,30 @@ export default function init(progress: InstanceType) { EMULSIFY_PROJECT_HOOK_INIT, ); if (existsSync(initPath)) { + phase = 'executing the starter init hook'; await executeScript(initPath); } // Remove the .git directory, as this is a starter kit. This step // should happen after dependencies are installed, and init scripts are // executed, otherwise git-reliant dev deps in the starter may error out. + phase = 'removing the starter Git metadata'; await fs.rm(join(target, '.git'), { recursive: true }); - - progress.tick(10, { - message: 'init script executed, initialization complete', - }); - - log('success', `Created an Emulsify project in ${target}.`); - getInitSuccessMessageForPlatform(platformName, target, { - includeDrupalInstallReminder: - isDetectedDrupalProject && platformName === 'drupal', - }).map(({ method, message }) => log(method, message)); } catch (e) { - throw new CliError(`Unable to pull down ${repository}: ${String(e)}`); + throw await rollbackFailedInitialization(target, phase, e); } + + // The filesystem transaction is complete. Keep display-only work outside + // the rollback boundary so a terminal/logging failure cannot delete a + // successfully initialized project. + progress.tick(10, { + message: 'init script executed, initialization complete', + }); + + log('success', `Created an Emulsify project in ${target}.`); + getInitSuccessMessageForPlatform(platformName, target, { + includeDrupalInstallReminder: + isDetectedDrupalProject && platformName === 'drupal', + }).map(({ method, message }) => log(method, message)); }; } diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index f1e95b6..ef91307 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -1075,6 +1075,42 @@ describe('systemInstall', () => { ); }); + it('installs optional dependencies of required components', async () => { + const variantWithDependency = { + ...variant, + components: [ + { + ...variant.components[0], + dependency: ['card'], + }, + variant.components[1], + ], + }; + const systemWithDependency = { + ...system, + variants: [variantWithDependency], + }; + getJsonFromCachedFileMock.mockResolvedValueOnce(systemWithDependency); + + await systemInstall('compound', {}); + + expect(installComponentFromCacheMock).toHaveBeenCalledTimes(2); + expect(installComponentFromCacheMock).toHaveBeenNthCalledWith( + 1, + systemWithDependency, + variantWithDependency, + 'button', + true, + ); + expect(installComponentFromCacheMock).toHaveBeenNthCalledWith( + 2, + systemWithDependency, + variantWithDependency, + 'card', + true, + ); + }); + it('installs all components when the all option is passed', async () => { await systemInstall('compound', { all: true }); diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index e1f45d8..29ac0f7 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -41,6 +41,7 @@ import { } from '../util/platform/platformCompatibility.js'; import { runPrompt } from '../util/prompt/index.js'; import buildSystemInstallPlan, { + selectSystemComponents, type SystemInstallPlan, } from '../util/system/buildSystemInstallPlan.js'; @@ -723,9 +724,7 @@ export default async function systemInstall( guidedInstall && !options.all ? await promptForInstallScope(variantConf, ++wizardStep, wizardTotalSteps) : options.all === true; - let componentsToInstall = installAll - ? variantConf.components - : variantConf.components.filter(({ required }) => required === true); + let componentsToInstall = selectSystemComponents(variantConf, installAll); if (guidedInstall) { const projectConfigPath = findFileInCurrentPath( diff --git a/src/index.ts b/src/index.ts index a66d481..594c926 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,6 +129,7 @@ system // Component sub-commands. const component = program .command('component') + .alias('components') .description('List, install, create, or customize components'); component .command('list') diff --git a/src/util/system/buildSystemInstallPlan.test.ts b/src/util/system/buildSystemInstallPlan.test.ts index 0de0d48..ad2ff1d 100644 --- a/src/util/system/buildSystemInstallPlan.test.ts +++ b/src/util/system/buildSystemInstallPlan.test.ts @@ -76,6 +76,37 @@ describe('buildSystemInstallPlan', () => { expect(plan.components[1]).toBe(icon); }); + it('includes optional transitive dependencies of required components once', () => { + const requiredButton = { + ...button, + dependency: ['card', 'icon'], + }; + const dependentCard = { + ...card, + dependency: ['icon'], + }; + const optionalIcon = { + ...icon, + required: false, + }; + const variant = buildVariant({ + components: [requiredButton, dependentCard, optionalIcon], + }); + const plan = buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ); + + expect(plan.components).toEqual([ + requiredButton, + dependentCard, + optionalIcon, + ]); + expect(plan.requiredComponentCount).toBe(1); + }); + it('selects all component objects while preserving config order', () => { const variant = buildVariant(); const plan = buildSystemInstallPlan( diff --git a/src/util/system/buildSystemInstallPlan.ts b/src/util/system/buildSystemInstallPlan.ts index 03b11eb..c470bdd 100644 --- a/src/util/system/buildSystemInstallPlan.ts +++ b/src/util/system/buildSystemInstallPlan.ts @@ -2,6 +2,7 @@ import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; import { dirname, relative, sep } from 'path'; import safeResolveWithin from '../fs/safeResolveWithin.js'; +import buildComponentDependencyList from '../project/buildComponentDependencyList.js'; import { getComponentDestination } from '../project/installComponentFromCache.js'; type SystemComponent = EmulsifyVariant['components'][number]; @@ -30,6 +31,28 @@ function uniqueInOrder(values: string[]): string[] { return [...new Set(values)]; } +export function selectSystemComponents( + variantConf: EmulsifyVariant, + installAll: boolean, +): SystemComponent[] { + if (installAll) { + return [...variantConf.components]; + } + + const requiredComponents = variantConf.components.filter( + ({ required }) => required === true, + ); + const selectedNames = uniqueInOrder( + requiredComponents.flatMap(({ name }) => + buildComponentDependencyList(variantConf.components, name), + ), + ); + + return selectedNames.map((name) => + variantConf.components.find((component) => component.name === name)!, + ); +} + function assertSystemStructure( systemConf: EmulsifySystem, component: SystemComponent, @@ -58,9 +81,7 @@ export default function buildSystemInstallPlan( const requiredComponents = variantConf.components.filter( ({ required }) => required === true, ); - const components = installAll - ? [...variantConf.components] - : requiredComponents; + const components = selectSystemComponents(variantConf, installAll); const componentParentDestinations = uniqueInOrder( components.map((component) => { diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index ff7380e..2b19720 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -876,6 +876,25 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(existsSync(join(isolatedHome, '.emulsify', 'cache')), true); }); + test('creates a component through the plural command alias', () => { + const componentName = 'plural-alias-example'; + const componentRoot = join(projectRoot, 'components', componentName); + const templatePath = join(componentRoot, `${componentName}.twig`); + const result = runCli(projectRoot, [ + 'components', + 'create', + componentName, + '--type', + 'twig', + '--directory', + 'components', + ]); + + assert.equal(result.status, 0, commandFailure('components create', result)); + assert.equal(result.stderr, ''); + assert.equal(existsSync(templatePath), true); + }); + test('requires an explicit template type outside a TTY without writing files', () => { const templatesRoot = join(projectRoot, '.cli', 'templates'); const before = snapshotFiles(templatesRoot);