From 5d21d5e9dfae6e16755002adb1e1688802c181ab Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 01:12:24 -0500 Subject: [PATCH 01/33] feat(cli): prompt for missing arguments in interactive terminals --- README.md | 17 ++- docs/cli-reference.md | 20 +++- docs/components.md | 37 +++++- src/handlers/componentCreate.test.ts | 48 +++++++- src/handlers/componentCreate.ts | 40 ++++++- src/handlers/componentInstall.test.ts | 105 ++++++++++++++++- src/handlers/componentInstall.ts | 81 ++++++++++--- src/handlers/init.ts | 66 ++++++----- src/handlers/systemInstall.ts | 60 +++++----- src/index.ts | 8 +- src/util/project/generateComponent.ts | 67 ++++++----- src/util/prompt/index.test.ts | 162 ++++++++++++++++++++++++++ src/util/prompt/index.ts | 67 +++++++++++ test/e2e/cli.test.mjs | 22 ++++ 14 files changed, 680 insertions(+), 120 deletions(-) create mode 100644 src/util/prompt/index.test.ts create mode 100644 src/util/prompt/index.ts diff --git a/README.md b/README.md index fa26e52..dece4ac 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,29 @@ emulsify init "My Theme" ./wp-content/themes --platform wordpress When WordPress is auto-detected, Emulsify initializes child themes into the detected themes directory, such as `wp-content/themes/my-theme` or `web/app/themes/my-theme` for Bedrock. -For non-interactive environments, pass the flags that normally prompt for input: +Interactive terminals can run `emulsify component create` with no arguments to +walk through the component name, format, and directory prompts. Likewise, +`emulsify component install` with no name presents the components available in +the installed system variant plus an explicit choice to install all components. + +Prompts only run when standard input is a TTY. In CI, scripts, and commands with +piped or redirected input, provide every required positional argument and flag; +the CLI exits with an actionable error instead of waiting for input: ```bash emulsify init "My Theme" ./web/themes/custom --platform drupal --yes emulsify system install compound +emulsify component install card --force +# Or install every available component: +emulsify component install --all emulsify component create promo-card --directory molecules --format default --yes ``` +For component installation, provide either a component name or `--all`, and use +`--force` when an existing destination should be replaced. For component +creation, provide the positional name plus `--format` and `--directory`, and use +`--yes` when an existing generated component should be replaced. + ## Documentation Detailed documentation lives in [docs](./docs/README.md). diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 37b0e1a..cd7d352 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -179,6 +179,10 @@ emulsify component install [name] emulsify component i [name] ``` +In an interactive terminal, omit `[name]` and `--all` to choose from the +components actually available in the installed system variant. The picker also +includes an explicit choice to install all available components. + Options: | Option | Description | @@ -198,6 +202,11 @@ emulsify component i accordion --force emulsify component install --all ``` +When standard input is not a TTY, provide either `[name]` or `--all`; the CLI +exits with an actionable error instead of opening the picker. If a named +component destination already exists, the command also exits unless `--force` +is passed to replace it without an overwrite prompt. + ## `component create` ```bash @@ -205,6 +214,11 @@ emulsify component create [name] emulsify component c [name] ``` +Run without `[name]` in an interactive terminal to start the complete creation +wizard. The CLI prompts for the component name first, explains invalid names and +prompts again, then asks for any missing format and directory values. Supplying +any of those values on the command line skips its corresponding prompt. + Options: | Option | Description | @@ -224,4 +238,8 @@ emulsify component create promo-card --directory molecules --format default --dr emulsify component c teaser --directory molecules --format sdc --yes ``` -In a non-interactive environment, pass both `--directory` and `--format`; otherwise the command errors instead of waiting for prompts that cannot be answered. +When standard input is not a TTY, provide the positional `[name]` plus both +`--directory` and `--format`; otherwise the command exits with an actionable +error instead of waiting for prompts that cannot be answered. If the generated +component already exists, also pass `--yes` to replace it without an overwrite +prompt. diff --git a/docs/components.md b/docs/components.md index 12fe86b..eb4fbc7 100644 --- a/docs/components.md +++ b/docs/components.md @@ -32,6 +32,15 @@ emulsify component i card The command installs the named component from the cached system into the project-relative directory defined by the selected variant structure. +In an interactive terminal, you can omit the name: + +```bash +emulsify component install +``` + +The CLI presents the components actually available in the installed system +variant, along with an explicit choice to install all available components. + If the component declares dependencies, those dependencies are installed too. ```json @@ -64,6 +73,19 @@ Use `--all` to install every component from the selected variant. This mode forc emulsify component install --all ``` +## Non-Interactive Installation + +Prompts only run when standard input is a TTY. In CI, scripts, and commands with +piped or redirected input, provide either a component name or `--all`; otherwise +the command exits with an actionable error instead of waiting for input. + +If a named component destination already exists, the non-interactive command +exits unless `--force` is passed to replace it without an overwrite prompt: + +```bash +emulsify component install card --force +``` + ## Dry Runs Use `--dry-run` to preview component installation without copying, removing, or overwriting files. @@ -86,6 +108,16 @@ Dry-run output includes: `component create` generates a new component from built-in templates or project-level template overrides. +In an interactive terminal, run it without a name to start the complete wizard: + +```bash +emulsify component create +``` + +The CLI prompts for a component name first. Invalid names are explained and +prompted again, after which the CLI prompts for any missing format and directory +values. + ```bash emulsify component create promo-card --directory molecules --format default emulsify component c teaser --directory molecules --format sdc @@ -150,7 +182,10 @@ Dry-run output includes the selected format, structure path, parent directory, f ## Non-Interactive Creation -In interactive terminals, missing `--format` or `--directory` values are prompted. In non-interactive environments, pass both flags: +Prompts only run when standard input is a TTY. In CI, scripts, and commands with +piped or redirected input, provide the positional component name plus both +`--format` and `--directory`; otherwise the command exits with an actionable +error instead of waiting for input: ```bash emulsify component create featured-item --directory base --format default diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index b019500..00409ff 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -12,7 +12,7 @@ jest.mock('@inquirer/prompts'); import fs from 'fs'; import { join, normalize, resolve, sep } from 'path'; import { pathExists, remove } from 'fs-extra'; -import { select, confirm } from '@inquirer/prompts'; +import { input, select, confirm } from '@inquirer/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; @@ -35,6 +35,7 @@ const cloneSystemMock = jest.fn(); const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; const pathExistsMock = pathExists as jest.Mock; const removeMock = remove as jest.Mock; +const inputMock = input as jest.Mock; const selectMock = select as jest.Mock; const confirmMock = confirm as jest.Mock; const mkdirMock = fs.promises.mkdir as jest.Mock; @@ -122,6 +123,7 @@ describe('componentCreate', () => { findFileInCurrentPathMock.mockReturnValue(projectConfigPath); pathExistsMock.mockResolvedValue(false); removeMock.mockResolvedValue(undefined); + inputMock.mockResolvedValue('button'); selectMock.mockResolvedValue('default'); confirmMock.mockResolvedValue(false); mkdirMock.mockResolvedValue(undefined); @@ -236,16 +238,46 @@ describe('componentCreate', () => { ); }); - it('throws a CliError when no component name is provided', async () => { - await expect(componentCreate('', {})).rejects.toThrow(CliError); - await expect(componentCreate('', {})).rejects.toThrow( + it('throws a CliError before loading the system when no component name is provided non-interactively', async () => { + setStdinIsTTY(false); + + await expect(componentCreate('', { refresh: true })).rejects.toThrow( + CliError, + ); + await expect(componentCreate('', { refresh: true })).rejects.toThrow( 'Please specify a name for the new component.', ); + expect(inputMock).not.toHaveBeenCalled(); expect(logMock).not.toHaveBeenCalled(); expect(getEmulsifyConfigMock).not.toHaveBeenCalled(); }); + it('prompts for a missing component name and validates it before continuing', async () => { + inputMock.mockImplementationOnce(async ({ validate }) => { + expect(validate('promo card')).toBe( + 'Component name may only include letters, numbers, and single hyphens between words.', + ); + expect(validate('---')).toBe( + 'Component name must include at least one letter or number.', + ); + expect(validate('promo-card')).toBe(true); + return 'promo-card'; + }); + selectMock.mockResolvedValueOnce('default').mockResolvedValueOnce('base'); + + await componentCreate(undefined, {}); + + expect(inputMock).toHaveBeenCalledWith({ + message: 'Component name:', + validate: expect.any(Function), + }); + expect(writeFileMock).toHaveBeenCalledWith( + join(componentPath('promo-card'), 'promo-card.twig'), + expect.stringContaining('promo-card.twig'), + ); + }); + it('prompts for format and directory when no directory is provided', async () => { selectMock.mockResolvedValueOnce('default').mockResolvedValueOnce('base'); @@ -358,4 +390,12 @@ describe('componentCreate', () => { 'Unable to create the button component: Unable to find an Emulsify project to create the component into.', ); }); + + it('preserves prompt cancellation for the top-level handler', async () => { + const cancellation = new Error('User force closed the prompt'); + cancellation.name = 'ExitPromptError'; + selectMock.mockRejectedValueOnce(cancellation); + + await expect(componentCreate('button', {})).rejects.toBe(cancellation); + }); }); diff --git a/src/handlers/componentCreate.ts b/src/handlers/componentCreate.ts index f7b4246..d6f4560 100644 --- a/src/handlers/componentCreate.ts +++ b/src/handlers/componentCreate.ts @@ -1,7 +1,22 @@ import type { CreateComponentHandlerOptions } from '@emulsify-cli/handlers'; +import { input } from '@inquirer/prompts'; import generateComponent from '../util/project/generateComponent.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; import CliError from '../lib/CliError.js'; +import deriveComponentNames from '../util/deriveComponentNames.js'; +import { isExitPromptError, runPrompt } from '../util/prompt/index.js'; + +const MISSING_COMPONENT_NAME_ERROR = + 'Please specify a name for the new component.'; + +function validateComponentName(name: string): true | string { + try { + deriveComponentNames(name); + return true; + } catch (error) { + return (error as Error).message; + } +} /** * Handler for the `component create` command. @@ -11,12 +26,19 @@ import CliError from '../lib/CliError.js'; * @throws {CliError} if component generation fails. */ export default async function componentCreate( - name: string, + name: string | void, options: CreateComponentHandlerOptions = {}, ): Promise { - if (!name?.trim()) { - throw new CliError('Please specify a name for the new component.'); - } + const componentName = name?.trim() + ? name + : await runPrompt({ + prompt: () => + input({ + message: 'Component name:', + validate: validateComponentName, + }), + nonInteractive: { error: MISSING_COMPONENT_NAME_ERROR }, + }); // Load the configured system and variant before generating the local component. const { variantConf } = await withEmulsifySystem('create components', { @@ -24,9 +46,15 @@ export default async function componentCreate( }); try { - await generateComponent(variantConf, name, options); + await generateComponent(variantConf, componentName, options); } catch (e) { + if (isExitPromptError(e)) { + throw e; + } + const msg = e instanceof Error ? e.message : String(e); - throw new CliError(`Unable to create the ${name} component: ${msg}`); + throw new CliError( + `Unable to create the ${componentName} component: ${msg}`, + ); } } diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index 22d6f62..d23b79b 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -12,7 +12,7 @@ jest.mock('@inquirer/prompts'); import { pathExists } from 'fs-extra'; import { join, resolve } from 'path'; -import { confirm } from '@inquirer/prompts'; +import { confirm, select } from '@inquirer/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; @@ -36,6 +36,15 @@ const copyItemFromCacheMock = copyItemFromCache as jest.Mock; const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; const pathExistsMock = pathExists as jest.Mock; const confirmMock = confirm as jest.Mock; +const selectMock = select as jest.Mock; +const originalStdinIsTTY = process.stdin.isTTY; + +function setStdinIsTTY(value: boolean | undefined) { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + }); +} const projectRoot = resolve('/project'); const projectConfigPath = join(projectRoot, 'project.emulsify.json'); @@ -102,6 +111,7 @@ const system = { describe('componentInstall', () => { beforeEach(() => { jest.clearAllMocks(); + setStdinIsTTY(true); // The handler clones systems through a higher-order cache helper. cloneIntoCacheMock.mockReturnValue(cloneSystemMock); cloneSystemMock.mockResolvedValue(undefined); @@ -111,6 +121,11 @@ describe('componentInstall', () => { findFileInCurrentPathMock.mockReturnValue(projectConfigPath); pathExistsMock.mockResolvedValue(false); confirmMock.mockResolvedValue(false); + selectMock.mockResolvedValue('card'); + }); + + afterAll(() => { + setStdinIsTTY(originalStdinIsTTY); }); it('throws when no Emulsify project is detected', async () => { @@ -228,14 +243,82 @@ describe('componentInstall', () => { ); }); - it('throws a CliError when neither a component name nor all option is provided', async () => { - await expect(componentInstall('', {})).rejects.toThrow(CliError); - await expect(componentInstall('', {})).rejects.toThrow( + it('throws a CliError before loading the system when neither a component name nor all option is provided non-interactively', async () => { + setStdinIsTTY(false); + + await expect(componentInstall('', { refresh: true })).rejects.toThrow( + CliError, + ); + await expect(componentInstall('', { refresh: true })).rejects.toThrow( 'Please specify a component to install, or pass --all to install all available components.', ); + expect(selectMock).not.toHaveBeenCalled(); expect(logMock).not.toHaveBeenCalled(); expect(getEmulsifyConfigMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + }); + + it('prompts with components from the installed variant and installs the selection', async () => { + selectMock.mockResolvedValueOnce('button'); + + await componentInstall(undefined, { force: true }); + + expect(selectMock).toHaveBeenCalledWith({ + message: 'Choose a component to install:', + choices: [ + { + name: 'button', + value: 'button', + description: undefined, + }, + { + name: 'icon', + value: 'icon', + description: undefined, + }, + { + name: 'card', + value: 'card', + description: undefined, + }, + { + name: 'Install all available components', + value: expect.any(Symbol), + }, + ], + }); + expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( + 1, + 'systems', + ['compound', 'components/00-base', 'button'], + componentPath('button'), + true, + ); + expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( + 2, + 'systems', + ['compound', 'components/00-base', 'icon'], + componentPath('icon'), + true, + ); + }); + + it('installs all components when the interactive all choice is selected', async () => { + selectMock.mockImplementationOnce(async ({ choices }) => { + return choices.at(-1).value; + }); + + await componentInstall(undefined, {}); + + expect(copyItemFromCacheMock).toHaveBeenCalledTimes(3); + expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( + 3, + 'systems', + ['compound', 'components/00-base', 'card'], + componentPath('card'), + true, + ); }); it('throws when the requested component is not found', async () => { @@ -247,6 +330,7 @@ describe('componentInstall', () => { it('installs all components with force when all option is passed', async () => { await componentInstall('', { all: true }); + expect(selectMock).not.toHaveBeenCalled(); expect(copyItemFromCacheMock).toHaveBeenCalledTimes(3); expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( 1, @@ -439,6 +523,19 @@ describe('componentInstall', () => { expect(copyItemFromCacheMock).not.toHaveBeenCalled(); }); + it('rejects an existing destination without opening an overwrite prompt when stdin is non-interactive', async () => { + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(true); + + await expect(componentInstall('card', {})).rejects.toThrow( + 'The component "card" already exists. Pass --force to replace existing components in non-interactive mode.', + ); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(copyItemFromCacheMock).not.toHaveBeenCalled(); + expect(logMock).not.toHaveBeenCalled(); + }); + it('reports dependency installation failures and rejects', async () => { copyItemFromCacheMock .mockResolvedValueOnce(undefined) diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index 0dcf439..15901e2 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -1,5 +1,5 @@ import { pathExists } from 'fs-extra'; -import { confirm } from '@inquirer/prompts'; +import { confirm, select } from '@inquirer/prompts'; import log from '../lib/log.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js'; import CliError from '../lib/CliError.js'; @@ -11,8 +11,14 @@ import installComponentFromCache, { import buildComponentDependencyList from '../util/project/buildComponentDependencyList.js'; import catchLater from '../util/catchLater.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; +import { requireInteractiveTerminal, runPrompt } from '../util/prompt/index.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; +const INSTALL_ALL_COMPONENTS = Symbol('install all components'); +const MISSING_COMPONENT_INSTALL_TARGET_ERROR = + 'Please specify a component to install, or pass --all to install all available components.'; +type ComponentInstallSelection = string | typeof INSTALL_ALL_COMPONENTS; + type ComponentInstallPlanItem = { name: string; isDependency: boolean; @@ -111,13 +117,17 @@ function logComponentInstallDryRun( * @throws {CliError} if any requested component or dependency fails to install. */ export default async function componentInstall( - name: string, + name: string | void, { force, all, dryRun, refresh }: InstallComponentHandlerOptions, ): Promise { - if (!name && !all) { - throw new CliError( - 'Please specify a component to install, or pass --all to install all available components.', - ); + let selectedName = name; + let installAll = all === true; + const needsSelection = !selectedName?.trim() && !installAll; + + // Preserve the fail-fast non-interactive path before loading or refreshing + // the cached system. Interactive sessions need that system to build choices. + if (needsSelection) { + requireInteractiveTerminal(MISSING_COMPONENT_INSTALL_TARGET_ERROR); } // Load the configured system and variant before resolving component installs. @@ -126,9 +136,36 @@ export default async function componentInstall( { refresh }, ); + if (needsSelection) { + const selection = await runPrompt({ + prompt: () => + select({ + message: 'Choose a component to install:', + choices: [ + ...variantConf.components.map((component) => ({ + name: component.name, + value: component.name, + description: component.description, + })), + { + name: 'Install all available components', + value: INSTALL_ALL_COMPONENTS, + }, + ], + }), + nonInteractive: { error: MISSING_COMPONENT_INSTALL_TARGET_ERROR }, + }); + + if (selection === INSTALL_ALL_COMPONENTS) { + installAll = true; + } else { + selectedName = selection; + } + } + // If all components are to be installed, spawn promises for installing all available components. const components: [string, boolean, Promise][] = []; - if (all) { + if (installAll) { const componentNames = variantConf.components.map( (component) => component.name, ); @@ -161,27 +198,28 @@ export default async function componentInstall( } // If there is only one component to install, add one single promise for the single component. else { + const rootComponentName = selectedName as string; const componentsWithDependencies = buildComponentDependencyList( variantConf.components, - name, + rootComponentName, ); if (componentsWithDependencies.length === 0) { throw new CliError( - `Cannot find the definition for component "${name}".\n\nRun "emulsify component list" to see the full list.`, + `Cannot find the definition for component "${rootComponentName}".\n\nRun "emulsify component list" to see the full list.`, ); } if (dryRun) { const dependencies = componentsWithDependencies.filter( - (componentName) => componentName !== name, + (dependencyName) => dependencyName !== rootComponentName, ); const plan = await buildComponentInstallPlan( variantConf, componentsWithDependencies, - name, + rootComponentName, Boolean(force), ); - logComponentInstallDryRun(name, dependencies, plan); + logComponentInstallDryRun(rootComponentName, dependencies, plan); return; } @@ -190,17 +228,26 @@ export default async function componentInstall( ); for (const componentName of componentsWithDependencies) { - const isDependency = componentName !== name; + const isDependency = componentName !== rootComponentName; let currentForce = force; const destination = projectConfigPath ? getComponentDestination(variantConf, componentName, projectConfigPath) : undefined; if (destination && (await pathExists(destination)) && !force) { - const dependencyNote = isDependency ? ` (required by "${name}")` : ''; - const result = await confirm({ - message: `The component "${componentName}"${dependencyNote} already exists. Would you like to replace it?`, - default: false, + const dependencyNote = isDependency + ? ` (required by "${rootComponentName}")` + : ''; + const overwriteMessage = `The component "${componentName}"${dependencyNote} already exists.`; + const result = await runPrompt({ + prompt: () => + confirm({ + message: `${overwriteMessage} Would you like to replace it?`, + default: false, + }), + nonInteractive: { + error: `${overwriteMessage} Pass --force to replace existing components in non-interactive mode.`, + }, }); if (result) { diff --git a/src/handlers/init.ts b/src/handlers/init.ts index d99eacb..a054961 100644 --- a/src/handlers/init.ts +++ b/src/handlers/init.ts @@ -24,6 +24,7 @@ import getInitSuccessMessageForPlatform from '../util/platform/getInitSuccessMes import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; import { isPlatform } from '../util/platform/platformCompatibility.js'; +import { runPrompt } from '../util/prompt/index.js'; const git = simpleGit(); @@ -60,21 +61,27 @@ export default function init(progress: InstanceType) { const { name: autoPlatformName, emulsifyParentDirectory } = (await getPlatformInfo()) || {}; const isDetectedDrupalProject = autoPlatformName === 'drupal'; - const canPrompt = process.stdin.isTTY === true; const acceptDefaults = options?.yes === true; // Prompts are skipped in non-TTY runs; --yes accepts prompt defaults and // explicit flags/arguments always take precedence. let projectName = name || options?.machineName; if (!projectName) { - if (acceptDefaults) { - projectName = DEFAULT_PROJECT_NAME; - } else if (canPrompt) { - projectName = await input({ - message: 'Project name:', - default: DEFAULT_PROJECT_NAME, - }); - } + projectName = await runPrompt({ + prompt: () => + input({ + message: 'Project name:', + default: DEFAULT_PROJECT_NAME, + }), + nonInteractive: { + error: + 'Unable to determine the project name. Please provide a valid project name.', + }, + accept: { + when: acceptDefaults, + value: DEFAULT_PROJECT_NAME, + }, + }); } if (!projectName) { @@ -85,14 +92,17 @@ export default function init(progress: InstanceType) { let targetParent = targetDirectory || emulsifyParentDirectory; if (!targetParent) { - if (acceptDefaults) { - targetParent = './'; - } else if (canPrompt) { - targetParent = await input({ - message: 'Target directory:', - default: './', - }); - } + targetParent = await runPrompt({ + prompt: () => + input({ + message: 'Target directory:', + default: './', + }), + // Preserve the existing error ordering: platform validation occurs + // before the missing target is reported below. + nonInteractive: { value: undefined }, + accept: { when: acceptDefaults, value: './' }, + }); } // If no platform name is given, and none can be detected, exit and error. @@ -106,15 +116,19 @@ export default function init(progress: InstanceType) { ); } if (!platformName) { - if (acceptDefaults) { - platformName = DEFAULT_PLATFORM; - } else if (canPrompt) { - platformName = await select({ - message: 'Platform:', - choices: PLATFORM_CHOICES, - default: DEFAULT_PLATFORM, - }); - } + platformName = await runPrompt({ + prompt: () => + select({ + message: 'Platform:', + choices: PLATFORM_CHOICES, + default: DEFAULT_PLATFORM, + }), + nonInteractive: { + error: + 'Unable to determine which platform you are installing Emulsify within. Please specify a platform (such as "drupal" or "wordpress") by passing a -p or --platform flag with your init command.', + }, + accept: { when: acceptDefaults, value: DEFAULT_PLATFORM }, + }); } if (!platformName) { diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 087e258..847b4e2 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -37,6 +37,7 @@ import { selectCompatiblePlatformVariant, selectExactPlatformVariant, } from '../util/platform/platformCompatibility.js'; +import { runPrompt } from '../util/prompt/index.js'; const CREATE_NEW_SYSTEM_CHOICE = 'create a new system'; const CANCEL_SYSTEM_INSTALL_CHOICE = 'cancel'; @@ -104,14 +105,19 @@ export async function getSystemRepoInfo( } async function promptForSystemInstallChoice(): Promise { - const availableSystems = await getAvailableSystems(); - const selectedSystem = await select({ - message: 'Choose a component system:', - choices: [ - ...availableSystems.map(({ name }) => name), - CREATE_NEW_SYSTEM_CHOICE, - CANCEL_SYSTEM_INSTALL_CHOICE, - ], + const selectedSystem = await runPrompt({ + prompt: async () => { + const availableSystems = await getAvailableSystems(); + return select({ + message: 'Choose a component system:', + choices: [ + ...availableSystems.map(({ name }) => name), + CREATE_NEW_SYSTEM_CHOICE, + CANCEL_SYSTEM_INSTALL_CHOICE, + ], + }); + }, + nonInteractive: { error: SYSTEM_INSTALL_ERROR }, }); if (selectedSystem === CANCEL_SYSTEM_INSTALL_CHOICE) { @@ -140,10 +146,15 @@ async function promptForVariantChoice( variants: T[], projectPlatform: Platform, systemName: string, + nonInteractiveError: string, ): Promise { - const selectedPlatform = await select({ - message: `Choose a ${systemName} variant for project platform "${projectPlatform}":`, - choices: variants.map(({ platform }) => platform), + const selectedPlatform = await runPrompt({ + prompt: () => + select({ + message: `Choose a ${systemName} variant for project platform "${projectPlatform}":`, + choices: variants.map(({ platform }) => platform), + }), + nonInteractive: { error: nonInteractiveError }, }); return variants.find(({ platform }) => platform === selectedPlatform) as T; } @@ -162,11 +173,16 @@ async function resolveSystemVariant( return selection.variant; } - if (selection.status === 'ambiguous' && process.stdin.isTTY === true) { + if (selection.status === 'ambiguous') { return await promptForVariantChoice( selection.variants, projectPlatform, systemConf.name, + getVariantSelectionErrorMessage( + systemConf, + projectPlatform, + requestedVariant, + ), ); } @@ -188,18 +204,13 @@ async function resolveSystemVariant( } if (selection.status === 'ambiguous') { - if (process.stdin.isTTY === true) { - return await promptForVariantChoice( - selection.variants, - projectPlatform, - systemConf.name, - ); - } - const compatibleVariants = selection.variants .map(({ platform }) => platform) .join(', '); - throw new CliError( + return await promptForVariantChoice( + selection.variants, + projectPlatform, + systemConf.name, `Multiple compatible variants were found for project platform "${projectPlatform}" within the system (${systemConf.name}): ${compatibleVariants}. Run this command in an interactive terminal or specify a variant.`, ); } @@ -304,12 +315,7 @@ export default async function systemInstall( // Attempt to load system information, and exit with a log message // if a valid system was not found. let selectedName = name; - if ( - !selectedName && - !options.repository && - !options.checkout && - process.stdin.isTTY === true - ) { + if (!selectedName && !options.repository && !options.checkout) { selectedName = await promptForSystemInstallChoice(); if (!selectedName) { return; diff --git a/src/index.ts b/src/index.ts index 18306de..33f9c8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import audit from './handlers/audit.js'; import cacheClear from './handlers/cacheClear.js'; import CliError from './lib/CliError.js'; import log from './lib/log.js'; +import { isExitPromptError } from './util/prompt/index.js'; import { createRequire } from 'module'; import { cyan, green } from 'colorette'; import boxen from 'boxen'; @@ -277,9 +278,14 @@ if (rootHelpRequested) { try { await program.parseAsync(process.argv); } catch (err) { + // Ctrl-C is an expected prompt cancellation, not a command failure. + if (isExitPromptError(err)) { + log('info', 'Cancelled.'); + process.exitCode = 130; + } // Expected CliError failures map their message and exitCode to the process; // unexpected failures still produce a message and a default non-zero exit. - if (err instanceof CliError) { + else if (err instanceof CliError) { log('error', err.message); process.exitCode = err.exitCode; } else { diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index 768a6f0..9b4b9fd 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -12,6 +12,7 @@ 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 { runPrompt } from '../prompt/index.js'; import resolveComponentTemplate from './resolveComponentTemplate.js'; import type { ComponentTemplateVars } from './renderTemplate.js'; import { @@ -84,7 +85,6 @@ export default async function generateComponent( ): Promise { const { filename, className, camelName, snakeName, humanName } = deriveComponentNames(componentName); - const canPrompt = process.stdin.isTTY === true; const providedFormat = options.format ? getComponentFormat(options.format) : undefined; @@ -104,31 +104,33 @@ export default async function generateComponent( // the command never waits for input it cannot receive. const format = providedFormat ? providedFormat - : canPrompt - ? await select({ - message: cyan('Choose the component format:'), - choices: COMPONENT_FORMAT_CHOICES, - }) - : (() => { - throw new Error( + : await runPrompt({ + prompt: () => + select({ + message: cyan('Choose the component format:'), + choices: COMPONENT_FORMAT_CHOICES, + }), + nonInteractive: { + error: 'Component format is required in non-interactive mode. Pass --format default or --format sdc.', - ); - })(); + }, + }); // Choose the component's parent structure within the given variant configuration. if (!directory) { - if (!canPrompt) { - throw new Error( - 'Component directory is required in non-interactive mode. Pass --directory .', - ); - } - - directory = await select({ - message: cyan('Choose a directory for the new component:'), - choices: variant.structureImplementations.map((structure) => ({ - name: structure.name, - value: structure.name, - })), + directory = await runPrompt({ + prompt: () => + select({ + message: cyan('Choose a directory for the new component:'), + choices: variant.structureImplementations.map((structure) => ({ + name: structure.name, + value: structure.name, + })), + }), + nonInteractive: { + error: + 'Component directory is required in non-interactive mode. Pass --directory .', + }, }); } @@ -268,16 +270,17 @@ export default async function generateComponent( } 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); + const shouldReplace = await runPrompt({ + prompt: () => + confirm({ + message: yellow( + `The component "${humanName}" already exists in ${structure.directory}. Would you like to replace it?`, + ), + default: false, + }), + nonInteractive: { value: false }, + accept: { when: options.yes === true, value: true }, + }); if (!shouldReplace) { return log('info', `Component creation canceled.`); diff --git a/src/util/prompt/index.test.ts b/src/util/prompt/index.test.ts new file mode 100644 index 0000000..6577bed --- /dev/null +++ b/src/util/prompt/index.test.ts @@ -0,0 +1,162 @@ +import CliError from '../../lib/CliError.js'; +import { + isExitPromptError, + isInteractiveTerminal, + requireInteractiveTerminal, + runPrompt, +} from './index.js'; + +const originalStdinIsTTY = process.stdin.isTTY; + +function setStdinIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + }); +} + +describe('prompt utilities', () => { + afterEach(() => { + setStdinIsTTY(originalStdinIsTTY); + }); + + describe('isInteractiveTerminal', () => { + it('returns true only when stdin is explicitly a TTY', () => { + setStdinIsTTY(true); + expect(isInteractiveTerminal()).toBe(true); + + setStdinIsTTY(false); + expect(isInteractiveTerminal()).toBe(false); + + setStdinIsTTY(undefined); + expect(isInteractiveTerminal()).toBe(false); + }); + }); + + describe('requireInteractiveTerminal', () => { + it('allows an interactive terminal', () => { + setStdinIsTTY(true); + + expect(() => requireInteractiveTerminal('Pass --value.')).not.toThrow(); + }); + + it('throws the caller-provided CliError in a non-interactive terminal', () => { + setStdinIsTTY(false); + + let thrown: unknown; + try { + requireInteractiveTerminal('Pass --value.'); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CliError); + expect(thrown).toMatchObject({ + message: 'Pass --value.', + exitCode: 1, + }); + }); + }); + + describe('isExitPromptError', () => { + it("recognizes an Error with Inquirer's cancellation name", () => { + const error = new Error('User force closed the prompt'); + error.name = 'ExitPromptError'; + + expect(isExitPromptError(error)).toBe(true); + }); + + it('rejects other errors and non-Error lookalikes', () => { + expect(isExitPromptError(new Error('different failure'))).toBe(false); + expect(isExitPromptError({ name: 'ExitPromptError' })).toBe(false); + }); + }); + + describe('runPrompt', () => { + it('returns an explicitly accepted value before checking terminal state', async () => { + setStdinIsTTY(false); + const prompt = jest.fn, []>(); + + await expect( + runPrompt({ + prompt, + nonInteractive: { error: 'Pass --value.' }, + accept: { when: true, value: 'accepted default' }, + }), + ).resolves.toBe('accepted default'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('runs a required prompt in an interactive terminal', async () => { + setStdinIsTTY(true); + const prompt = jest.fn().mockResolvedValue('prompted value'); + + await expect( + runPrompt({ + prompt, + nonInteractive: { error: 'Pass --value.' }, + accept: { when: false, value: 'unused default' }, + }), + ).resolves.toBe('prompted value'); + expect(prompt).toHaveBeenCalledTimes(1); + }); + + it('throws before a required prompt in a non-interactive terminal', async () => { + setStdinIsTTY(false); + const prompt = jest.fn, []>(); + + await expect( + runPrompt({ + prompt, + nonInteractive: { error: 'Pass --value.' }, + }), + ).rejects.toMatchObject({ + name: 'CliError', + message: 'Pass --value.', + exitCode: 1, + }); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('uses a safe fallback without prompting in a non-interactive terminal', async () => { + setStdinIsTTY(undefined); + const prompt = jest.fn, []>(); + + await expect( + runPrompt({ + prompt, + nonInteractive: { value: false }, + }), + ).resolves.toBe(false); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('runs a prompt instead of using its fallback in an interactive terminal', async () => { + setStdinIsTTY(true); + const prompt = jest.fn().mockResolvedValue(true); + + await expect( + runPrompt({ + prompt, + nonInteractive: { value: false }, + }), + ).resolves.toBe(true); + expect(prompt).toHaveBeenCalledTimes(1); + }); + + it('preserves prompt cancellation errors for the top-level handler', async () => { + setStdinIsTTY(true); + const cancellation = new Error('User force closed the prompt'); + cancellation.name = 'ExitPromptError'; + + await expect( + runPrompt({ + prompt: async () => { + throw cancellation; + }, + nonInteractive: { error: 'Pass --value.' }, + }), + ).rejects.toBe(cancellation); + }); + }); +}); diff --git a/src/util/prompt/index.ts b/src/util/prompt/index.ts new file mode 100644 index 0000000..0af3b76 --- /dev/null +++ b/src/util/prompt/index.ts @@ -0,0 +1,67 @@ +import CliError from '../../lib/CliError.js'; + +type NonInteractivePromptBehavior = { error: string } | { value: T }; + +export type RunPromptOptions = { + prompt: () => Promise; + nonInteractive: NonInteractivePromptBehavior; + accept?: { + when: boolean; + value: T; + }; +}; + +/** + * Whether stdin belongs to an interactive terminal that can safely show a prompt. + */ +export function isInteractiveTerminal(): boolean { + return process.stdin.isTTY === true; +} + +/** + * Require an interactive terminal before code proceeds to a prompt. + * + * @param nonInteractiveError actionable error shown when stdin is not a TTY. + * @throws {CliError} when stdin is not an interactive terminal. + */ +export function requireInteractiveTerminal(nonInteractiveError: string): void { + if (!isInteractiveTerminal()) { + throw new CliError(nonInteractiveError); + } +} + +/** + * Identify the error Inquirer throws when a user cancels a prompt with Ctrl-C. + * + * @remarks `@inquirer/prompts` does not export this error class, so use its + * stable Error name without depending directly on Inquirer's internal package. + */ +export function isExitPromptError(error: unknown): error is Error { + return error instanceof Error && error.name === 'ExitPromptError'; +} + +/** + * Run a prompt only when stdin is interactive, with explicit behavior for all + * other environments. An accepted value (for example, an opt-in `--yes` + * default) always takes precedence over terminal detection. + */ +export async function runPrompt({ + prompt, + nonInteractive, + accept, +}: RunPromptOptions): Promise { + if (accept?.when === true) { + return accept.value; + } + + if ('error' in nonInteractive) { + requireInteractiveTerminal(nonInteractive.error); + return prompt(); + } + + if (!isInteractiveTerminal()) { + return nonInteractive.value; + } + + return prompt(); +} diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 3db0a36..0d2dd6b 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -256,6 +256,28 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); }); + test('fails fast when component create has no name outside a TTY', () => { + const result = runCli(tempRoot, ['component', 'create']); + + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + assert.match( + result.stderr, + /Please specify a name for the new component\./, + ); + }); + + test('fails fast when component install has no target outside a TTY', () => { + const result = runCli(tempRoot, ['component', 'install']); + + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + assert.match( + result.stderr, + /Please specify a component to install, or pass --all to install all available components\./, + ); + }); + test('initializes a WordPress project with starter hook metadata', () => { const result = runCli(tempRoot, [ 'init', From 58733c71a37c480d17639dab38659db9b8fbfbf6 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 01:31:37 -0500 Subject: [PATCH 02/33] feat(system): add a system create command --- README.md | 18 +- docs/cli-reference.md | 84 +++- docs/systems.md | 153 +++++- src/handlers/systemCreate.test.ts | 491 ++++++++++++++++++++ src/handlers/systemCreate.ts | 223 +++++++++ src/handlers/systemInstall.test.ts | 34 ++ src/index.ts | 35 +- src/types/handlers.d.ts | 15 + src/util/getGitRepoNameFromUrl.test.ts | 69 ++- src/util/getGitRepoNameFromUrl.ts | 66 ++- src/util/system/buildSystemScaffold.test.ts | 109 +++++ src/util/system/buildSystemScaffold.ts | 161 +++++++ test/e2e/cli.test.mjs | 116 +++++ test/e2e/root-help.txt | 12 + 14 files changed, 1553 insertions(+), 33 deletions(-) create mode 100644 src/handlers/systemCreate.test.ts create mode 100644 src/handlers/systemCreate.ts create mode 100644 src/util/system/buildSystemScaffold.test.ts create mode 100644 src/util/system/buildSystemScaffold.ts diff --git a/README.md b/README.md index dece4ac..cf9cf0e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Emulsify CLI -Command line interface for creating Emulsify projects, installing component systems, installing system components, generating local components, and routing project audits to Emulsify Core. +Command line interface for creating Emulsify projects, authoring and installing component systems, installing system components, generating local components, and routing project audits to Emulsify Core. ## Requirements @@ -45,6 +45,18 @@ emulsify init "My Theme" ./wp-content/themes --platform wordpress When WordPress is auto-detected, Emulsify initializes child themes into the detected themes directory, such as `wp-content/themes/my-theme` or `web/app/themes/my-theme` for Bedrock. +To author a standalone, distributable component system, run `system create` +outside or inside any project. The target directory is created beneath the +selected parent directory: + +```bash +emulsify system create "My System" --directory ./systems --platform "drupal || wordpress" --git +``` + +This creates `./systems/my-system` with valid system and variant configuration, +an installable `example-card` component, repository documentation, a +`.gitignore`, and a license placeholder to replace before distribution. + Interactive terminals can run `emulsify component create` with no arguments to walk through the component name, format, and directory prompts. Likewise, `emulsify component install` with no name presents the components available in @@ -56,6 +68,7 @@ the CLI exits with an actionable error instead of waiting for input: ```bash emulsify init "My Theme" ./web/themes/custom --platform drupal --yes +emulsify system create my-system --directory ./systems --platform none --git emulsify system install compound emulsify component install card --force # Or install every available component: @@ -91,7 +104,8 @@ Detailed documentation lives in [docs](./docs/README.md). | `emulsify init [name] [path]` | | Initializes an Emulsify project from a starter. | | `emulsify audit [...args]` | | Runs the project-installed Emulsify Core audit. | | `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | -| `emulsify system install [name]` | | Installs or scaffolds a system in the current Emulsify project. | +| `emulsify system create [name]` | | Creates a standalone component-system repository. | +| `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. | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index cd7d352..6f25581 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -17,7 +17,8 @@ The examples below reflect the command definitions in `src/index.ts` and the gen | `emulsify init [name] [path]` | | Initialize an Emulsify project from a starter. | | `emulsify audit [...args]` | | Run the project-installed Emulsify Core audit. | | `emulsify system list` | `emulsify system ls` | List built-in systems available for installation. | -| `emulsify system install [name]` | | Install or scaffold a system in the current Emulsify project. | +| `emulsify system create [name]` | | Create a standalone, distributable component system. | +| `emulsify system install [name]` | | Install a system in the current Emulsify project. | | `emulsify component list` | `emulsify component ls` | List components available from the installed system and variant. | | `emulsify component install [name]` | `emulsify component i [name]` | Install a component from the installed system and variant. | | `emulsify component create [name]` | `emulsify component c [name]` | Generate a new local component in the current project. | @@ -112,19 +113,91 @@ emulsify system ls Lists the built-in system names and repositories known to this CLI version. +## `system create` + +```bash +emulsify system create [name] +``` + +Creates a standalone component-system repository. It does not require an +Emulsify project and does not change `project.emulsify.json`. The supplied name +is normalized to a lowercase, hyphenated machine name. The target is the +normalized name beneath the parent passed to `--directory`; for example, +`"My System" --directory ./systems` creates `./systems/my-system`. + +Options: + +| Option | Description | +| -------------------------------------- | ------------------------------------------------------------------------------------ | +| `-d, --directory ` | Parent directory in which the normalized system directory is created. | +| `-p, --platform ` | Variant target: `none`, a concrete platform, or a compound compatibility expression. | +| `--git` | Initialize a Git repository in the generated system. | +| `--no-git` | Generate the system without initializing Git. | +| `--homepage ` | Override the homepage metadata written to `system.emulsify.json`. | +| `--repository ` | Override the repository metadata written to `system.emulsify.json`. | +| `-y, --yes` | Accept defaults for every missing prompt value. | + +In an interactive terminal, missing name, parent directory, platform expression, +and Git choice are prompted. In a non-interactive environment, provide them as +arguments and flags or use `--yes`; the command exits with an actionable error +instead of waiting for input. + +With `--yes`, missing values default to: + +- Name: `custom-system` +- Parent directory: `./` +- Platform expression: `none` +- Git initialization: enabled + +The generated homepage and repository metadata use placeholder example URLs +derived from the normalized name unless `--homepage` or `--repository` is +provided. Replace placeholders before publishing. + +The command refuses to overwrite an existing target. A successful scaffold +contains: + +```text +my-system/ +├── .gitignore +├── LICENSE +├── README.md +├── system.emulsify.json +└── components/ + └── example-card/ + ├── example-card.scss + ├── example-card.stories.js + ├── example-card.twig + └── example-card.yml +``` + +The generated `example-card` is marked as required, so installing the system +also proves that its component source layout is usable. `--git` additionally +creates the `.git/` metadata directory with `main` as the initial branch. + +Examples: + +```bash +emulsify system create +emulsify system create "My System" --directory ./systems --platform none --git +emulsify system create shared-system --directory ./systems --platform "drupal || wordpress" --no-git +emulsify system create my-system --yes +emulsify system create my-system --directory ./systems --platform drupal --git \ + --homepage https://design.example.com/my-system \ + --repository https://github.com/example/my-system.git +``` + ## `system install` ```bash emulsify system install [name] ``` -Run without `[name]` in an interactive terminal to choose from built-in systems, scaffold a new system definition, or cancel: +Run without `[name]` in an interactive terminal to choose from built-in systems or cancel: ```text ? Choose a component system: ❯ compound emulsify-ui-kit - create a new system cancel ``` @@ -132,7 +205,7 @@ Options: | Option | Description | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `-r, --repository ` | Install a system from a specific Git repository. Custom repository URLs must end in `.git`. | +| `-r, --repository ` | Install from a remote Git repository or a local repository path. Remote URLs must end in `.git`. | | `-c, --checkout ` | Checkout to use. This is required when `--repository` is used. | | `--variant ` | Install the variant whose platform expression exactly matches this value. Quote compound expressions at the shell. | | `-a, --all` | Install every component in the selected variant. Without this flag, only components marked `required: true` are installed during system install. | @@ -146,10 +219,9 @@ emulsify system install emulsify-ui-kit emulsify system install compound --all emulsify system install --repository https://github.com/example/example-system.git --checkout v1.0.0 emulsify system install --repository https://github.com/example/example-system.git --checkout v1.0.0 --variant wordpress +emulsify system install --repository /absolute/path/to/local-system --checkout v1.0.0 ``` -Selecting `create a new system` writes `system.emulsify.json` in the current Emulsify project root. Complete the generated system name, repository, structures, variants, and components before using it to install or generate components. - System variant compatibility is selected from each variant's `platform` expression. Examples: - `"platform": "wordpress"` matches WordPress projects. diff --git a/docs/systems.md b/docs/systems.md index 2bb7bdb..e81dd4b 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -32,7 +32,6 @@ In an interactive terminal, the CLI prompts for a system: ? Choose a component system: ❯ compound emulsify-ui-kit - create a new system cancel ``` @@ -78,53 +77,173 @@ emulsify system install \ --variant wordpress ``` -Custom system repository URLs must end in `.git`, because the CLI parses the system name from the repository filename. +Remote custom-system URLs must end in `.git`, because the CLI parses the system name from the repository filename. You can also pass an ordinary relative or absolute path to a local Git repository without a `.git` suffix: + +```bash +emulsify system install \ + --repository /absolute/path/to/example-system \ + --checkout v1.0.0 +``` Omit `--variant` to use automatic platform compatibility selection. Pass it to select an exact variant platform expression; quote shared expressions such as `--variant "drupal || wordpress"`. Prefer tags or commit hashes for `--checkout` so subsequent installs use the same system version. -## Create A New System Definition +## Author A Standalone System -Choose `create a new system` from the interactive prompt to scaffold a local `system.emulsify.json` in the current Emulsify project root. +`system create` generates a complete, distributable system repository. It is a standalone command: run it inside or outside an Emulsify project, and it will not read or update `project.emulsify.json`. -```text -Created system.emulsify.json. +```bash +emulsify system create [name] +``` + +In an interactive terminal, omit values to walk through prompts for the system name, target parent directory, platform targets, and Git initialization. Names are normalized to lowercase, hyphenated machine names. The `--directory` option is a parent directory, so this command creates `./systems/my-system`: + +```bash +emulsify system create "My System" \ + --directory ./systems \ + --platform "drupal || wordpress" \ + --git +``` + +Use `--homepage` and `--repository` to write real project metadata at creation time: + +```bash +emulsify system create my-system \ + --directory ./systems \ + --platform drupal \ + --git \ + --homepage https://design.example.org/my-system \ + --repository https://github.com/acme/my-system.git +``` + +Without those overrides, the metadata defaults to `https://example.com/` and `https://github.com/example/.git`. Replace these placeholders before publishing. The generated `LICENSE` is also a placeholder; choose a license appropriate for the system before distribution. + +Use `--no-git` instead of `--git` when another tool will initialize the repository. In non-interactive environments, supply the positional name, `--directory`, `--platform`, and either `--git` or `--no-git`, or use `--yes`. `--yes` supplies these defaults for anything omitted: + +| Value | Default | +| ------------------ | --------------- | +| Name | `custom-system` | +| Parent directory | `./` | +| Platform | `none` | +| Git initialization | Enabled | + +The command never merges into or overwrites an existing target. If the normalized target directory already exists, choose another name or parent directory. + +### Generated Repository Anatomy -Add your real system name, repository, structures, variants, and components before using this system to install or generate components. +The scaffold has a valid `system.emulsify.json`, repository guidance, and one real component that can be installed immediately: + +```text +my-system/ +├── .gitignore +├── LICENSE +├── README.md +├── system.emulsify.json +└── components/ + └── example-card/ + ├── example-card.scss + ├── example-card.stories.js + ├── example-card.twig + └── example-card.yml ``` -The scaffold is intentionally minimal and must be completed before it represents a real component system: +When Git initialization is enabled, `.git/` is also created with `main` as the initial branch. The generated configuration follows this shape: ```json { - "name": "custom-system", - "homepage": "https://example.com/custom-system", - "repository": "https://github.com/example/custom-system.git", + "name": "my-system", + "homepage": "https://example.com/my-system", + "repository": "https://github.com/example/my-system.git", "structure": [ { "name": "components", - "description": "Project component library" + "description": "Reusable components provided by this system" } ], "variants": [ { - "platform": "drupal", + "platform": "drupal || wordpress", "structureImplementations": [ { "name": "components", - "directory": "./src/components" + "directory": "components" } ], - "components": [] + "components": [ + { + "name": "example-card", + "structure": "components", + "description": "Example card included with the generated system", + "required": true + } + ] } ] } ``` -The generated variant platform follows the current project platform when it is `drupal`, `wordpress`, or `none`. If `system.emulsify.json` already exists, the CLI stops rather than overwrite it. +`structure` declares the system's logical component groups. Each variant's `structureImplementations` maps those groups to source directories in the repository. Here the `components` structure maps directly to `components`, so the `example-card` source resolves to `components/example-card`. Because the component is marked `required: true`, `system install` copies it without needing `--all`. + +The example provides Twig, Sass, YAML data, and Storybook story files. Customize or replace it, then keep the component entries and on-disk directories in sync as the library grows. + +### Choose Platform Targets + +Use `none` for a platform-neutral system, a concrete target such as `drupal` or `wordpress`, or a compatibility expression for a shared implementation: + +```bash +emulsify system create generic-system --directory ./systems --platform none --git +emulsify system create drupal-system --directory ./systems --platform drupal --git +emulsify system create shared-system --directory ./systems --platform "drupal || wordpress" --git +``` + +Quote expressions containing `||` so the shell passes the whole value to the CLI. The generated variant stores the normalized expression in `system.emulsify.json`; installation uses it when selecting a variant for the project's concrete platform. + +### Test A Scaffold Locally + +Commit and tag the generated repository before installing it. Local installs accept an ordinary filesystem path, so this workflow does not require a remote host: + +```bash +emulsify system create my-system \ + --directory /tmp/emulsify-systems \ + --platform none \ + --git +cd /tmp/emulsify-systems/my-system +git add . +git commit -m "feat: create component system" +git tag v0.1.0 + +cd /path/to/emulsify-project +emulsify system install \ + --repository /tmp/emulsify-systems/my-system \ + --checkout v0.1.0 +emulsify component list +``` + +Installing the scaffold records the system and selected variant in `project.emulsify.json` and installs its required `example-card` under the mapped `components` directory. This local round trip is a useful validation before publishing. + +### Publish And Install A Release + +Before publishing, replace the placeholder homepage, repository, license, and example content if you did not supply final values during creation. Commit the finished repository, create a stable tag, and push both to your Git host: + +```bash +git remote add origin https://github.com/acme/my-system.git +git add . +git commit -m "feat: publish initial system" +git tag v1.0.0 +git push -u origin HEAD +git push origin v1.0.0 +``` + +Consumers can install the tagged release from an Emulsify project. Remote custom repository URLs must end in `.git`: + +```bash +emulsify system install \ + --repository https://github.com/acme/my-system.git \ + --checkout v1.0.0 +``` -Creating a new system definition does not clone a remote system, install components, install general assets, run install hooks, or update `project.emulsify.json`. +Use immutable tags or commit hashes for published integrations. Create a new tag for later releases so consumers can choose when to upgrade. ## Project Config After Install diff --git a/src/handlers/systemCreate.test.ts b/src/handlers/systemCreate.test.ts new file mode 100644 index 0000000..97bbe6d --- /dev/null +++ b/src/handlers/systemCreate.test.ts @@ -0,0 +1,491 @@ +/** + * @file Unit tests for the system create handler. + */ + +jest.mock('../lib/log', () => jest.fn()); +jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); +jest.mock('../util/system/validateSystemConfig', () => jest.fn()); +jest.mock('@inquirer/prompts'); + +import type { CreateSystemHandlerOptions } from '@emulsify-cli/handlers'; + +import { checkbox, confirm, input } from '@inquirer/prompts'; +import { existsSync, promises as fs } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { simpleGit } from 'simple-git'; + +import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; +import log from '../lib/log.js'; +import writeToJsonFile from '../util/fs/writeToJsonFile.js'; +import buildSystemScaffold, { + type BuildSystemScaffoldOptions, +} from '../util/system/buildSystemScaffold.js'; +import validateSystemConfig from '../util/system/validateSystemConfig.js'; +import systemCreate, { normalizeSystemName } from './systemCreate.js'; + +const inputMock = input as jest.Mock; +const checkboxMock = checkbox as jest.Mock; +const confirmMock = confirm as jest.Mock; +const existsSyncMock = existsSync as jest.Mock; +const mkdirMock = fs.mkdir as jest.Mock; +const writeFileMock = fs.writeFile as jest.Mock; +const writeToJsonFileMock = writeToJsonFile as jest.Mock; +const validateSystemConfigMock = validateSystemConfig as jest.Mock; +const logMock = log as jest.Mock; +const simpleGitMock = simpleGit as jest.Mock; +const gitInitMock = simpleGit().init as jest.Mock; +const originalStdinIsTTY = process.stdin.isTTY; + +const parentDirectory = resolve('/systems'); +const explicitHomepage = 'https://design.example.com/acme-system'; +const explicitRepository = 'https://github.com/example-inc/acme-system.git'; + +const explicitOptions: CreateSystemHandlerOptions = { + directory: parentDirectory, + platform: 'drupal || wordpress', + git: false, + homepage: explicitHomepage, + repository: explicitRepository, +}; + +function setStdinIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + }); +} + +function expectedScaffold(overrides: Partial = {}) { + return buildSystemScaffold({ + name: 'acme-system', + platform: 'drupal || wordpress', + homepage: explicitHomepage, + repository: explicitRepository, + ...overrides, + }); +} + +function expectNoWrites(): void { + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(writeToJsonFileMock).not.toHaveBeenCalled(); + expect(simpleGitMock).not.toHaveBeenCalled(); +} + +describe('normalizeSystemName', () => { + it.each([ + [' Acme System ', 'acme-system'], + ['AcmeSystem', 'acme-system'], + ['acme_system', 'acme-system'], + ['acme--system', 'acme-system'], + ])('normalizes %j to %j', (name, expected) => { + expect(normalizeSystemName(name)).toBe(expected); + }); + + it.each(['', '!!', 'x!'])( + 'rejects a name without three machine-name characters: %j', + (name) => { + expect(() => normalizeSystemName(name)).toThrow( + 'System name must contain at least three letters or numbers. Pass the [name] positional argument or use --yes for the default.', + ); + }, + ); +}); + +describe('systemCreate', () => { + beforeEach(() => { + jest.clearAllMocks(); + setStdinIsTTY(false); + existsSyncMock.mockReturnValue(false); + mkdirMock.mockResolvedValue(undefined); + writeFileMock.mockResolvedValue(undefined); + writeToJsonFileMock.mockResolvedValue(undefined); + gitInitMock.mockResolvedValue(undefined); + validateSystemConfigMock.mockImplementation( + async (systemConfig: unknown) => ({ valid: true, systemConfig }), + ); + }); + + afterAll(() => { + setStdinIsTTY(originalStdinIsTTY); + }); + + it('creates the exact explicit scaffold without prompting and initializes Git', async () => { + const target = join(parentDirectory, 'acme-system'); + const scaffold = expectedScaffold(); + + await systemCreate('AcmeSystem', { + ...explicitOptions, + platform: ' drupal || wordpress || drupal ', + git: true, + }); + + expect(inputMock).not.toHaveBeenCalled(); + expect(checkboxMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(existsSyncMock).toHaveBeenCalledTimes(1); + expect(existsSyncMock).toHaveBeenCalledWith(target); + expect(validateSystemConfigMock).toHaveBeenCalledTimes(1); + expect(validateSystemConfigMock).toHaveBeenCalledWith( + scaffold.systemConfig, + ); + expect(mkdirMock).toHaveBeenCalledTimes(scaffold.files.length + 1); + expect(mkdirMock).toHaveBeenNthCalledWith(1, target, { + recursive: true, + }); + expect(writeToJsonFileMock).toHaveBeenCalledTimes(1); + expect(writeToJsonFileMock).toHaveBeenCalledWith( + join(target, EMULSIFY_SYSTEM_CONFIG_FILE), + scaffold.systemConfig, + ); + expect(writeFileMock).toHaveBeenCalledTimes(scaffold.files.length); + + for (const { path, contents } of scaffold.files) { + const destination = resolve(target, path); + expect(mkdirMock).toHaveBeenCalledWith(dirname(destination), { + recursive: true, + }); + expect(writeFileMock).toHaveBeenCalledWith(destination, contents, { + encoding: 'utf-8', + }); + } + + expect(simpleGitMock).toHaveBeenCalledTimes(1); + expect(simpleGitMock).toHaveBeenCalledWith(target); + expect(gitInitMock).toHaveBeenCalledTimes(1); + expect(gitInitMock).toHaveBeenCalledWith(false, { + '--initial-branch': 'main', + }); + expect(logMock).toHaveBeenNthCalledWith( + 1, + 'success', + `Created the acme-system system in ${target}.`, + ); + expect(logMock).toHaveBeenNthCalledWith( + 2, + 'info', + 'Git was initialized on branch main. Review the generated metadata, then commit the scaffold before installing it.', + ); + expect(logMock).toHaveBeenCalledTimes(2); + }); + + it('prompts for missing values in order and uses the selected values', async () => { + setStdinIsTTY(true); + inputMock + .mockResolvedValueOnce('Fancy_System') + .mockResolvedValueOnce('/interactive-systems'); + checkboxMock.mockResolvedValueOnce(['drupal', 'wordpress']); + confirmMock.mockResolvedValueOnce(true); + + await systemCreate(undefined); + + const namePrompt = inputMock.mock.calls[0][0]; + const directoryPrompt = inputMock.mock.calls[1][0]; + const platformPrompt = checkboxMock.mock.calls[0][0]; + + expect(inputMock).toHaveBeenCalledTimes(2); + expect(namePrompt).toMatchObject({ + message: 'System name:', + default: 'custom-system', + }); + expect(namePrompt.validate('ValidSystem')).toBe(true); + expect(namePrompt.validate('!!')).toBe( + 'System name must contain at least three letters or numbers. Pass the [name] positional argument or use --yes for the default.', + ); + expect( + namePrompt.validate({ + trim: () => { + throw 'unexpected validator failure'; + }, + }), + ).toBe('unexpected validator failure'); + expect(directoryPrompt).toMatchObject({ + message: 'Target directory:', + default: './', + }); + expect(directoryPrompt.validate('/tmp/systems')).toBe(true); + expect(directoryPrompt.validate(' ')).toBe( + 'Target directory cannot be empty.', + ); + expect(checkboxMock).toHaveBeenCalledTimes(1); + expect(platformPrompt).toMatchObject({ + message: 'Platform targets:', + choices: [ + { + name: 'Generic / no platform', + value: 'none', + checked: true, + }, + { name: 'Drupal', value: 'drupal' }, + { name: 'WordPress', value: 'wordpress' }, + ], + }); + expect(platformPrompt.validate(['drupal'])).toBe(true); + expect(platformPrompt.validate([])).toBe( + 'Select at least one platform target.', + ); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledWith({ + message: 'Initialize a Git repository?', + default: true, + }); + + expect(inputMock.mock.invocationCallOrder[0]).toBeLessThan( + inputMock.mock.invocationCallOrder[1], + ); + expect(inputMock.mock.invocationCallOrder[1]).toBeLessThan( + checkboxMock.mock.invocationCallOrder[0], + ); + expect(checkboxMock.mock.invocationCallOrder[0]).toBeLessThan( + confirmMock.mock.invocationCallOrder[0], + ); + + const target = join(resolve('/interactive-systems'), 'fancy-system'); + const scaffold = expectedScaffold({ + name: 'fancy-system', + homepage: 'https://example.com/fancy-system', + repository: 'https://github.com/example/fancy-system.git', + }); + expect(validateSystemConfigMock).toHaveBeenCalledWith( + scaffold.systemConfig, + ); + expect(writeToJsonFileMock).toHaveBeenCalledWith( + join(target, EMULSIFY_SYSTEM_CONFIG_FILE), + scaffold.systemConfig, + ); + expect(simpleGitMock).toHaveBeenCalledWith(target); + }); + + it('uses every default with --yes without consulting terminal state', async () => { + setStdinIsTTY(undefined); + const target = join(resolve('./'), 'custom-system'); + const scaffold = expectedScaffold({ + name: 'custom-system', + platform: 'none', + homepage: 'https://example.com/custom-system', + repository: 'https://github.com/example/custom-system.git', + }); + + await systemCreate(undefined, { yes: true }); + + expect(inputMock).not.toHaveBeenCalled(); + expect(checkboxMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(existsSyncMock).toHaveBeenCalledWith(target); + expect(writeToJsonFileMock).toHaveBeenCalledWith( + join(target, EMULSIFY_SYSTEM_CONFIG_FILE), + scaffold.systemConfig, + ); + expect(simpleGitMock).toHaveBeenCalledWith(target); + expect(gitInitMock).toHaveBeenCalledWith(false, { + '--initial-branch': 'main', + }); + }); + + it.each<{ + label: string; + name: string | undefined; + options: CreateSystemHandlerOptions; + message: string; + }>([ + { + label: 'name', + name: undefined, + options: { + directory: parentDirectory, + platform: 'none', + git: false, + }, + message: + 'System name is required in non-interactive mode. Pass the [name] positional argument or use --yes.', + }, + { + label: 'target directory', + name: 'acme-system', + options: { platform: 'none', git: false }, + message: + 'Target directory is required in non-interactive mode. Pass --directory or use --yes.', + }, + { + label: 'platform', + name: 'acme-system', + options: { directory: parentDirectory, git: false }, + message: + 'A platform target is required in non-interactive mode. Pass --platform or use --yes.', + }, + { + label: 'Git choice', + name: 'acme-system', + options: { directory: parentDirectory, platform: 'none' }, + message: + 'Git initialization choice is required in non-interactive mode. Pass --git or --no-git, or use --yes.', + }, + ])( + 'rejects a missing $label in a non-interactive terminal before writing', + async ({ name, options, message }) => { + await expect(systemCreate(name, options)).rejects.toMatchObject({ + name: 'CliError', + message, + exitCode: 1, + }); + + expect(inputMock).not.toHaveBeenCalled(); + expect(checkboxMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(existsSyncMock).not.toHaveBeenCalled(); + expect(validateSystemConfigMock).not.toHaveBeenCalled(); + expectNoWrites(); + }, + ); + + it('rejects an invalid positional name before checking or writing the target', async () => { + await expect(systemCreate('x!', explicitOptions)).rejects.toMatchObject({ + name: 'CliError', + message: + 'System name must contain at least three letters or numbers. Pass the [name] positional argument or use --yes for the default.', + exitCode: 1, + }); + + expect(existsSyncMock).not.toHaveBeenCalled(); + expect(validateSystemConfigMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + + it('rejects an unsupported platform expression before checking or writing the target', async () => { + await expect( + systemCreate('acme-system', { + ...explicitOptions, + platform: 'joomla', + }), + ).rejects.toMatchObject({ + name: 'CliError', + message: + 'Unsupported platform expression "joomla". Pass --platform with none, drupal, wordpress, or a supported expression such as "drupal || wordpress".', + exitCode: 1, + }); + + expect(existsSyncMock).not.toHaveBeenCalled(); + expect(validateSystemConfigMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + + it('rejects an existing target before validation or writes', async () => { + const target = join(parentDirectory, 'acme-system'); + existsSyncMock.mockReturnValueOnce(true); + + await expect( + systemCreate('acme-system', explicitOptions), + ).rejects.toMatchObject({ + name: 'CliError', + message: `The system target is already occupied: ${target}. Choose another parent directory with --directory.`, + exitCode: 1, + }); + + expect(existsSyncMock).toHaveBeenCalledWith(target); + expect(validateSystemConfigMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + + it.each([ + { + errors: [ + { instancePath: '/name', message: 'must match pattern' }, + { instancePath: '', message: undefined }, + ], + detail: '/name must match pattern; / is invalid', + }, + { + errors: undefined, + detail: 'unknown schema validation error', + }, + ])( + 'rejects a scaffold that fails schema validation: $detail', + async ({ errors, detail }) => { + validateSystemConfigMock.mockResolvedValueOnce({ + valid: false, + errors, + }); + + await expect( + systemCreate('acme-system', explicitOptions), + ).rejects.toMatchObject({ + name: 'CliError', + message: `Unable to create an invalid system scaffold: ${detail}`, + exitCode: 1, + }); + + expect(validateSystemConfigMock).toHaveBeenCalledTimes(1); + expectNoWrites(); + expect(logMock).not.toHaveBeenCalled(); + }, + ); + + it('wraps an artifact write failure and does not initialize Git or log success', async () => { + const target = join(parentDirectory, 'acme-system'); + writeFileMock.mockRejectedValueOnce(new Error('disk full')); + + await expect( + systemCreate('acme-system', { ...explicitOptions, git: true }), + ).rejects.toMatchObject({ + name: 'CliError', + message: `Unable to create the system in ${target}: disk full`, + exitCode: 1, + }); + + expect(writeToJsonFileMock).toHaveBeenCalledTimes(1); + expect(writeFileMock).toHaveBeenCalled(); + expect(simpleGitMock).not.toHaveBeenCalled(); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('wraps a system config write failure', async () => { + const target = join(parentDirectory, 'acme-system'); + writeToJsonFileMock.mockRejectedValueOnce( + new Error('configuration is read-only'), + ); + + await expect( + systemCreate('acme-system', explicitOptions), + ).rejects.toMatchObject({ + name: 'CliError', + message: `Unable to create the system in ${target}: configuration is read-only`, + exitCode: 1, + }); + + expect(simpleGitMock).not.toHaveBeenCalled(); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('skips Git and only logs creation when --no-git is selected', async () => { + const target = join(parentDirectory, 'acme-system'); + + await systemCreate('acme-system', explicitOptions); + + expect(simpleGitMock).not.toHaveBeenCalled(); + expect(gitInitMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + 'success', + `Created the acme-system system in ${target}.`, + ); + }); + + it('wraps Git initialization failure and does not log success', async () => { + const target = join(parentDirectory, 'acme-system'); + gitInitMock.mockRejectedValueOnce('git command failed'); + + await expect( + systemCreate('acme-system', { ...explicitOptions, git: true }), + ).rejects.toMatchObject({ + name: 'CliError', + message: `Unable to create the system in ${target}: git command failed`, + exitCode: 1, + }); + + expect(simpleGitMock).toHaveBeenCalledWith(target); + expect(gitInitMock).toHaveBeenCalledWith(false, { + '--initial-branch': 'main', + }); + expect(logMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/handlers/systemCreate.ts b/src/handlers/systemCreate.ts new file mode 100644 index 0000000..cf0ad6b --- /dev/null +++ b/src/handlers/systemCreate.ts @@ -0,0 +1,223 @@ +import type { Platform, PlatformExpression } from '@emulsify-cli/config'; +import type { CreateSystemHandlerOptions } from '@emulsify-cli/handlers'; +import type { ErrorObject } from 'ajv'; + +import { checkbox, confirm, input } from '@inquirer/prompts'; +import { existsSync, promises as fs } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { simpleGit } from 'simple-git'; + +import CliError from '../lib/CliError.js'; +import log from '../lib/log.js'; +import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; +import strToMachineName from '../util/strToMachineName.js'; +import safeResolveWithin from '../util/fs/safeResolveWithin.js'; +import writeToJsonFile from '../util/fs/writeToJsonFile.js'; +import { normalizePlatformExpression } from '../util/platform/platformCompatibility.js'; +import { runPrompt } from '../util/prompt/index.js'; +import buildSystemScaffold from '../util/system/buildSystemScaffold.js'; +import validateSystemConfig from '../util/system/validateSystemConfig.js'; + +const DEFAULT_SYSTEM_NAME = 'custom-system'; +const DEFAULT_TARGET_DIRECTORY = './'; +const DEFAULT_PLATFORM: Platform = 'none'; + +const PLATFORM_CHOICES: { name: string; value: Platform; checked?: boolean }[] = + [ + { + name: 'Generic / no platform', + value: 'none', + checked: true, + }, + { name: 'Drupal', value: 'drupal' }, + { name: 'WordPress', value: 'wordpress' }, + ]; + +/** + * Convert a human-readable system name into its repository/config identity. + */ +export function normalizeSystemName(name: string): string { + const trimmedName = name?.trim() || ''; + const machineName = strToMachineName( + trimmedName.replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' '), + ); + + if (machineName.length < 3) { + throw new CliError( + 'System name must contain at least three letters or numbers. Pass the [name] positional argument or use --yes for the default.', + ); + } + + return machineName; +} + +function validatePromptedSystemName(name: string): true | string { + try { + normalizeSystemName(name); + return true; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +function normalizeRequestedPlatform(platform: string): PlatformExpression { + try { + return normalizePlatformExpression(platform) as PlatformExpression; + } catch { + throw new CliError( + `Unsupported platform expression "${platform}". Pass --platform with none, drupal, wordpress, or a supported expression such as "drupal || wordpress".`, + ); + } +} + +function formatValidationErrors( + errors: ErrorObject[] | null | undefined, +): string { + return ( + errors + ?.map( + ({ instancePath, message }) => + `${instancePath || '/'} ${message || 'is invalid'}`, + ) + .join('; ') || 'unknown schema validation error' + ); +} + +/** + * Handler for `emulsify system create [name]`. + */ +export default async function systemCreate( + name: string | void, + options: CreateSystemHandlerOptions = {}, +): Promise { + const acceptDefaults = options.yes === true; + + let requestedName = name?.trim(); + if (!requestedName) { + requestedName = await runPrompt({ + prompt: () => + input({ + message: 'System name:', + default: DEFAULT_SYSTEM_NAME, + validate: validatePromptedSystemName, + }), + nonInteractive: { + error: + 'System name is required in non-interactive mode. Pass the [name] positional argument or use --yes.', + }, + accept: { when: acceptDefaults, value: DEFAULT_SYSTEM_NAME }, + }); + } + const systemName = normalizeSystemName(requestedName); + + let targetParent = options.directory?.trim(); + if (!targetParent) { + targetParent = await runPrompt({ + prompt: () => + input({ + message: 'Target directory:', + default: DEFAULT_TARGET_DIRECTORY, + validate: (value) => + value.trim().length > 0 || 'Target directory cannot be empty.', + }), + nonInteractive: { + error: + 'Target directory is required in non-interactive mode. Pass --directory or use --yes.', + }, + accept: { when: acceptDefaults, value: DEFAULT_TARGET_DIRECTORY }, + }); + } + + let platform = options.platform + ? normalizeRequestedPlatform(options.platform) + : undefined; + if (!platform) { + const platforms = await runPrompt({ + prompt: () => + checkbox({ + message: 'Platform targets:', + choices: PLATFORM_CHOICES, + validate: (values) => + values.length > 0 || 'Select at least one platform target.', + }), + nonInteractive: { + error: + 'A platform target is required in non-interactive mode. Pass --platform or use --yes.', + }, + accept: { when: acceptDefaults, value: [DEFAULT_PLATFORM] }, + }); + platform = normalizeRequestedPlatform(platforms.join(' || ')); + } + + let initializeGit = options.git; + if (initializeGit === undefined) { + initializeGit = await runPrompt({ + prompt: () => + confirm({ + message: 'Initialize a Git repository?', + default: true, + }), + nonInteractive: { + error: + 'Git initialization choice is required in non-interactive mode. Pass --git or --no-git, or use --yes.', + }, + accept: { when: acceptDefaults, value: true }, + }); + } + + const target = join(resolve(targetParent), systemName); + if (existsSync(target)) { + throw new CliError( + `The system target is already occupied: ${target}. Choose another parent directory with --directory.`, + ); + } + + const scaffold = buildSystemScaffold({ + name: systemName, + platform, + homepage: options.homepage || `https://example.com/${systemName}`, + repository: + options.repository || `https://github.com/example/${systemName}.git`, + }); + const validation = await validateSystemConfig(scaffold.systemConfig); + if (!validation.valid) { + throw new CliError( + `Unable to create an invalid system scaffold: ${formatValidationErrors(validation.errors)}`, + ); + } + + try { + await fs.mkdir(target, { recursive: true }); + await Promise.all([ + writeToJsonFile( + join(target, EMULSIFY_SYSTEM_CONFIG_FILE), + scaffold.systemConfig, + ), + ...scaffold.files.map(async ({ path, contents }) => { + const destination = safeResolveWithin( + target, + path, + 'System scaffold file', + ); + await fs.mkdir(dirname(destination), { recursive: true }); + await fs.writeFile(destination, contents, { encoding: 'utf-8' }); + }), + ]); + + if (initializeGit) { + await simpleGit(target).init(false, { '--initial-branch': 'main' }); + } + } catch (error) { + throw new CliError( + `Unable to create the system in ${target}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + log('success', `Created the ${systemName} system in ${target}.`); + if (initializeGit) { + log( + 'info', + 'Git was initialized on branch main. Review the generated metadata, then commit the scaffold before installing it.', + ); + } +} diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 60d7d88..f2ef2ae 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -177,6 +177,21 @@ describe('getSystemRepoInfo', () => { }); }); + it('returns repository information from a local path without a .git suffix', async () => { + const repository = resolve('/fixtures/custom-system'); + + await expect( + getSystemRepoInfo(undefined, { + repository, + checkout: 'main', + }), + ).resolves.toEqual({ + name: 'custom-system', + repository, + checkout: 'main', + }); + }); + it('throws invalid explicit repository URLs', async () => { await expect( getSystemRepoInfo(undefined, { @@ -1056,6 +1071,25 @@ describe('systemInstall', () => { }); }); + it('clones an explicit local repository path without a .git suffix', async () => { + const repository = resolve('/fixtures/custom-system'); + + await systemInstall(undefined, { + repository, + checkout: 'main', + }); + + expect(cloneIntoCacheMock).toHaveBeenCalledWith( + 'systems', + ['custom-system'], + { refresh: true }, + ); + expect(cloneSystemMock).toHaveBeenCalledWith({ + repository, + checkout: 'main', + }); + }); + it('uses a cached checkout when no checkout is available after installation', async () => { getRepositoryLatestTagMock.mockResolvedValueOnce(undefined); diff --git a/src/index.ts b/src/index.ts index 33f9c8a..3661ef6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import withProgressBar from './handlers/hofs/withProgressBar.js'; import init from './handlers/init.js'; import systemList from './handlers/systemList.js'; import systemInstall from './handlers/systemInstall.js'; +import systemCreate from './handlers/systemCreate.js'; import componentList from './handlers/componentList.js'; import componentInstall from './handlers/componentInstall.js'; import componentCreate from './handlers/componentCreate.js'; @@ -30,6 +31,7 @@ function getRootHelp(): string { ' emulsify --help', ' emulsify init [name] [path] [options]', ' emulsify audit [...args]', + ' emulsify system create [name] [options]', ' emulsify system install [name] [options]', ' emulsify component [options]', ' emulsify cache clear [options]', @@ -61,6 +63,17 @@ function getRootHelp(): string { ' system list', ' List built-in component systems available for installation. Alias: system ls.', '', + ' system create [name]', + ' Scaffold a standalone component-system repository. Missing values prompt in interactive terminals.', + ' Options:', + ' -d, --directory Parent directory for the new system repository.', + ' -p, --platform Platform targets for the first variant.', + ' --git Initialize a Git repository on branch main.', + ' --no-git Do not initialize a Git repository.', + ' --homepage Homepage URI for system.emulsify.json.', + ' --repository Repository URI for system.emulsify.json.', + ' -y, --yes Accept defaults for every missing value.', + '', ' system install [name]', ' Install a built-in or repository-backed component system. With no name or repository in', ' an interactive terminal, prompts for compound, emulsify-ui-kit, create a new system,', @@ -144,12 +157,32 @@ program // System sub-commands. const system = program .command('system') - .description('List, install, or scaffold component systems'); + .description('List, create, or install component systems'); system .command('list') .description('List built-in systems available for installation') .alias('ls') .action(systemList); +system + .command('create [name]') + .description('Scaffold a standalone component-system repository') + .option( + '-d --directory ', + 'Parent directory in which to create the new system repository.', + ) + .option( + '-p --platform ', + 'Platform compatibility expression for the first variant.', + ) + .option('--git', 'Initialize a Git repository on branch main.') + .option('--no-git', 'Do not initialize a Git repository.') + .option('--homepage ', 'Homepage URI for system.emulsify.json.') + .option('--repository ', 'Repository URI for system.emulsify.json.') + .option( + '-y --yes', + 'Accept defaults for all missing system scaffold values without prompting.', + ) + .action(systemCreate); system .command('install [name]') .description( diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 149fdc0..da2408d 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -24,6 +24,21 @@ declare module '@emulsify-cli/handlers' { all?: boolean; }; + export type CreateSystemHandlerOptions = { + /** Parent directory in which the standalone system repository is created. */ + directory?: string | void; + /** Platform compatibility expression for the scaffold's first variant. */ + platform?: PlatformExpression | string | void; + /** Whether to initialize the generated system as a Git repository. */ + git?: boolean; + /** Homepage URI written to the generated system configuration. */ + homepage?: string | void; + /** Repository URI written to the generated system configuration. */ + repository?: string | void; + /** Accept defaults for all missing system scaffold values. */ + yes?: boolean; + }; + export type ListComponentHandlerOptions = { /** Check the configured system's remote ref before reusing its local cache entry. */ refresh?: boolean; diff --git a/src/util/getGitRepoNameFromUrl.test.ts b/src/util/getGitRepoNameFromUrl.test.ts index bfef25a..7dfb1aa 100644 --- a/src/util/getGitRepoNameFromUrl.test.ts +++ b/src/util/getGitRepoNameFromUrl.test.ts @@ -1,4 +1,7 @@ import getGitRepoNameFromUrl from './getGitRepoNameFromUrl.js'; +import fs from 'fs'; +import { resolve } from 'path'; +import { pathToFileURL } from 'url'; describe('getGitRepoNameFromUrl', () => { it('can convert an ssl git url into a repo name', () => { @@ -17,13 +20,77 @@ describe('getGitRepoNameFromUrl', () => { ).toBe('emulsify-starter'); }); - it('can throw an Error if given an invalid git url', () => { + it('preserves dots in a remote repository name', () => { + expect.assertions(1); + + expect( + getGitRepoNameFromUrl( + 'https://github.com/emulsify-ds/example.system.git', + ), + ).toBe('example.system'); + }); + + it('can derive a repository name from local paths without a .git suffix', () => { + expect.assertions(4); + + expect(getGitRepoNameFromUrl('/tmp/example-system')).toBe('example-system'); + expect(getGitRepoNameFromUrl('./fixtures/example-system')).toBe( + 'example-system', + ); + expect(getGitRepoNameFromUrl('C:\\fixtures\\example-system')).toBe( + 'example-system', + ); + (fs.existsSync as jest.Mock).mockReturnValueOnce(true); + expect(getGitRepoNameFromUrl('src')).toBe('src'); + }); + + it('can derive a repository name from a local file url', () => { + expect.assertions(1); + + expect( + getGitRepoNameFromUrl( + pathToFileURL(resolve('fixtures/example-system')).href, + ), + ).toBe('example-system'); + }); + + it('strips a terminal .git suffix from local repository paths', () => { expect.assertions(2); + + expect(getGitRepoNameFromUrl('/tmp/example-system.git')).toBe( + 'example-system', + ); + expect(getGitRepoNameFromUrl('/tmp/.git')).toBeUndefined(); + }); + + it('returns nothing when a local file url cannot be parsed', () => { + expect.assertions(1); + + expect(getGitRepoNameFromUrl('file://%')).toBeUndefined(); + }); + + it('can throw an Error if given an invalid git url', () => { + expect.assertions(5); expect(() => { getGitRepoNameFromUrl(''); }).toThrow(Error); expect(() => { getGitRepoNameFromUrl('https://github.com/emulsify-ds/emulsify-starter'); }).toThrow(Error); + expect(() => { + getGitRepoNameFromUrl( + 'ssh://git@github.com/emulsify-ds/emulsify-starter', + ); + }).toThrow('The repository URL must end in .git.'); + expect(() => { + getGitRepoNameFromUrl( + 'https://github.com/emulsify-ds/emulsify-starter.git?ref=main', + ); + }).toThrow('The repository URL must end in .git.'); + expect(() => { + getGitRepoNameFromUrl( + 'https://github.com/emulsify-ds/emulsify-starter.git#main', + ); + }).toThrow('The repository URL must end in .git.'); }); }); diff --git a/src/util/getGitRepoNameFromUrl.ts b/src/util/getGitRepoNameFromUrl.ts index 212c188..1bfff6e 100644 --- a/src/util/getGitRepoNameFromUrl.ts +++ b/src/util/getGitRepoNameFromUrl.ts @@ -1,18 +1,72 @@ +import { existsSync } from 'fs'; +import { basename, isAbsolute, resolve, win32 } from 'path'; + +const fileUriPattern = /^file:\/\//i; +const explicitRelativePathPattern = /^\.{1,2}(?:[\\/]|$)/; +const uriPattern = /^[a-z][a-z\d+.-]*:\/\//i; +const scpLikePattern = /^(?:[^@/\\\s]+@)?[^:/\\\s]+:.+/; + +function isLocalRepository(repository: string): boolean { + if ( + fileUriPattern.test(repository) || + isAbsolute(repository) || + win32.isAbsolute(repository) || + explicitRelativePathPattern.test(repository) + ) { + return true; + } + + if (uriPattern.test(repository) || scpLikePattern.test(repository)) { + return false; + } + + return existsSync(repository); +} + +function getLocalRepositoryName(repository: string): string | void { + let name: string; + + if (fileUriPattern.test(repository)) { + try { + const pathname = decodeURIComponent(new URL(repository).pathname); + name = basename(pathname.replace(/[\\/]+$/, '')); + } catch { + return; + } + } else if (win32.isAbsolute(repository) || repository.includes('\\')) { + name = win32.basename(repository.replace(/[\\/]+$/, '')); + } else { + name = basename(resolve(repository)); + } + + return name.endsWith('.git') ? name.slice(0, -4) || undefined : name; +} + /** - * Helper function that takes a .git url (ssh or https) and returns the name - * of the repository contained within the url. + * Helper function that takes a Git URL or local repository path and returns + * the repository name. Remote URLs must retain the existing `.git` suffix; + * local paths and file URLs may omit it. * - * @param url git url from which a repo name should be extracted. + * @param url Git URL or local path from which a repo name should be extracted. * * @returns string repo name, or undefined if one cannot be parsed. */ export default function getGitRepoNameFromUrl(url: string): string | void { - const parts = url.split('/'); + const repository = url.trim(); + if (!repository) { + throw new Error('The repository URL must end in .git.'); + } + + if (isLocalRepository(repository)) { + return getLocalRepositoryName(repository); + } + + const parts = repository.split('/'); const gitName = parts.at(-1) as string; // If no .git extension is provided, then this is an invalid git url. - if (!gitName.includes('.git')) { + if (!gitName.endsWith('.git')) { throw new Error('The repository URL must end in .git.'); } - return gitName.split('.').at(0); + return gitName.slice(0, -4) || undefined; } diff --git a/src/util/system/buildSystemScaffold.test.ts b/src/util/system/buildSystemScaffold.test.ts new file mode 100644 index 0000000..9e166f0 --- /dev/null +++ b/src/util/system/buildSystemScaffold.test.ts @@ -0,0 +1,109 @@ +import { + buildScssTemplate, + buildStoriesTemplate, + buildTwigTemplate, + buildYmlTemplate, +} from '../project/componentTemplates/index.js'; +import validateSystemConfig from './validateSystemConfig.js'; +import buildSystemScaffold, { + buildSystemDefinition, + type BuildSystemScaffoldOptions, +} from './buildSystemScaffold.js'; + +const options: BuildSystemScaffoldOptions = { + name: 'acme-system', + platform: 'drupal || wordpress', + homepage: 'https://example.com/acme-system', + repository: 'https://github.com/example/acme-system.git', +}; + +describe('buildSystemScaffold', () => { + it('builds a schema-valid system definition with an installable example component', async () => { + const systemConfig = buildSystemDefinition(options); + + expect(systemConfig).toEqual({ + name: 'acme-system', + homepage: 'https://example.com/acme-system', + repository: 'https://github.com/example/acme-system.git', + structure: [ + { + name: 'components', + description: 'Reusable components provided by this system', + }, + ], + variants: [ + { + platform: 'drupal || wordpress', + structureImplementations: [ + { + name: 'components', + directory: 'components', + }, + ], + components: [ + { + name: 'example-card', + structure: 'components', + description: 'Example card included with the generated system', + required: true, + }, + ], + }, + ], + }); + await expect(validateSystemConfig(systemConfig)).resolves.toEqual({ + valid: true, + systemConfig, + }); + }); + + it('returns documentation, repository metadata, and standard component artifacts', () => { + const scaffold = buildSystemScaffold(options); + const files = Object.fromEntries( + scaffold.files.map(({ path, contents }) => [path, contents]), + ); + + expect(scaffold.systemConfig).toEqual(buildSystemDefinition(options)); + expect(Object.keys(files)).toEqual([ + 'README.md', + '.gitignore', + 'LICENSE', + 'components/example-card/example-card.twig', + 'components/example-card/example-card.scss', + 'components/example-card/example-card.yml', + 'components/example-card/example-card.stories.js', + ]); + expect(files['README.md']).toContain('# acme-system'); + expect(files['README.md']).toContain('targeting `drupal || wordpress`'); + expect(files['README.md']).toContain( + 'emulsify system install --repository https://github.com/example/acme-system.git --checkout ', + ); + expect(files['README.md']).toContain( + 'Homepage: https://example.com/acme-system', + ); + expect(files['.gitignore']).toBe('.DS_Store\nnode_modules/\n'); + expect(files['LICENSE']).toContain('replace this placeholder'); + expect(files['components/example-card/example-card.twig']).toBe( + buildTwigTemplate( + 'example-card', + 'example_card', + 'example-card', + 'DEFAULT', + ), + ); + expect(files['components/example-card/example-card.scss']).toBe( + buildScssTemplate('example-card', 'DEFAULT'), + ); + expect(files['components/example-card/example-card.yml']).toBe( + buildYmlTemplate('example_card', 'Example Card'), + ); + expect(files['components/example-card/example-card.stories.js']).toBe( + buildStoriesTemplate( + 'exampleCard', + 'example-card', + 'Example Card', + 'components', + ), + ); + }); +}); diff --git a/src/util/system/buildSystemScaffold.ts b/src/util/system/buildSystemScaffold.ts new file mode 100644 index 0000000..ea45c9d --- /dev/null +++ b/src/util/system/buildSystemScaffold.ts @@ -0,0 +1,161 @@ +import type { EmulsifySystem, PlatformExpression } from '@emulsify-cli/config'; +import { + buildScssTemplate, + buildStoriesTemplate, + buildTwigTemplate, + buildYmlTemplate, +} from '../project/componentTemplates/index.js'; + +const COMPONENT_STRUCTURE_NAME = 'components'; +const EXAMPLE_COMPONENT_NAME = 'example-card'; +const EXAMPLE_COMPONENT_CAMEL_NAME = 'exampleCard'; +const EXAMPLE_COMPONENT_SNAKE_NAME = 'example_card'; +const EXAMPLE_COMPONENT_HUMAN_NAME = 'Example Card'; +const DEFAULT_FORMAT_LABEL = 'DEFAULT'; + +export type BuildSystemScaffoldOptions = { + name: string; + platform: PlatformExpression; + homepage: string; + repository: string; +}; + +export type SystemScaffoldArtifact = { + path: string; + contents: string; +}; + +export type SystemScaffold = { + systemConfig: EmulsifySystem; + files: SystemScaffoldArtifact[]; +}; + +/** + * Build the system definition for a standalone generated system repository. + */ +export function buildSystemDefinition({ + name, + platform, + homepage, + repository, +}: BuildSystemScaffoldOptions): EmulsifySystem { + return { + name, + homepage, + repository, + structure: [ + { + name: COMPONENT_STRUCTURE_NAME, + description: 'Reusable components provided by this system', + }, + ], + variants: [ + { + platform, + structureImplementations: [ + { + name: COMPONENT_STRUCTURE_NAME, + directory: COMPONENT_STRUCTURE_NAME, + }, + ], + components: [ + { + name: EXAMPLE_COMPONENT_NAME, + structure: COMPONENT_STRUCTURE_NAME, + description: 'Example card included with the generated system', + required: true, + }, + ], + }, + ], + }; +} + +function buildReadme({ + name, + platform, + homepage, + repository, +}: BuildSystemScaffoldOptions): string { + return `# ${name} + +An [Emulsify](https://www.emulsify.info/) component system targeting \`${platform}\`. + +## Included component + +- \`example-card\`: a standard Emulsify component ready to customize or replace. + +## Use this system + +1. Replace the example metadata and component with your system's content. +2. Replace the placeholder in \`LICENSE\` with the license for this system. +3. Commit the repository and create a stable tag. +4. Install that tag from an Emulsify project: + + \`\`\`bash + emulsify system install --repository ${repository} --checkout + \`\`\` + +Homepage: ${homepage} +`; +} + +/** + * Build a complete, filesystem-independent scaffold for a system repository. + */ +export default function buildSystemScaffold( + options: BuildSystemScaffoldOptions, +): SystemScaffold { + const exampleComponentDirectory = `${COMPONENT_STRUCTURE_NAME}/${EXAMPLE_COMPONENT_NAME}`; + + return { + systemConfig: buildSystemDefinition(options), + files: [ + { + path: 'README.md', + contents: buildReadme(options), + }, + { + path: '.gitignore', + contents: '.DS_Store\nnode_modules/\n', + }, + { + path: 'LICENSE', + contents: + 'Choose a license for this system and replace this placeholder before distribution.\n', + }, + { + path: `${exampleComponentDirectory}/${EXAMPLE_COMPONENT_NAME}.twig`, + contents: buildTwigTemplate( + EXAMPLE_COMPONENT_NAME, + EXAMPLE_COMPONENT_SNAKE_NAME, + EXAMPLE_COMPONENT_NAME, + DEFAULT_FORMAT_LABEL, + ), + }, + { + path: `${exampleComponentDirectory}/${EXAMPLE_COMPONENT_NAME}.scss`, + contents: buildScssTemplate( + EXAMPLE_COMPONENT_NAME, + DEFAULT_FORMAT_LABEL, + ), + }, + { + path: `${exampleComponentDirectory}/${EXAMPLE_COMPONENT_NAME}.yml`, + contents: buildYmlTemplate( + EXAMPLE_COMPONENT_SNAKE_NAME, + EXAMPLE_COMPONENT_HUMAN_NAME, + ), + }, + { + path: `${exampleComponentDirectory}/${EXAMPLE_COMPONENT_NAME}.stories.js`, + contents: buildStoriesTemplate( + EXAMPLE_COMPONENT_CAMEL_NAME, + EXAMPLE_COMPONENT_NAME, + EXAMPLE_COMPONENT_HUMAN_NAME, + COMPONENT_STRUCTURE_NAME, + ), + }, + ], + }; +} diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 0d2dd6b..e350f84 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -278,6 +278,122 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); }); + test('fails fast when system create has no values outside a TTY', () => { + const result = runCli(tempRoot, ['system', 'create']); + + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + assert.match( + result.stderr, + /Pass the \[name\] positional argument or use --yes/, + ); + assert.equal(existsSync(join(tempRoot, 'custom-system')), false); + }); + + test('creates a standalone system and installs it from a local path', () => { + const systemName = 'round-trip-system'; + const generatedSystemRoot = join(tempRoot, systemName); + const generatedProjectRoot = join(projectsRoot, 'round-trip-project'); + const createResult = runCli(tempRoot, [ + 'system', + 'create', + 'Round Trip System', + '--directory', + tempRoot, + '--platform', + 'drupal || wordpress', + '--git', + ]); + + assert.equal( + createResult.status, + 0, + commandFailure('system create', createResult), + ); + assert.equal(createResult.stderr, ''); + assert.match(createResult.stdout, /Created the round-trip-system system/); + assert.equal(existsSync(join(generatedSystemRoot, '.git')), true); + assert.equal(existsSync(join(generatedSystemRoot, 'README.md')), true); + assert.equal(existsSync(join(generatedSystemRoot, '.gitignore')), true); + assert.equal(existsSync(join(generatedSystemRoot, 'LICENSE')), true); + + const systemConfig = JSON.parse( + readFileSync(join(generatedSystemRoot, 'system.emulsify.json'), 'utf8'), + ); + assert.equal(systemConfig.name, systemName); + assert.equal(systemConfig.variants[0].platform, 'drupal || wordpress'); + assert.deepEqual(systemConfig.variants[0].components, [ + { + name: 'example-card', + structure: 'components', + description: 'Example card included with the generated system', + required: true, + }, + ]); + + git(generatedSystemRoot, ['add', '.']); + git(generatedSystemRoot, [ + '-c', + 'user.email=e2e@example.test', + '-c', + 'user.name=Emulsify E2E', + 'commit', + '-m', + 'test: commit generated system', + ]); + + const initResult = runCli(tempRoot, [ + 'init', + 'Round Trip Project', + projectsRoot, + '--machineName', + 'round-trip-project', + '--starter', + starterRepository, + '--checkout', + 'main', + '--platform', + 'wordpress', + '--yes', + ]); + assert.equal( + initResult.status, + 0, + commandFailure('round-trip init', initResult), + ); + + const installResult = runCli(generatedProjectRoot, [ + 'system', + 'install', + '--repository', + generatedSystemRoot, + '--checkout', + 'main', + ]); + assert.equal( + installResult.status, + 0, + commandFailure('round-trip system install', installResult), + ); + assert.match( + installResult.stdout, + /Successfully installed the round-trip-system system using the drupal \|\| wordpress variant/, + ); + + const installedComponentRoot = join( + generatedProjectRoot, + 'components', + 'example-card', + ); + for (const extension of ['twig', 'scss', 'yml', 'stories.js']) { + assert.equal( + existsSync(join(installedComponentRoot, `example-card.${extension}`)), + true, + `generated example-card.${extension} should install`, + ); + } + }); + test('initializes a WordPress project with starter hook metadata', () => { const result = runCli(tempRoot, [ 'init', diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index a3a4c18..7e22727 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -8,6 +8,7 @@ Usage: emulsify --help emulsify init [name] [path] [options] emulsify audit [...args] + emulsify system create [name] [options] emulsify system install [name] [options] emulsify component [options] emulsify cache clear [options] @@ -39,6 +40,17 @@ Commands: system list List built-in component systems available for installation. Alias: system ls. + system create [name] + Scaffold a standalone component-system repository. Missing values prompt in interactive terminals. + Options: + -d, --directory Parent directory for the new system repository. + -p, --platform Platform targets for the first variant. + --git Initialize a Git repository on branch main. + --no-git Do not initialize a Git repository. + --homepage Homepage URI for system.emulsify.json. + --repository Repository URI for system.emulsify.json. + -y, --yes Accept defaults for every missing value. + system install [name] Install a built-in or repository-backed component system. With no name or repository in an interactive terminal, prompts for compound, emulsify-ui-kit, create a new system, From 8430746a7a9af77e10817c7f9c3ac4b0a31d6dcb Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 01:36:10 -0500 Subject: [PATCH 03/33] refactor(system): delegate custom system scaffolding to system create --- src/handlers/systemInstall.test.ts | 132 +---------------------------- src/handlers/systemInstall.ts | 78 +---------------- src/index.ts | 11 +-- test/e2e/root-help.txt | 5 +- 4 files changed, 10 insertions(+), 216 deletions(-) diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index f2ef2ae..312245e 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -14,19 +14,17 @@ jest.mock('../util/project/setEmulsifyConfig', () => jest.fn()); jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); jest.mock('../util/fs/executeScript', () => jest.fn()); -jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('@inquirer/prompts'); import fs from 'fs'; import { join, resolve } from 'path'; -import type { EmulsifySystem, Platform } from '@emulsify-cli/config'; +import type { EmulsifySystem } from '@emulsify-cli/config'; import { select } from '@inquirer/prompts'; import log from '../lib/log.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_PROJECT_HOOK_FOLDER, EMULSIFY_PROJECT_HOOK_SYSTEM_INSTALL, - EMULSIFY_SYSTEM_CONFIG_FILE, } from '../lib/constants.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; @@ -39,7 +37,6 @@ import setEmulsifyConfig from '../util/project/setEmulsifyConfig.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import executeScript from '../util/fs/executeScript.js'; -import writeToJsonFile from '../util/fs/writeToJsonFile.js'; import systemInstall, { getSystemRepoInfo } from './systemInstall.js'; const logMock = log as jest.Mock; @@ -56,13 +53,11 @@ const setEmulsifyConfigMock = setEmulsifyConfig as jest.Mock; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; const executeScriptMock = executeScript as jest.Mock; -const writeToJsonFileMock = writeToJsonFile as jest.Mock; const existsSyncMock = fs.existsSync as jest.Mock; const selectMock = select as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; const projectRoot = resolve('/project'); const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); -const systemConfigPath = join(projectRoot, EMULSIFY_SYSTEM_CONFIG_FILE); const systemInstallHookPath = join( projectRoot, EMULSIFY_PROJECT_HOOK_FOLDER, @@ -132,32 +127,6 @@ const availableSystems = [ }, ]; -function customSystemDefinition(platform: Platform): EmulsifySystem { - return { - name: 'custom-system', - homepage: 'https://example.com/custom-system', - repository: 'https://github.com/example/custom-system.git', - structure: [ - { - name: 'components', - description: 'Project component library', - }, - ], - variants: [ - { - platform, - structureImplementations: [ - { - name: 'components', - directory: './src/components', - }, - ], - components: [], - }, - ], - }; -} - describe('getSystemRepoInfo', () => { beforeEach(() => { jest.clearAllMocks(); @@ -264,7 +233,6 @@ describe('systemInstall', () => { getJsonFromCachedFileMock.mockResolvedValue(system); setEmulsifyConfigMock.mockResolvedValue(undefined); getEmulsifyConfigMock.mockResolvedValue(projectConfig); - writeToJsonFileMock.mockResolvedValue(undefined); // A found project config plus an existing hook covers the optional hook branch. findFileInCurrentPathMock.mockReturnValue(projectConfigPath); existsSyncMock.mockReturnValue(true); @@ -297,7 +265,7 @@ describe('systemInstall', () => { ); }); - it('prompts with built-in systems plus create and cancel when no system is provided in an interactive terminal', async () => { + it('prompts with built-in systems and cancel when no system is provided in an interactive terminal', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce('cancel'); @@ -306,7 +274,7 @@ describe('systemInstall', () => { expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenNthCalledWith(1, { message: 'Choose a component system:', - choices: ['compound', 'emulsify-ui-kit', 'create a new system', 'cancel'], + choices: ['compound', 'emulsify-ui-kit', 'cancel'], }); }); @@ -354,100 +322,6 @@ describe('systemInstall', () => { expect(installGeneralAssetsFromCacheMock).not.toHaveBeenCalled(); }); - it('writes a custom system definition when create a new system is selected', async () => { - setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('create a new system'); - findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); - existsSyncMock.mockReturnValueOnce(false); - - await systemInstall(undefined, {}); - - expect(writeToJsonFileMock).toHaveBeenCalledWith( - systemConfigPath, - customSystemDefinition('drupal'), - ); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Created system.emulsify.json.', - ); - expect(logMock).toHaveBeenCalledWith( - 'info', - 'Add your real system name, repository, structures, variants, and components before using this system to install or generate components.', - ); - }); - - it('uses the current none platform in the custom system definition', async () => { - setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('create a new system'); - getEmulsifyConfigMock.mockResolvedValueOnce({ - ...projectConfig, - project: { - ...projectConfig.project, - platform: 'none', - }, - }); - findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); - existsSyncMock.mockReturnValueOnce(false); - - await systemInstall(undefined, {}); - - expect(writeToJsonFileMock).toHaveBeenCalledWith( - systemConfigPath, - customSystemDefinition('none'), - ); - }); - - it('uses the current wordpress platform in the custom system definition', async () => { - setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('create a new system'); - getEmulsifyConfigMock.mockResolvedValueOnce({ - ...projectConfig, - project: { - ...projectConfig.project, - platform: 'wordpress', - }, - }); - findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); - existsSyncMock.mockReturnValueOnce(false); - - await systemInstall(undefined, {}); - - expect(writeToJsonFileMock).toHaveBeenCalledWith( - systemConfigPath, - customSystemDefinition('wordpress'), - ); - }); - - it('does not overwrite an existing custom system definition', async () => { - setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('create a new system'); - findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); - existsSyncMock.mockReturnValueOnce(true); - - await expect(systemInstall(undefined, {})).rejects.toThrow( - 'system.emulsify.json already exists. Remove or rename it before creating a new custom system definition.', - ); - - expect(writeToJsonFileMock).not.toHaveBeenCalled(); - }); - - it('does not run remote install side effects when creating a custom system definition', async () => { - setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('create a new system'); - findFileInCurrentPathMock.mockReturnValueOnce(projectConfigPath); - existsSyncMock.mockReturnValueOnce(false); - - await systemInstall(undefined, {}); - - expect(cloneIntoCacheMock).not.toHaveBeenCalled(); - expect(cloneSystemMock).not.toHaveBeenCalled(); - expect(getJsonFromCachedFileMock).not.toHaveBeenCalled(); - expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); - expect(installComponentFromCacheMock).not.toHaveBeenCalled(); - expect(installGeneralAssetsFromCacheMock).not.toHaveBeenCalled(); - expect(executeScriptMock).not.toHaveBeenCalled(); - }); - it('does not prompt when a system name is provided', async () => { setStdinIsTTY(true); diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 847b4e2..35669c5 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -1,10 +1,6 @@ import type { InstallSystemHandlerOptions } from '@emulsify-cli/handlers'; import type { GitCloneOptions } from '@emulsify-cli/git'; -import type { - EmulsifyProjectConfiguration, - EmulsifySystem, - Platform, -} from '@emulsify-cli/config'; +import type { EmulsifySystem, Platform } from '@emulsify-cli/config'; import { dirname, join } from 'path'; import { existsSync } from 'fs'; @@ -29,7 +25,6 @@ import setEmulsifyConfig from '../util/project/setEmulsifyConfig.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import executeScript from '../util/fs/executeScript.js'; -import writeToJsonFile from '../util/fs/writeToJsonFile.js'; import validateSystemConfig from '../util/system/validateSystemConfig.js'; import { getVariantPlatformExpressions, @@ -39,7 +34,6 @@ import { } from '../util/platform/platformCompatibility.js'; import { runPrompt } from '../util/prompt/index.js'; -const CREATE_NEW_SYSTEM_CHOICE = 'create a new system'; const CANCEL_SYSTEM_INSTALL_CHOICE = 'cancel'; const SYSTEM_INSTALL_ERROR = 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.'; @@ -112,7 +106,6 @@ async function promptForSystemInstallChoice(): Promise { message: 'Choose a component system:', choices: [ ...availableSystems.map(({ name }) => name), - CREATE_NEW_SYSTEM_CHOICE, CANCEL_SYSTEM_INSTALL_CHOICE, ], }); @@ -220,70 +213,6 @@ async function resolveSystemVariant( ); } -function getCustomSystemPlatform(platform: string): Platform { - return isPlatform(platform) ? platform : 'none'; -} - -function buildCustomSystemDefinition(platform: Platform): EmulsifySystem { - return { - name: 'custom-system', - homepage: 'https://example.com/custom-system', - repository: 'https://github.com/example/custom-system.git', - structure: [ - { - name: 'components', - description: 'Project component library', - }, - ], - variants: [ - { - platform, - structureImplementations: [ - { - name: 'components', - directory: './src/components', - }, - ], - components: [], - }, - ], - }; -} - -async function scaffoldCustomSystemDefinition( - projectConfig: EmulsifyProjectConfiguration, -): Promise { - const projectConfigPath = findFileInCurrentPath(EMULSIFY_PROJECT_CONFIG_FILE); - if (!projectConfigPath) { - throw new CliError( - `Unable to find ${EMULSIFY_PROJECT_CONFIG_FILE}. Run this command from within an Emulsify project.`, - ); - } - - const systemConfigPath = join( - dirname(projectConfigPath), - EMULSIFY_SYSTEM_CONFIG_FILE, - ); - if (existsSync(systemConfigPath)) { - throw new CliError( - `${EMULSIFY_SYSTEM_CONFIG_FILE} already exists. Remove or rename it before creating a new custom system definition.`, - ); - } - - await writeToJsonFile( - systemConfigPath, - buildCustomSystemDefinition( - getCustomSystemPlatform(projectConfig.project.platform), - ), - ); - - log('success', `Created ${EMULSIFY_SYSTEM_CONFIG_FILE}.`); - log( - 'info', - 'Add your real system name, repository, structures, variants, and components before using this system to install or generate components.', - ); -} - /** * Handler for the `system install` command. * @@ -320,11 +249,6 @@ export default async function systemInstall( if (!selectedName) { return; } - - if (selectedName === CREATE_NEW_SYSTEM_CHOICE) { - await scaffoldCustomSystemDefinition(projectConfig); - return; - } } const repo = await getSystemRepoInfo(selectedName, options); diff --git a/src/index.ts b/src/index.ts index 3661ef6..5c631b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,10 +76,9 @@ function getRootHelp(): string { '', ' system install [name]', ' Install a built-in or repository-backed component system. With no name or repository in', - ' an interactive terminal, prompts for compound, emulsify-ui-kit, create a new system,', - ' or cancel.', + ' an interactive terminal, prompts for compound, emulsify-ui-kit, or cancel.', ' Options:', - ' -r, --repository Install from a custom system repository ending in .git.', + ' -r, --repository Install from a remote .git URL or local repository path.', ' -c, --checkout Checkout to use with --repository.', ' --variant Select an exact variant platform expression.', ' -a, --all Install every component in the selected variant.', @@ -185,12 +184,10 @@ system .action(systemCreate); system .command('install [name]') - .description( - 'Install a component system, prompt for a system, or scaffold a local system definition', - ) + .description('Install a component system or prompt for a built-in system') .option( '-r --repository ', - 'Git repository containing the system to install. Custom repository URLs must end in .git.', + 'Git repository containing the system to install. Remote URLs must end in .git; local paths are accepted.', ) .option( '-c --checkout ', diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 7e22727..2308001 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -53,10 +53,9 @@ Commands: system install [name] Install a built-in or repository-backed component system. With no name or repository in - an interactive terminal, prompts for compound, emulsify-ui-kit, create a new system, - or cancel. + an interactive terminal, prompts for compound, emulsify-ui-kit, or cancel. Options: - -r, --repository Install from a custom system repository ending in .git. + -r, --repository Install from a remote .git URL or local repository path. -c, --checkout Checkout to use with --repository. --variant Select an exact variant platform expression. -a, --all Install every component in the selected variant. From 534679288b79aa1715eb422f9de184681f21154a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 02:14:56 -0500 Subject: [PATCH 04/33] feat(system): guide system installation with a review step --- docs/cli-reference.md | 76 ++- docs/systems.md | 105 ++- src/handlers/systemInstall.test.ts | 634 +++++++++++++++++- src/handlers/systemInstall.ts | 592 +++++++++++++--- src/index.ts | 11 +- src/types/handlers.d.ts | 2 + src/types/internal.d.ts | 5 + .../platform/platformCompatibility.test.ts | 123 ++++ src/util/platform/platformCompatibility.ts | 58 +- .../system/buildSystemInstallPlan.test.ts | 326 +++++++++ src/util/system/buildSystemInstallPlan.ts | 119 ++++ src/util/system/getAvailableSystems.test.ts | 4 + src/util/system/getAvailableSystems.ts | 4 + test/e2e/cli.test.mjs | 39 ++ test/e2e/root-help.txt | 5 +- 15 files changed, 1961 insertions(+), 142 deletions(-) create mode 100644 src/util/system/buildSystemInstallPlan.test.ts create mode 100644 src/util/system/buildSystemInstallPlan.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6f25581..3c1b47f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -192,15 +192,66 @@ emulsify system create my-system --directory ./systems --platform drupal --git \ emulsify system install [name] ``` -Run without `[name]` in an interactive terminal to choose from built-in systems or cancel: +Run without a name or repository in an interactive terminal to start the guided +installer. The built-in path covers four decisions: source, component set, +installation scope, and final review. + +The source picker presents human-readable system names and descriptions: ```text -? Choose a component system: -❯ compound - emulsify-ui-kit - cancel +Which system? +❯ Compound Accessible, tested components. Drupal, WordPress, plain. + Emulsify UI Kit Broader design-system starter kit. + Bring your own Install from a git repository you control. + ──────────── + Cancel +``` + +After the selected system is downloaded and validated, the component-set picker +shows each variant in plain language with its raw platform expression in +parentheses. Compatible choices are ordered first, and the best match for the +current project is marked `Recommended` and selected by default. Each choice +also shows its total component count and how many are essential. + +The scope picker offers: + +- `Essentials only` — install only components marked `required: true`. +- `Everything` — install every component in the selected component set. + +Both choices include their component counts. The review then shows the selected +system and checkout, repository source, component set, scope, component and +asset counts, and their concrete destination paths. The CLI asks +for confirmation before changing `project.emulsify.json`, copying components or +assets, or running the project install hook. The selected repository may already +have been downloaded into the CLI cache so its configuration can be reviewed. + +```text +Install a component system Step 4 of 4 + + System Compound · v2.3.1 + Source github.com/emulsify-ds/compound + Component set Drupal + Scope Essentials only + Will install 5 components → components/ + 2 asset folders → assets/, src/vendor/ + +? Install now? (Y/n) ``` +Pass `-y, --yes` to display this final review and accept it without opening the +confirmation prompt. It does not choose a missing source, component set, or +installation scope. + +Selecting `Bring your own` adds prompts for a repository URL or local path and a +checkout (branch, tag, or commit); the displayed step total expands to include +them. Selecting `Cancel`, or declining the final review, reports `System install +cancelled.` and leaves the project configuration and destinations unchanged. + +Supplying a built-in name or both custom-repository flags bypasses the guided +installer and preserves the direct command behavior. Variant compatibility is +resolved automatically unless `--variant` is supplied, and only essential +components are installed unless `--all` is supplied. + Options: | Option | Description | @@ -209,11 +260,26 @@ Options: | `-c, --checkout ` | Checkout to use. This is required when `--repository` is used. | | `--variant ` | Install the variant whose platform expression exactly matches this value. Quote compound expressions at the shell. | | `-a, --all` | Install every component in the selected variant. Without this flag, only components marked `required: true` are installed during system install. | +| `-y, --yes` | Accept the final guided-install review without prompting. It does not supply any earlier wizard choice. | + +Prompts are never opened when standard input is not a TTY. A bare command fails +immediately with this guidance: + +```text +No component system source was provided. Pass a built-in system name as the positional argument, or pass both --repository and --checkout . +``` + +For scripts and CI, pass a built-in name or pass both `--repository` and +`--checkout`. Supplying only one custom-repository option also fails immediately +and names the missing flag. `--yes` does not make a bare command +non-interactive: the source, component set, and scope still require choices. Examples: ```bash emulsify system install +# Interactive wizard with no final confirmation prompt: +emulsify system install --yes emulsify system install compound emulsify system install emulsify-ui-kit emulsify system install compound --all diff --git a/docs/systems.md b/docs/systems.md index e81dd4b..39cc9c7 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -26,21 +26,69 @@ Run `system install` from inside an Emulsify project. emulsify system install ``` -In an interactive terminal, the CLI prompts for a system: +With no name or repository options in an interactive terminal, the CLI opens a +guided installer. Its source picker includes the two built-in systems, a custom +source, and a safe exit: ```text -? Choose a component system: -❯ compound - emulsify-ui-kit - cancel +Which system? +❯ Compound Accessible, tested components. Drupal, WordPress, plain. + Emulsify UI Kit Broader design-system starter kit. + Bring your own Install from a git repository you control. + ──────────── + Cancel ``` -Choosing `compound` or `emulsify-ui-kit` installs that built-in system. Choosing `cancel` exits without changing files: +The built-in path has four decisions: + +1. **System/source.** Choose Compound, Emulsify UI Kit, or another repository. +2. **Component set.** Choose a system variant. The CLI displays a plain-language + label and the raw platform expression, puts compatible choices first, and + marks the best match for the current project as `Recommended`. Component and + essential counts appear on every choice. +3. **Installation scope.** Choose `Essentials only` to install components marked + `required: true`, or `Everything` to install every component. Both choices + show how many components they install. +4. **Review.** Check the system and checkout, repository source, component set, + selected scope, component and asset counts, and their concrete destination + paths before confirming. + +The repository is downloaded and its configuration validated before the +component-set and review screens can be built. This may populate the isolated +Emulsify cache, but the CLI does not update `project.emulsify.json`, copy project +files, or run the project install hook until the final review is confirmed. + +The final screen makes those project changes concrete before asking for +confirmation: + +```text +Install a component system Step 4 of 4 + + System Compound · v2.3.1 + Source github.com/emulsify-ds/compound + Component set Drupal + Scope Essentials only + Will install 5 components → components/ + 2 asset folders → assets/, src/vendor/ + +? Install now? (Y/n) +``` + +Use `-y, --yes` to render and accept that final review without opening the +confirmation prompt. It supplies no earlier answer: source, component set, and +scope choices are still required, so the guided installer still needs a TTY. + +Choosing `Cancel` at the source picker, or declining the final review, exits +without changing project configuration or destinations: ```text System install cancelled. ``` +Choosing `Bring your own` inserts two additional steps for the repository URL or +local path and the checkout (branch, tag, or commit). The wizard's displayed +step total expands for this path rather than continuing to say four steps. + For a built-in system, the command: 1. Finds and validates the nearest `project.emulsify.json`. @@ -48,10 +96,11 @@ For a built-in system, the command: 3. Checks out the latest Git tag when the built-in system reference does not specify a checkout. 4. Clones the system into the local Emulsify cache. 5. Reads and validates `system.emulsify.json` from the cached system. -6. Selects the best compatible variant for `project.platform`, or the exact expression passed with `--variant`. -7. Writes `system` and `variant` entries into `project.emulsify.json`. -8. Installs components marked `required: true`. -9. Installs variant-level general files and directories. +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. +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. If you already know the system name, pass it directly: @@ -60,6 +109,10 @@ emulsify system install compound 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. + Use `--all` to install every component in the selected variant during system installation: ```bash @@ -68,7 +121,9 @@ emulsify system install compound --all ## Install A Custom System -Use `--repository` and `--checkout` together. +Choose `Bring your own` in the guided installer to be prompted for the +repository and checkout, or use `--repository` and `--checkout` together for a +direct install. ```bash emulsify system install \ @@ -89,6 +144,34 @@ Omit `--variant` to use automatic platform compatibility selection. Pass it to s Prefer tags or commit hashes for `--checkout` so subsequent installs use the same system version. +## Non-Interactive Installation + +Every prompt is gated behind an interactive TTY. Bare `system install` fails +immediately in CI, when piped, or when standard input is redirected, with this +actionable message: + +```text +No component system source was provided. Pass a built-in system name as the positional argument, or pass both --repository and --checkout . +``` + +Use a positional built-in name or provide both custom source flags: + +```bash +emulsify system install compound +emulsify system install compound --variant drupal --all +emulsify system install \ + --repository https://github.com/example/example-system.git \ + --checkout v1.0.0 \ + --variant wordpress +``` + +Providing `--repository` without `--checkout`, or `--checkout` without +`--repository`, exits non-zero and identifies the missing flag. Explicit source +commands do not open the guided review: compatible component-set selection and +the essentials-only default remain deterministic unless `--variant` or `--all` +is passed. Because `--yes` only accepts the final guided review, it does not +supply a missing source or make bare `system install --yes` valid outside a TTY. + ## Author A Standalone System `system create` generates a complete, distributable system repository. It is a standalone command: run it inside or outside an Emulsify project, and it will not read or update `project.emulsify.json`. diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 312245e..4329d1e 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -7,7 +7,11 @@ jest.mock('../util/system/getAvailableSystems', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); jest.mock('../util/cache/getCachedItemCheckout', () => jest.fn()); jest.mock('../util/getRepositoryLatestTag', () => jest.fn()); -jest.mock('../util/project/installComponentFromCache', () => jest.fn()); +jest.mock('../util/project/installComponentFromCache', () => ({ + __esModule: true, + ...jest.requireActual('../util/project/installComponentFromCache'), + default: jest.fn(), +})); jest.mock('../util/project/installGeneralAssetsFromCache', () => jest.fn()); jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/project/setEmulsifyConfig', () => jest.fn()); @@ -18,8 +22,8 @@ jest.mock('@inquirer/prompts'); import fs from 'fs'; import { join, resolve } from 'path'; -import type { EmulsifySystem } from '@emulsify-cli/config'; -import { select } from '@inquirer/prompts'; +import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; +import { confirm, input, select, Separator } from '@inquirer/prompts'; import log from '../lib/log.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, @@ -37,7 +41,10 @@ import setEmulsifyConfig from '../util/project/setEmulsifyConfig.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import executeScript from '../util/fs/executeScript.js'; -import systemInstall, { getSystemRepoInfo } from './systemInstall.js'; +import systemInstall, { + formatSystemInstallReview, + getSystemRepoInfo, +} from './systemInstall.js'; const logMock = log as jest.Mock; const getAvailableSystemsMock = getAvailableSystems as jest.Mock; @@ -54,7 +61,10 @@ const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; const executeScriptMock = executeScript as jest.Mock; const existsSyncMock = fs.existsSync as jest.Mock; +const confirmMock = confirm as jest.Mock; +const inputMock = input as jest.Mock; const selectMock = select as jest.Mock; +const separatorMock = Separator as unknown as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; const projectRoot = resolve('/project'); const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); @@ -82,7 +92,7 @@ const projectConfig = { }, }; -const variant = { +const variant: EmulsifyVariant = { platform: 'drupal', structureImplementations: [ { @@ -119,14 +129,53 @@ const system = { const availableSystems = [ { name: 'compound', + label: 'Compound', + description: 'Accessible, tested components. Drupal, WordPress, plain.', repository: 'https://github.com/emulsify-ds/compound.git', + platforms: ['none', 'drupal', 'wordpress'], }, { name: 'emulsify-ui-kit', + label: 'Emulsify UI Kit', + description: 'Broader design-system starter kit.', repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', + platforms: ['none', 'drupal', 'wordpress'], }, ]; +const builtInSource = { + kind: 'built-in' as const, + reference: availableSystems[0], +}; +const customSource = { kind: 'custom' as const }; +const cancelSource = { kind: 'cancel' as const }; + +function wizardHeader(step: number, total?: number): string { + return `${'Install a component system'.padEnd(60)}${ + total ? `Step ${step} of ${total}` : `Step ${step}` + }`; +} + +function formatChoice(label: string, description: string): string { + return `${label.padEnd(22)}${description}`; +} + +function queueBuiltInWizard({ + variantIndex = 0, + installAll = false, + confirmed = true, +}: { + variantIndex?: number; + installAll?: boolean; + confirmed?: boolean; +} = {}): void { + selectMock + .mockResolvedValueOnce(builtInSource) + .mockResolvedValueOnce(variantIndex) + .mockResolvedValueOnce(installAll); + confirmMock.mockResolvedValueOnce(confirmed); +} + describe('getSystemRepoInfo', () => { beforeEach(() => { jest.clearAllMocks(); @@ -218,9 +267,65 @@ describe('getSystemRepoInfo', () => { }); }); +describe('formatSystemInstallReview', () => { + it('formats a local source, omitted checkout, zero components, and plural asset destinations', () => { + expect( + formatSystemInstallReview( + 'Local System', + '/fixtures/local-system.git', + undefined, + { ...variant, platform: 'none' }, + { + components: [], + requiredComponentCount: 0, + totalComponentCount: 2, + componentParentDestinations: [], + directoryAssetDestinations: ['assets/fonts/', 'assets/images/'], + fileAssetDestinations: [], + directoryAssetCount: 2, + fileAssetCount: 0, + totalAssetCount: 2, + }, + false, + ), + ).toBe(` System Local System + Source /fixtures/local-system + Component set Platform-neutral + Scope Essentials only + Will install 0 components → none + 2 asset folders → assets/fonts/, assets/images/`); + }); + + it('preserves a root destination marker when formatting component directories', () => { + expect( + formatSystemInstallReview( + 'Local System', + '/fixtures/local-system', + 'main', + variant, + { + components: [variant.components[0]], + requiredComponentCount: 1, + totalComponentCount: 2, + componentParentDestinations: ['.'], + directoryAssetDestinations: [], + fileAssetDestinations: [], + directoryAssetCount: 0, + fileAssetCount: 0, + totalAssetCount: 0, + }, + true, + ), + ).toContain('Will install 1 component → .'); + }); +}); + describe('systemInstall', () => { beforeEach(() => { jest.clearAllMocks(); + confirmMock.mockReset(); + inputMock.mockReset(); + selectMock.mockReset(); setStdinIsTTY(false); // The handler clones systems through a higher-order cache helper. cloneIntoCacheMock.mockReturnValue(cloneSystemMock); @@ -261,29 +366,109 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'You have already selected a system within this Emulsify project.', + 'This Emulsify project already has a component system configured. Run "emulsify component list" to see what is available. To choose a different system, remove the existing "system" and "variant" entries from project.emulsify.json, then run "emulsify system install" again.', ); }); - it('prompts with built-in systems and cancel when no system is provided in an interactive terminal', async () => { + it('renders the step-one catalog, separator, and Cancel choice', async () => { setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('cancel'); + selectMock.mockResolvedValueOnce(cancelSource); await systemInstall(undefined, {}); expect(selectMock).toHaveBeenCalledTimes(1); - expect(selectMock).toHaveBeenNthCalledWith(1, { - message: 'Choose a component system:', - choices: ['compound', 'emulsify-ui-kit', 'cancel'], + expect(selectMock).toHaveBeenCalledWith({ + message: 'Which system?', + choices: [ + { + name: formatChoice( + 'Compound', + 'Accessible, tested components. Drupal, WordPress, plain.', + ), + value: builtInSource, + short: 'Compound', + }, + { + name: formatChoice( + 'Emulsify UI Kit', + 'Broader design-system starter kit.', + ), + value: { + kind: 'built-in', + reference: availableSystems[1], + }, + short: 'Emulsify UI Kit', + }, + { + name: formatChoice( + 'Bring your own', + 'Install from a git repository you control.', + ), + value: customSource, + short: 'Bring your own', + }, + expect.any(Separator), + { + name: 'Cancel', + value: cancelSource, + }, + ], }); + expect(separatorMock).toHaveBeenCalledWith('────────────'); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(1)); + expect(logMock).toHaveBeenCalledWith('info', 'System install cancelled.'); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); }); - it('installs the selected built-in system from the interactive prompt', async () => { + it('walks a built-in system through all four guided steps and reviews Essentials', async () => { + const guidedVariant = { + ...variant, + directories: [ + { + name: 'fonts', + path: 'assets/fonts', + destinationPath: 'assets/fonts', + }, + ], + files: [ + { + name: 'tokens', + path: 'assets/tokens.css', + destinationPath: 'styles/tokens.css', + }, + ], + } as EmulsifyVariant; + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [guidedVariant], + }); setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('compound'); + queueBuiltInWizard(); await systemInstall(undefined, {}); + expect(selectMock).toHaveBeenCalledTimes(3); + expect(selectMock).toHaveBeenNthCalledWith(3, { + message: 'How much do you want to install?', + choices: [ + { + name: formatChoice('Essentials only', '1 required component'), + value: false, + short: 'Essentials only', + }, + { + name: formatChoice('Everything', '2 components'), + value: true, + short: 'Everything', + }, + ], + default: false, + }); + expect(confirmMock).toHaveBeenCalledWith({ + message: 'Install now?', + default: true, + }); expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { refresh: true, }); @@ -301,25 +486,238 @@ describe('systemInstall', () => { structureImplementations: variant.structureImplementations, }, }); + expect(installComponentFromCacheMock).toHaveBeenCalledTimes(1); + expect(installComponentFromCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'compound' }), + guidedVariant, + 'button', + true, + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'Loading Compound from github.com/emulsify-ds/compound. This may take a moment…', + ); + expect(logMock).toHaveBeenCalledWith('info', 'Loaded Compound · v1.0.0.'); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(1)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(2, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(3, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(4, 4)); + expect(logMock).toHaveBeenCalledWith( + 'info', + `\n System Compound · v1.0.0 + Source github.com/emulsify-ds/compound + Component set Drupal + Scope Essentials only + Will install 1 component → components/00-base/ + 1 asset folder → assets/fonts/ + 1 asset file → styles/tokens.css\n`, + ); + expect(confirmMock.mock.invocationCallOrder[0]).toBeLessThan( + setEmulsifyConfigMock.mock.invocationCallOrder[0], + ); expect(logMock).toHaveBeenCalledWith( 'success', - 'Successfully installed the compound system using the drupal variant.', + 'Successfully installed the Compound system using the Drupal component set.', ); }); - it('cancels the interactive prompt without modifying files', async () => { + it('declines the final review without mutating project files', async () => { setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('cancel'); + queueBuiltInWizard({ confirmed: false }); await systemInstall(undefined, {}); - expect(logMock).toHaveBeenCalledWith('info', 'System install cancelled.'); - expect(cloneIntoCacheMock).not.toHaveBeenCalled(); - expect(cloneSystemMock).not.toHaveBeenCalled(); - expect(getJsonFromCachedFileMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).toHaveBeenCalled(); + expect(getJsonFromCachedFileMock).toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'System install cancelled. No project files were changed.', + ); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); + expect(installComponentFromCacheMock).not.toHaveBeenCalled(); + expect(installGeneralAssetsFromCacheMock).not.toHaveBeenCalled(); + expect(executeScriptMock).not.toHaveBeenCalled(); + }); + + it('accepts the final guided review with --yes without prompting for confirmation', async () => { + setStdinIsTTY(true); + queueBuiltInWizard(); + + await systemInstall(undefined, { yes: true }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(4, 4)); + expect(setEmulsifyConfigMock).toHaveBeenCalled(); + expect(installComponentFromCacheMock).toHaveBeenCalledWith( + system, + variant, + 'button', + true, + ); + }); + + it('walks a bring-your-own repository through all six guided steps and installs Everything', async () => { + const repository = 'git@github.com:example/custom-system.git'; + const customSystem = { + ...system, + name: 'custom-system', + repository: 'https://github.com/example/custom-system.git', + } as EmulsifySystem; + getJsonFromCachedFileMock.mockResolvedValueOnce(customSystem); + setStdinIsTTY(true); + selectMock + .mockResolvedValueOnce(customSource) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(true); + inputMock + .mockResolvedValueOnce(` ${repository} `) + .mockResolvedValueOnce(' release '); + confirmMock.mockResolvedValueOnce(true); + + await systemInstall(undefined, {}); + + expect(inputMock).toHaveBeenNthCalledWith(1, { + message: 'Repository URL or local path:', + validate: expect.any(Function), + }); + expect(inputMock).toHaveBeenNthCalledWith(2, { + message: 'Checkout (branch, tag, or commit):', + validate: expect.any(Function), + }); + const repositoryValidator = inputMock.mock.calls[0][0].validate; + expect( + repositoryValidator('https://github.com/example/custom-system.git'), + ).toBe(true); + expect( + repositoryValidator('https://github.com/example/custom-system'), + ).toBe('The repository URL must end in .git.'); + expect(repositoryValidator('https://github.com/example/.git')).toBe( + 'Enter a Git repository with a recognizable name.', + ); + const checkoutValidator = inputMock.mock.calls[1][0].validate; + expect(checkoutValidator(' ')).toBe('Enter a branch, tag, or commit.'); + expect(checkoutValidator('main')).toBe(true); + expect(cloneIntoCacheMock).toHaveBeenCalledWith( + 'systems', + ['custom-system'], + { refresh: true }, + ); + expect(cloneSystemMock).toHaveBeenCalledWith({ + repository, + checkout: 'release', + }); + expect(installComponentFromCacheMock).toHaveBeenCalledTimes(2); + expect(installComponentFromCacheMock).toHaveBeenNthCalledWith( + 2, + customSystem, + variant, + 'card', + true, + ); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(1)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(2, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(3, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(4, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(5, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(6, 6)); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'Loading the component system from github.com/example/custom-system. This may take a moment…', + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'Loaded Custom System · release.', + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + `\n System Custom System · release + Source github.com/example/custom-system + Component set Drupal + Scope Everything + Will install 2 components → components/00-base/\n`, + ); + }); + + it('rejects a repository and declared system name mismatch before review or project mutation', async () => { + const repository = 'https://github.com/example/custom-system.git'; + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + name: 'declared-system', + }); + setStdinIsTTY(true); + selectMock.mockResolvedValueOnce(customSource); + inputMock.mockResolvedValueOnce(repository).mockResolvedValueOnce('main'); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'The repository was cached as "custom-system", but system.emulsify.json declares the system name "declared-system". These names must match so files can be installed safely. Rename the repository or update the system name, then retry.', + ); + + expect(cloneSystemMock).toHaveBeenCalledWith({ + repository, + checkout: 'main', + }); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(findFileInCurrentPathMock).not.toHaveBeenCalled(); expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); expect(installComponentFromCacheMock).not.toHaveBeenCalled(); expect(installGeneralAssetsFromCacheMock).not.toHaveBeenCalled(); + expect(executeScriptMock).not.toHaveBeenCalled(); + }); + + it('orders guided component sets by recommendation and defaults to the best match', async () => { + const genericVariant = { ...variant, platform: 'none' }; + const sharedVariant = { + ...variant, + platform: 'drupal || wordpress', + }; + const exactVariant = { ...variant, platform: 'drupal' }; + const incompatibleVariant = { ...variant, platform: 'wordpress' }; + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [ + genericVariant, + sharedVariant, + exactVariant, + incompatibleVariant, + ], + }); + setStdinIsTTY(true); + selectMock + .mockResolvedValueOnce(builtInSource) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(false); + confirmMock.mockResolvedValueOnce(false); + + await systemInstall(undefined, {}); + + expect(selectMock).toHaveBeenNthCalledWith(2, { + message: 'Which component set?', + choices: [ + { + name: 'Drupal (drupal) — Recommended · 2 components, 1 required component', + value: 2, + short: 'Drupal', + }, + { + name: 'Drupal and WordPress (drupal || wordpress) · 2 components, 1 required component', + value: 1, + short: 'Drupal and WordPress', + }, + { + name: 'Platform-neutral (none) · 2 components, 1 required component', + value: 0, + short: 'Platform-neutral', + }, + { + name: 'WordPress (wordpress) · 2 components, 1 required component', + value: 3, + short: 'WordPress', + }, + ], + default: 2, + }); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); }); it('does not prompt when a system name is provided', async () => { @@ -363,8 +761,145 @@ describe('systemInstall', () => { it('throws a helpful error in non-interactive mode when no system is provided', async () => { await expect(systemInstall(undefined, {})).rejects.toThrow( - 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.', + 'No component system source was provided. Pass a built-in system name as the positional argument, or pass both --repository and --checkout .', + ); + + expect(selectMock).not.toHaveBeenCalled(); + expect(getAvailableSystemsMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + }); + + describe('guided prompt TTY guards', () => { + it('rejects the custom repository prompt after stdin stops being interactive', async () => { + setStdinIsTTY(true); + selectMock.mockImplementationOnce(async () => { + setStdinIsTTY(false); + return customSource; + }); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'A custom repository is required in non-interactive mode. Pass --repository .', + ); + + expect(inputMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + }); + + it('rejects the custom checkout prompt after stdin stops being interactive', async () => { + setStdinIsTTY(true); + selectMock.mockResolvedValueOnce(customSource); + inputMock.mockImplementationOnce(async () => { + setStdinIsTTY(false); + return 'https://github.com/example/custom-system.git'; + }); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'A custom checkout is required in non-interactive mode. Pass --checkout .', + ); + + expect(inputMock).toHaveBeenCalledTimes(1); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + }); + + it('rejects the component-set prompt after stdin stops being interactive', async () => { + setStdinIsTTY(true); + selectMock.mockImplementationOnce(async () => { + setStdinIsTTY(false); + return builtInSource; + }); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'A component set choice is required in non-interactive mode. Pass --variant . Available component sets: Drupal (drupal).', + ); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(cloneIntoCacheMock).toHaveBeenCalled(); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); + }); + + it('rejects the install-scope prompt after stdin stops being interactive', async () => { + setStdinIsTTY(true); + selectMock + .mockResolvedValueOnce(builtInSource) + .mockImplementationOnce(async () => { + setStdinIsTTY(false); + return 0; + }); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'Install scope is required in non-interactive mode. Pass --all to install every component, or provide a system name to install required components only.', + ); + + expect(selectMock).toHaveBeenCalledTimes(2); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); + }); + + it('rejects final confirmation after stdin stops being interactive', async () => { + setStdinIsTTY(true); + selectMock + .mockResolvedValueOnce(builtInSource) + .mockResolvedValueOnce(0) + .mockImplementationOnce(async () => { + setStdinIsTTY(false); + return false; + }); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'Installation confirmation is required in non-interactive mode. Pass --yes to accept the reviewed installation.', + ); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); + expect(installComponentFromCacheMock).not.toHaveBeenCalled(); + expect(installGeneralAssetsFromCacheMock).not.toHaveBeenCalled(); + }); + }); + + it('fails a guided install before prompting when no component set is compatible', async () => { + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [{ ...variant, platform: 'wordpress' }], + }); + setStdinIsTTY(true); + selectMock.mockResolvedValueOnce(builtInSource); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'The Compound system has no component set that works with this Drupal project. Available component sets: WordPress (wordpress). Pass --variant to choose one explicitly.', + ); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); + }); + + it('guards the guided review when the project configuration path cannot be found', async () => { + setStdinIsTTY(true); + queueBuiltInWizard(); + findFileInCurrentPathMock.mockReturnValueOnce(undefined); + + await expect(systemInstall(undefined, {})).rejects.toThrow( + 'Unable to find the Emulsify project configuration for the installation review.', ); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); + expect(installComponentFromCacheMock).not.toHaveBeenCalled(); + }); + + it('uses explicit variant, all, and yes options to shorten a guided install to two steps', async () => { + setStdinIsTTY(true); + selectMock.mockResolvedValueOnce(builtInSource); + + await systemInstall(undefined, { + variant: 'drupal', + all: true, + yes: true, + }); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith('info', wizardHeader(2, 2)); + expect(installComponentFromCacheMock).toHaveBeenCalledTimes(2); + expect(setEmulsifyConfigMock).toHaveBeenCalled(); }); it('throws when no variant can be determined', async () => { @@ -377,7 +912,7 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'Unable to determine a variant for the specified system. Please either pass in a valid variant using the --variant flag.', + 'This project does not declare a supported platform. Set project.platform in project.emulsify.json to none, drupal, or wordpress before installing a component system.', ); }); @@ -405,7 +940,7 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'The system install failed due to the validation errors reported above. Please fix the the errors in the "compound" configuration and try again.', + 'The system install failed due to the validation errors reported above. Please fix the errors in the "compound" configuration and try again.', ); expect(consoleErrorMock).toHaveBeenCalledWith( @@ -436,7 +971,7 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'The system install failed due to the validation errors reported above. Please fix the the errors in the "compound" configuration and try again.', + 'The system install failed due to the validation errors reported above. Please fix the errors in the "compound" configuration and try again.', ); expect(consoleErrorMock).toHaveBeenCalled(); @@ -448,8 +983,23 @@ describe('systemInstall', () => { await expect( systemInstall('compound', { variant: 'none' }), ).rejects.toThrow( - 'Unable to find a compatible variant matching "none" for project platform "drupal" within the system (compound). Available variant platform expressions: drupal.', + 'The Compound system has no component set matching --variant "none". Available component sets: Drupal (drupal).', + ); + }); + + it('rejects duplicate exact component sets instead of opening an unnumbered prompt', async () => { + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + variants: [variant, { ...variant }], + }); + + await expect( + systemInstall('compound', { variant: 'drupal' }), + ).rejects.toThrow( + 'The Compound system defines more than one component set for --variant "drupal". Ask the system maintainer to give each component set a unique platform expression.', ); + expect(selectMock).not.toHaveBeenCalled(); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); }); it('throws when the system has no variants', async () => { @@ -459,7 +1009,7 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'Unable to find a compatible variant for project platform "drupal" within the system (compound). Available variant platform expressions: none.', + 'The Compound system has no component set that works with this Drupal project. Available component sets: none. Pass --variant to choose one explicitly.', ); }); @@ -751,13 +1301,13 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'Multiple compatible variants were found for project platform "none" within the system (compound): drupal, wordpress. Run this command in an interactive terminal or specify a variant.', + 'More than one Compound component set works equally well with this Platform-neutral project: Drupal (drupal), WordPress (wordpress). Run this command in an interactive terminal, or pass --variant .', ); }); it('prompts when a none project platform matches multiple variants in an interactive terminal', async () => { setStdinIsTTY(true); - selectMock.mockResolvedValueOnce('wordpress'); + selectMock.mockResolvedValueOnce(1); getEmulsifyConfigMock.mockResolvedValueOnce({ ...projectConfig, project: { @@ -782,8 +1332,20 @@ describe('systemInstall', () => { await systemInstall('compound', {}); expect(selectMock).toHaveBeenCalledWith({ - message: 'Choose a compound variant for project platform "none":', - choices: ['drupal', 'wordpress'], + message: 'Which Compound component set should be used?', + choices: [ + { + name: 'Drupal (drupal) — Recommended · 2 components, 1 required component', + value: 0, + short: 'Drupal', + }, + { + name: 'WordPress (wordpress) — Recommended · 2 components, 1 required component', + value: 1, + short: 'WordPress', + }, + ], + default: 0, }); expect(setEmulsifyConfigMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -882,7 +1444,7 @@ describe('systemInstall', () => { it('throws when no matching system repository is found', async () => { await expect(systemInstall('missing', {})).rejects.toThrow( - 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.', + 'Unable to resolve the requested component system source. Pass a valid built-in system name as the positional argument, or pass both --repository and --checkout .', ); }); @@ -893,7 +1455,7 @@ describe('systemInstall', () => { checkout: 'main', }), ).rejects.toThrow( - 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.', + 'Unable to resolve the requested component system source. Pass a valid built-in system name as the positional argument, or pass both --repository and --checkout .', ); }); @@ -927,6 +1489,10 @@ describe('systemInstall', () => { it('uses explicit repository options when provided', async () => { setStdinIsTTY(true); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + name: 'custom-system', + }); await systemInstall(undefined, { repository: 'https://github.com/example/custom-system.git', @@ -947,6 +1513,10 @@ describe('systemInstall', () => { it('clones an explicit local repository path without a .git suffix', async () => { const repository = resolve('/fixtures/custom-system'); + getJsonFromCachedFileMock.mockResolvedValueOnce({ + ...system, + name: 'custom-system', + }); await systemInstall(undefined, { repository, diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 35669c5..b01929e 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -1,10 +1,15 @@ import type { InstallSystemHandlerOptions } from '@emulsify-cli/handlers'; import type { GitCloneOptions } from '@emulsify-cli/git'; -import type { EmulsifySystem, Platform } from '@emulsify-cli/config'; +import type { + EmulsifySystem, + EmulsifyVariant, + Platform, +} from '@emulsify-cli/config'; +import type { EmulsifySystemReference } from '@emulsify-cli/internal'; import { dirname, join } from 'path'; import { existsSync } from 'fs'; -import { select } from '@inquirer/prompts'; +import { confirm, input, select, Separator } from '@inquirer/prompts'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_SYSTEM_CONFIG_FILE, @@ -29,14 +34,111 @@ import validateSystemConfig from '../util/system/validateSystemConfig.js'; import { getVariantPlatformExpressions, isPlatform, + parsePlatformExpression, + rankPlatformVariants, selectCompatiblePlatformVariant, selectExactPlatformVariant, } from '../util/platform/platformCompatibility.js'; import { runPrompt } from '../util/prompt/index.js'; +import buildSystemInstallPlan, { + type SystemInstallPlan, +} from '../util/system/buildSystemInstallPlan.js'; + +const MISSING_SYSTEM_SOURCE_ERROR = + 'No component system source was provided. Pass a built-in system name as the positional argument, or pass both --repository and --checkout .'; +const INVALID_SYSTEM_SOURCE_ERROR = + 'Unable to resolve the requested component system source. Pass a valid built-in system name as the positional argument, or pass both --repository and --checkout .'; +const WIZARD_TITLE = 'Install a component system'; + +type BuiltInSourceChoice = { + kind: 'built-in'; + reference: EmulsifySystemReference; +}; + +type CustomSourceChoice = { + kind: 'custom'; +}; + +type CancelSourceChoice = { + kind: 'cancel'; +}; + +type SystemSourceChoice = + BuiltInSourceChoice | CustomSourceChoice | CancelSourceChoice; + +function formatWizardHeader(step: number, total?: number): string { + const progress = total ? `Step ${step} of ${total}` : `Step ${step}`; + return `${WIZARD_TITLE.padEnd(60)}${progress}`; +} + +function showWizardStep(step: number, total?: number): void { + log('info', formatWizardHeader(step, total)); +} + +function formatChoice(label: string, description: string): string { + return `${label.padEnd(22)}${description}`; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function formatSystemLabel(name: string): string { + return name + .split(/[-_]/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function formatPlatformLabel(expression: string): string { + const platforms = parsePlatformExpression(expression).map((platform) => { + if (platform === 'none') { + return 'Platform-neutral'; + } + if (platform === 'drupal') { + return 'Drupal'; + } + return 'WordPress'; + }); + + if (platforms.length <= 1) { + return platforms[0] || expression; + } + + return `${platforms.slice(0, -1).join(', ')} and ${platforms.at(-1)}`; +} + +function formatRepositorySource(repository: string): string { + try { + const url = new URL(repository); + if (url.protocol === 'file:') { + return decodeURIComponent(url.pathname).replace(/\.git\/?$/, ''); + } + if (url.host) { + return `${url.host}${url.pathname}`.replace(/\.git\/?$/, ''); + } + } catch { + // Local paths and SCP-style Git URLs are formatted below. + } + + const scpStyle = repository.match(/^(?:[^@\s]+@)?([^:]+):(.+)$/); + if (scpStyle) { + return `${scpStyle[1]}/${scpStyle[2]}`.replace(/\.git\/?$/, ''); + } -const CANCEL_SYSTEM_INSTALL_CHOICE = 'cancel'; -const SYSTEM_INSTALL_ERROR = - 'Unable to download specified system. Specify a valid built-in system name as the positional argument, or provide both --repository and --checkout (branch, tag, or commit) for a custom system.'; + return repository.replace(/\.git\/?$/, ''); +} + +function validateRepositoryInput(repository: string): true | string { + try { + return getGitRepoNameFromUrl(repository.trim()) + ? true + : 'Enter a Git repository with a recognizable name.'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} /** * Helper function that uses InstallSystemHandlerOptions input to determine what @@ -98,27 +200,85 @@ export async function getSystemRepoInfo( } } -async function promptForSystemInstallChoice(): Promise { - const selectedSystem = await runPrompt({ +async function promptForSystemInstallChoice(): Promise< + BuiltInSourceChoice | CustomSourceChoice | void +> { + const selectedSource = await runPrompt({ prompt: async () => { const availableSystems = await getAvailableSystems(); - return select({ - message: 'Choose a component system:', + showWizardStep(1); + return select({ + message: 'Which system?', choices: [ - ...availableSystems.map(({ name }) => name), - CANCEL_SYSTEM_INSTALL_CHOICE, + ...availableSystems.map((reference) => ({ + name: formatChoice(reference.label, reference.description), + value: { kind: 'built-in', reference } as BuiltInSourceChoice, + short: reference.label, + })), + { + name: formatChoice( + 'Bring your own', + 'Install from a git repository you control.', + ), + value: { kind: 'custom' } as CustomSourceChoice, + short: 'Bring your own', + }, + new Separator('────────────'), + { + name: 'Cancel', + value: { kind: 'cancel' } as CancelSourceChoice, + }, ], }); }, - nonInteractive: { error: SYSTEM_INSTALL_ERROR }, + nonInteractive: { error: MISSING_SYSTEM_SOURCE_ERROR }, }); - if (selectedSystem === CANCEL_SYSTEM_INSTALL_CHOICE) { + if (selectedSource.kind === 'cancel') { log('info', 'System install cancelled.'); return; } - return selectedSystem; + return selectedSource; +} + +async function promptForCustomRepository( + step: number, + total: number, +): Promise { + return runPrompt({ + prompt: () => { + showWizardStep(step, total); + return input({ + message: 'Repository URL or local path:', + validate: validateRepositoryInput, + }); + }, + nonInteractive: { + error: + 'A custom repository is required in non-interactive mode. Pass --repository .', + }, + }); +} + +async function promptForCustomCheckout( + step: number, + total: number, +): Promise { + return runPrompt({ + prompt: () => { + showWizardStep(step, total); + return input({ + message: 'Checkout (branch, tag, or commit):', + validate: (value) => + value.trim().length > 0 || 'Enter a branch, tag, or commit.', + }); + }, + nonInteractive: { + error: + 'A custom checkout is required in non-interactive mode. Pass --checkout .', + }, + }); } function getVariantSelectionErrorMessage( @@ -126,30 +286,86 @@ function getVariantSelectionErrorMessage( projectPlatform: Platform, requestedVariant: string | void, ): string { - const availableVariants = - getVariantPlatformExpressions(systemConf.variants).join(', ') || 'none'; - const requestedVariantMessage = requestedVariant - ? ` matching "${requestedVariant}"` - : ''; + const availableVariants = getVariantPlatformExpressions(systemConf.variants); + const availableComponentSets = availableVariants.length + ? availableVariants + .map( + (expression) => `${formatPlatformLabel(expression)} (${expression})`, + ) + .join(', ') + : 'none'; - return `Unable to find a compatible variant${requestedVariantMessage} for project platform "${projectPlatform}" within the system (${systemConf.name}). Available variant platform expressions: ${availableVariants}.`; + if (requestedVariant) { + return `The ${formatSystemLabel(systemConf.name)} system has no component set matching --variant "${requestedVariant}". Available component sets: ${availableComponentSets}.`; + } + + return `The ${formatSystemLabel(systemConf.name)} system has no component set that works with this ${formatPlatformLabel(projectPlatform)} project. Available component sets: ${availableComponentSets}. Pass --variant to choose one explicitly.`; } -async function promptForVariantChoice( - variants: T[], +function getVariantPromptErrorMessage(systemConf: EmulsifySystem): string { + const availableComponentSets = getVariantPlatformExpressions( + systemConf.variants, + ) + .map((expression) => `${formatPlatformLabel(expression)} (${expression})`) + .join(', '); + + return `A component set choice is required in non-interactive mode. Pass --variant . Available component sets: ${availableComponentSets || 'none'}.`; +} + +async function promptForVariantChoice( + variants: EmulsifyVariant[], projectPlatform: Platform, systemName: string, nonInteractiveError: string, -): Promise { - const selectedPlatform = await runPrompt({ - prompt: () => - select({ - message: `Choose a ${systemName} variant for project platform "${projectPlatform}":`, - choices: variants.map(({ platform }) => platform), - }), + wizardStep?: { step: number; total: number }, +): Promise { + const rankedVariants = rankPlatformVariants(variants, projectPlatform); + const recommendation = selectCompatiblePlatformVariant( + variants, + projectPlatform, + ); + const recommendedVariants = new Set( + recommendation.status === 'selected' + ? [recommendation.variant] + : recommendation.status === 'ambiguous' + ? recommendation.variants + : [], + ); + const defaultChoice = rankedVariants.find(({ variant }) => + recommendedVariants.has(variant), + )?.index; + + const selectedIndex = await runPrompt({ + prompt: () => { + if (wizardStep) { + showWizardStep(wizardStep.step, wizardStep.total); + } + return select({ + message: wizardStep + ? 'Which component set?' + : `Which ${formatSystemLabel(systemName)} component set should be used?`, + choices: rankedVariants.map(({ variant, index }) => { + const componentCount = variant.components.length; + const requiredCount = variant.components.filter( + ({ required }) => required === true, + ).length; + const counts = ` · ${pluralize(componentCount, 'component')}, ${pluralize(requiredCount, 'required component')}`; + const recommended = recommendedVariants.has(variant) + ? ' — Recommended' + : ''; + + return { + name: `${formatPlatformLabel(variant.platform)} (${variant.platform})${recommended}${counts}`, + value: index, + short: formatPlatformLabel(variant.platform), + }; + }), + default: defaultChoice, + }); + }, nonInteractive: { error: nonInteractiveError }, }); - return variants.find(({ platform }) => platform === selectedPlatform) as T; + return variants[selectedIndex]; } async function resolveSystemVariant( @@ -167,15 +383,8 @@ async function resolveSystemVariant( } if (selection.status === 'ambiguous') { - return await promptForVariantChoice( - selection.variants, - projectPlatform, - systemConf.name, - getVariantSelectionErrorMessage( - systemConf, - projectPlatform, - requestedVariant, - ), + throw new CliError( + `The ${formatSystemLabel(systemConf.name)} system defines more than one component set for --variant "${requestedVariant}". Ask the system maintainer to give each component set a unique platform expression.`, ); } @@ -197,14 +406,14 @@ async function resolveSystemVariant( } if (selection.status === 'ambiguous') { - const compatibleVariants = selection.variants - .map(({ platform }) => platform) + const compatibleComponentSets = selection.variants + .map(({ platform }) => `${formatPlatformLabel(platform)} (${platform})`) .join(', '); return await promptForVariantChoice( selection.variants, projectPlatform, systemConf.name, - `Multiple compatible variants were found for project platform "${projectPlatform}" within the system (${systemConf.name}): ${compatibleVariants}. Run this command in an interactive terminal or specify a variant.`, + `More than one ${formatSystemLabel(systemConf.name)} component set works equally well with this ${formatPlatformLabel(projectPlatform)} project: ${compatibleComponentSets}. Run this command in an interactive terminal, or pass --variant .`, ); } @@ -213,6 +422,123 @@ async function resolveSystemVariant( ); } +async function promptForInstallScope( + variant: EmulsifyVariant, + step: number, + total: number, +): Promise { + const requiredComponentCount = variant.components.filter( + ({ required }) => required === true, + ).length; + + return runPrompt({ + prompt: () => { + showWizardStep(step, total); + return select({ + message: 'How much do you want to install?', + choices: [ + { + name: formatChoice( + 'Essentials only', + pluralize(requiredComponentCount, 'required component'), + ), + value: false, + short: 'Essentials only', + }, + { + name: formatChoice( + 'Everything', + pluralize(variant.components.length, 'component'), + ), + value: true, + short: 'Everything', + }, + ], + default: false, + }); + }, + nonInteractive: { + error: + 'Install scope is required in non-interactive mode. Pass --all to install every component, or provide a system name to install required components only.', + }, + }); +} + +function formatDestinationList(destinations: string[]): string { + return destinations.length > 0 ? destinations.join(', ') : 'none'; +} + +function formatDirectoryDestinations(destinations: string[]): string { + return formatDestinationList( + destinations.map((destination) => + destination === '.' || destination.endsWith('/') + ? destination + : `${destination}/`, + ), + ); +} + +export function formatSystemInstallReview( + systemLabel: string, + repository: string, + checkout: string | void, + variant: EmulsifyVariant, + plan: SystemInstallPlan, + installAll: boolean, +): string { + const continuationIndent = ' '; + const installRows = [ + `${pluralize(plan.components.length, 'component')} → ${formatDirectoryDestinations(plan.componentParentDestinations)}`, + ]; + + if (plan.directoryAssetCount > 0) { + installRows.push( + `${pluralize(plan.directoryAssetCount, 'asset folder')} → ${formatDestinationList(plan.directoryAssetDestinations)}`, + ); + } + + if (plan.fileAssetCount > 0) { + installRows.push( + `${pluralize(plan.fileAssetCount, 'asset file')} → ${formatDestinationList(plan.fileAssetDestinations)}`, + ); + } + + return [ + ` System ${systemLabel}${checkout ? ` · ${checkout}` : ''}`, + ` Source ${formatRepositorySource(repository)}`, + ` Component set ${formatPlatformLabel(variant.platform)}`, + ` Scope ${installAll ? 'Everything' : 'Essentials only'}`, + ` Will install ${installRows[0]}`, + ...installRows.slice(1).map((row) => `${continuationIndent}${row}`), + ].join('\n'); +} + +async function promptForInstallConfirmation( + review: string, + step: number, + total: number, + accept: boolean, +): Promise { + showWizardStep(step, total); + log('info', `\n${review}\n`); + + return runPrompt({ + prompt: () => + confirm({ + message: 'Install now?', + default: true, + }), + nonInteractive: { + error: + 'Installation confirmation is required in non-interactive mode. Pass --yes to accept the reviewed installation.', + }, + accept: { + when: accept, + value: true, + }, + }); +} + /** * Handler for the `system install` command. * @@ -237,23 +563,58 @@ export default async function systemInstall( if (projectConfig.system) { throw new CliError( - 'You have already selected a system within this Emulsify project.', + 'This Emulsify project already has a component system configured. Run "emulsify component list" to see what is available. To choose a different system, remove the existing "system" and "variant" entries from project.emulsify.json, then run "emulsify system install" again.', ); } - // Attempt to load system information, and exit with a log message - // if a valid system was not found. - let selectedName = name; - if (!selectedName && !options.repository && !options.checkout) { - selectedName = await promptForSystemInstallChoice(); - if (!selectedName) { + const guidedInstall = !name && !options.repository && !options.checkout; + let wizardStep = 1; + let wizardTotalSteps = 0; + let systemLabel: string | undefined; + let repo: (GitCloneOptions & { name: string }) | void; + + if (guidedInstall) { + const source = await promptForSystemInstallChoice(); + if (!source) { return; } + + if (source.kind === 'built-in') { + const { reference } = source; + systemLabel = reference.label; + repo = { + name: reference.name, + repository: reference.repository, + checkout: reference.checkout, + }; + wizardTotalSteps = 2 + (options.variant ? 0 : 1) + (options.all ? 0 : 1); + } else { + wizardTotalSteps = 4 + (options.variant ? 0 : 1) + (options.all ? 0 : 1); + const repository = ( + await promptForCustomRepository(++wizardStep, wizardTotalSteps) + ).trim(); + const checkout = ( + await promptForCustomCheckout(++wizardStep, wizardTotalSteps) + ).trim(); + repo = await getSystemRepoInfo(undefined, { + ...options, + repository, + checkout, + }); + } + } else { + repo = await getSystemRepoInfo(name, options); } - const repo = await getSystemRepoInfo(selectedName, options); if (!repo) { - throw new CliError(SYSTEM_INSTALL_ERROR); + throw new CliError(INVALID_SYSTEM_SOURCE_ERROR); + } + + if (guidedInstall) { + log( + 'info', + `Loading ${systemLabel || 'the component system'} from ${formatRepositorySource(repo.repository)}. This may take a moment…`, + ); } // Attempt to get latest tag if no branch was supplied. @@ -294,42 +655,120 @@ export default async function systemInstall( // in order to have more readable output from the AJV validation. console.error('System configuration errors:', e); throw new CliError( - `The system install failed due to the validation errors reported above. Please fix the the errors in the "${systemConf.name}" configuration and try again.`, + `The system install failed due to the validation errors reported above. Please fix the errors in the "${systemConf.name}" configuration and try again.`, + ); + } + + if (systemConf.name !== repo.name) { + throw new CliError( + `The repository was cached as "${repo.name}", but system.emulsify.json declares the system name "${systemConf.name}". These names must match so files can be installed safely. Rename the repository or update the system name, then retry.`, ); } + if (!repo.checkout) { + repo.checkout = await getCachedItemCheckout({ + bucket: 'systems', + itemPath: [repo.name], + repository: repo.repository, + checkout: repo.checkout, + }); + } + if (!repo.checkout) { + throw new CliError( + 'Unable to determine which system checkout was loaded. Retry with --checkout .', + ); + } + + systemLabel ||= formatSystemLabel(systemConf.name); + if (guidedInstall) { + log('info', `Loaded ${systemLabel} · ${repo.checkout}.`); + } + const projectPlatform = projectConfig.project.platform; if (!isPlatform(projectPlatform)) { throw new CliError( - 'Unable to determine a variant for the specified system. Please either pass in a valid variant using the --variant flag.', + 'This project does not declare a supported platform. Set project.platform in project.emulsify.json to none, drupal, or wordpress before installing a component system.', ); } // @TODO: clone variants into their own cache bucket if a reference is provided. - const variantConf = await resolveSystemVariant( - systemConf, - projectPlatform, - options.variant, - ); + let variantConf: EmulsifyVariant; + if (guidedInstall && !options.variant) { + const compatibility = selectCompatiblePlatformVariant( + systemConf.variants, + projectPlatform, + ); + if (compatibility.status === 'none') { + throw new CliError( + getVariantSelectionErrorMessage(systemConf, projectPlatform, undefined), + ); + } - // Update emulsify project config. - try { - // If no checkout was passed along, and the default checkout was used, fetch it - // it can be stored in the project config. - let checkout = repo.checkout; - if (!checkout) { - checkout = await getCachedItemCheckout({ - bucket: 'systems', - itemPath: [repo.name], - repository: repo.repository, - checkout: repo.checkout, - }); + variantConf = await promptForVariantChoice( + systemConf.variants || [], + projectPlatform, + systemConf.name, + getVariantPromptErrorMessage(systemConf), + { step: ++wizardStep, total: wizardTotalSteps }, + ); + } else { + variantConf = await resolveSystemVariant( + systemConf, + projectPlatform, + options.variant, + ); + } + + const installAll = + guidedInstall && !options.all + ? await promptForInstallScope(variantConf, ++wizardStep, wizardTotalSteps) + : options.all === true; + let componentsToInstall = installAll + ? variantConf.components + : variantConf.components.filter(({ required }) => required === true); + + if (guidedInstall) { + const projectConfigPath = findFileInCurrentPath( + EMULSIFY_PROJECT_CONFIG_FILE, + ); + if (!projectConfigPath) { + throw new CliError( + 'Unable to find the Emulsify project configuration for the installation review.', + ); + } + + const plan = buildSystemInstallPlan( + systemConf, + variantConf, + installAll, + projectConfigPath, + ); + componentsToInstall = plan.components; + const confirmed = await promptForInstallConfirmation( + formatSystemInstallReview( + systemLabel, + repo.repository, + repo.checkout, + variantConf, + plan, + installAll, + ), + ++wizardStep, + wizardTotalSteps, + options.yes === true, + ); + if (!confirmed) { + log('info', 'System install cancelled. No project files were changed.'); + return; } + } + // Update emulsify project config. + try { await setEmulsifyConfig({ system: { repository: repo.repository, - checkout, + checkout: repo.checkout, }, // @TODO: Because we don't yet support referenced variants, for now we only // pass in the platform name. @@ -344,12 +783,7 @@ export default async function systemInstall( try { // Install all required components or all available components. - const componentsList = variantConf.components; - const requiredComponents = componentsList.filter( - ({ required }) => required === true, - ); - - for (const component of options.all ? componentsList : requiredComponents) { + for (const component of componentsToInstall) { await installComponentFromCache( systemConf, variantConf, @@ -383,6 +817,8 @@ export default async function systemInstall( return log( 'success', - `Successfully installed the ${systemConf.name} system using the ${variantConf.platform} variant.`, + guidedInstall + ? `Successfully installed the ${systemLabel} system using the ${formatPlatformLabel(variantConf.platform)} component set.` + : `Successfully installed the ${systemConf.name} system using the ${variantConf.platform} variant.`, ); } diff --git a/src/index.ts b/src/index.ts index 5c631b6..c570e6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,13 +75,14 @@ function getRootHelp(): string { ' -y, --yes Accept defaults for every missing value.', '', ' system install [name]', - ' Install a built-in or repository-backed component system. With no name or repository in', - ' an interactive terminal, prompts for compound, emulsify-ui-kit, or cancel.', + ' Install a built-in or repository-backed component system. With no source in an interactive', + ' terminal, guides you through system, component-set, scope, and review choices.', ' Options:', ' -r, --repository Install from a remote .git URL or local repository path.', ' -c, --checkout Checkout to use with --repository.', ' --variant Select an exact variant platform expression.', ' -a, --all Install every component in the selected variant.', + ' -y, --yes Accept the final guided-install review without prompting.', '', ' component list', ' List components available from the installed system and selected variant. Alias: component ls.', @@ -184,7 +185,7 @@ system .action(systemCreate); system .command('install [name]') - .description('Install a component system or prompt for a built-in system') + .description('Install a component system or open the guided installer') .option( '-r --repository ', 'Git repository containing the system to install. Remote URLs must end in .git; local paths are accepted.', @@ -201,6 +202,10 @@ system '-a --all', 'Install every component in the selected variant. Without this flag, only required components are installed.', ) + .option( + '-y --yes', + 'Accept the final guided-install review without prompting.', + ) .action(systemInstall); // Component sub-commands. diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index da2408d..a20db2a 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -22,6 +22,8 @@ declare module '@emulsify-cli/handlers' { checkout?: string | void; variant?: PlatformExpression | void; all?: boolean; + /** Accept the guided installation after rendering its final review. */ + yes?: boolean; }; export type CreateSystemHandlerOptions = { diff --git a/src/types/internal.d.ts b/src/types/internal.d.ts index 81f5396..b02c26e 100644 --- a/src/types/internal.d.ts +++ b/src/types/internal.d.ts @@ -47,7 +47,12 @@ declare module '@emulsify-cli/internal' { * Represents an internal reference to an Emulsify system, such as compound. */ export type EmulsifySystemReference = { + /** Machine-friendly system identifier used by CLI arguments and cache paths. */ name: string; + /** Human-facing system name used in interactive choices. */ + label: string; + /** Short human-facing summary used when choosing a system. */ + description: string; /** * Path to the git repository containing the system. */ diff --git a/src/util/platform/platformCompatibility.test.ts b/src/util/platform/platformCompatibility.test.ts index af7a51e..c9bbf00 100644 --- a/src/util/platform/platformCompatibility.test.ts +++ b/src/util/platform/platformCompatibility.test.ts @@ -4,6 +4,7 @@ import { normalizePlatformExpression, parsePlatformExpression, platformExpressionMatchesProject, + rankPlatformVariants, selectCompatiblePlatformVariant, selectExactPlatformVariant, tryParsePlatformExpression, @@ -102,6 +103,128 @@ describe('platformCompatibility', () => { }); }); + describe('rankPlatformVariants', () => { + it('orders exact, shared, and generic matches before incompatible variants', () => { + const variants = [ + genericVariant, + wordpressVariant, + sharedVariant, + drupalVariant, + unknownVariant, + ]; + + expect(rankPlatformVariants(variants, 'drupal')).toEqual([ + { + variant: drupalVariant, + rank: 0, + index: 3, + }, + { + variant: sharedVariant, + rank: 1, + index: 2, + }, + { + variant: genericVariant, + rank: 2, + index: 0, + }, + { + variant: wordpressVariant, + index: 1, + }, + { + variant: unknownVariant, + index: 4, + }, + ]); + }); + + it('prefers an exact generic variant for none projects and ranks all other variants equally', () => { + const variants = [drupalVariant, genericVariant, wordpressVariant]; + + expect(rankPlatformVariants(variants, 'none')).toEqual([ + { + variant: genericVariant, + rank: 0, + index: 1, + }, + { + variant: drupalVariant, + rank: 3, + index: 0, + }, + { + variant: wordpressVariant, + rank: 3, + index: 2, + }, + ]); + }); + + it('preserves source order when compatible variants have the same rank', () => { + const firstSharedVariant = { + platform: 'drupal || wordpress', + name: 'first', + }; + const secondSharedVariant = { + platform: 'wordpress || drupal', + name: 'second', + }; + + expect( + rankPlatformVariants( + [secondSharedVariant, firstSharedVariant], + 'wordpress', + ), + ).toEqual([ + { + variant: secondSharedVariant, + rank: 1, + index: 0, + }, + { + variant: firstSharedVariant, + rank: 1, + index: 1, + }, + ]); + }); + + it('does not mutate the source array or variants', () => { + const exactVariant = Object.freeze({ + platform: 'drupal', + name: 'exact', + }); + const generic = Object.freeze({ + platform: 'none', + name: 'generic', + }); + const variants = Object.freeze([generic, exactVariant]); + + const ranked = rankPlatformVariants(variants, 'drupal'); + + expect(variants).toEqual([generic, exactVariant]); + expect(ranked).not.toBe(variants); + expect(ranked.map(({ variant }) => variant)).toEqual([ + exactVariant, + generic, + ]); + expect(ranked[0].variant).toBe(exactVariant); + expect(ranked[1].variant).toBe(generic); + }); + + it('retains unranked variants and handles a missing variant list', () => { + expect(rankPlatformVariants([wordpressVariant], 'drupal')).toEqual([ + { + variant: wordpressVariant, + index: 0, + }, + ]); + expect(rankPlatformVariants(undefined, 'drupal')).toEqual([]); + }); + }); + describe('selectCompatiblePlatformVariant', () => { it('skips unparseable variants when a compatible variant exists', () => { expect( diff --git a/src/util/platform/platformCompatibility.ts b/src/util/platform/platformCompatibility.ts index ddfdb4d..bc39cef 100644 --- a/src/util/platform/platformCompatibility.ts +++ b/src/util/platform/platformCompatibility.ts @@ -10,6 +10,19 @@ type VariantWithPlatform = { platform: string; }; +export type PlatformVariantMatchRank = 0 | 1 | 2 | 3; + +export type RankedPlatformVariant = { + variant: T; + rank?: PlatformVariantMatchRank; + index: number; +}; + +type RankedCompatiblePlatformVariant = + RankedPlatformVariant & { + rank: PlatformVariantMatchRank; + }; + type VariantSelectionResult = | { status: 'selected'; @@ -77,7 +90,7 @@ export function platformExpressionMatchesProject( function getVariantMatchRank( variantPlatform: string, projectPlatform: Platform, -): number | undefined { +): PlatformVariantMatchRank | undefined { const platforms = tryParsePlatformExpression(variantPlatform); if (!platforms) { return undefined; @@ -104,6 +117,34 @@ function getVariantMatchRank( return undefined; } +/** + * Return every variant with compatible variants ordered from the strongest + * platform match to the weakest, followed by incompatible variants. Variants + * with the same rank retain their source order. + * + * The returned records and array are new; variants and the source array are + * never mutated. + */ +export function rankPlatformVariants( + variants: readonly T[] | undefined, + projectPlatform: Platform, +): RankedPlatformVariant[] { + return (variants || []) + .map((variant, index) => { + const rank = getVariantMatchRank(variant.platform, projectPlatform); + return rank === undefined ? { variant, index } : { variant, rank, index }; + }) + .sort((left, right) => { + if (left.rank === undefined) { + return right.rank === undefined ? left.index - right.index : 1; + } + if (right.rank === undefined) { + return -1; + } + return left.rank - right.rank || left.index - right.index; + }); +} + export function getVariantPlatformExpressions( variants: readonly VariantWithPlatform[] | undefined, ): string[] { @@ -114,21 +155,16 @@ export function selectCompatiblePlatformVariant( variants: readonly T[] | undefined, projectPlatform: Platform, ): VariantSelectionResult { - const rankedVariants = (variants || []) - .map((variant) => ({ - variant, - rank: getVariantMatchRank(variant.platform, projectPlatform), - })) - .filter( - (match): match is { variant: T; rank: number } => - match.rank !== undefined, - ); + const rankedVariants = rankPlatformVariants(variants, projectPlatform).filter( + (match): match is RankedCompatiblePlatformVariant => + match.rank !== undefined, + ); if (rankedVariants.length === 0) { return { status: 'none' }; } - const bestRank = Math.min(...rankedVariants.map(({ rank }) => rank)); + const bestRank = rankedVariants[0].rank; const bestMatches = rankedVariants .filter(({ rank }) => rank === bestRank) .map(({ variant }) => variant); diff --git a/src/util/system/buildSystemInstallPlan.test.ts b/src/util/system/buildSystemInstallPlan.test.ts new file mode 100644 index 0000000..0de0d48 --- /dev/null +++ b/src/util/system/buildSystemInstallPlan.test.ts @@ -0,0 +1,326 @@ +import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; + +import { resolve } from 'path'; +import buildSystemInstallPlan from './buildSystemInstallPlan.js'; + +const projectConfigPath = resolve('/project/project.emulsify.json'); + +const button = { + name: 'button', + structure: 'base', + required: true, +}; +const icon = { + name: 'icon', + structure: 'base', + required: true, +}; +const card = { + name: 'card', + structure: 'layout', +}; + +function buildVariant( + overrides: Partial = {}, +): EmulsifyVariant { + return { + platform: 'drupal', + structureImplementations: [ + { name: 'base', directory: 'components/00-base' }, + { name: 'layout', directory: 'components/01-layout' }, + ], + components: [button, icon, card], + ...overrides, + }; +} + +function buildSystem( + variant: EmulsifyVariant, + overrides: Partial = {}, +): EmulsifySystem { + return { + name: 'compound', + homepage: 'https://example.com/compound', + repository: 'https://github.com/emulsify-ds/compound.git', + structure: [ + { name: 'base', description: 'Base components' }, + { name: 'layout', description: 'Layout components' }, + ], + variants: [variant], + ...overrides, + }; +} + +describe('buildSystemInstallPlan', () => { + it('selects exact required component objects and reports required and total counts', () => { + const variant = buildVariant(); + const plan = buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ); + + expect(plan).toEqual({ + components: [button, icon], + requiredComponentCount: 2, + totalComponentCount: 3, + componentParentDestinations: ['components/00-base'], + directoryAssetDestinations: [], + fileAssetDestinations: [], + directoryAssetCount: 0, + fileAssetCount: 0, + totalAssetCount: 0, + }); + expect(plan.components[0]).toBe(button); + expect(plan.components[1]).toBe(icon); + }); + + it('selects all component objects while preserving config order', () => { + const variant = buildVariant(); + const plan = buildSystemInstallPlan( + buildSystem(variant), + variant, + true, + projectConfigPath, + ); + + expect(plan.components).toEqual([button, icon, card]); + expect(plan.components[0]).toBe(button); + expect(plan.components[1]).toBe(icon); + expect(plan.components[2]).toBe(card); + expect(plan.requiredComponentCount).toBe(2); + expect(plan.totalComponentCount).toBe(3); + expect(plan.componentParentDestinations).toEqual([ + 'components/00-base', + 'components/01-layout', + ]); + }); + + it('returns an empty selection when no components are required', () => { + const variant = buildVariant({ + components: [card], + directories: [], + files: [], + }); + const plan = buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ); + + expect(plan.components).toEqual([]); + expect(plan.requiredComponentCount).toBe(0); + expect(plan.totalComponentCount).toBe(1); + expect(plan.componentParentDestinations).toEqual([]); + expect(plan.totalAssetCount).toBe(0); + }); + + it('deduplicates component parents across structures in first-seen order', () => { + const firstLayout = { + name: 'hero', + structure: 'layout', + required: true, + }; + const firstBase = { + name: 'link', + structure: 'base', + required: true, + }; + const secondLayout = { + name: 'grid', + structure: 'layout', + required: true, + }; + const variant = buildVariant({ + structureImplementations: [ + { name: 'base', directory: 'src/components/base' }, + { name: 'layout', directory: 'src/components/layout' }, + ], + components: [firstLayout, firstBase, secondLayout], + }); + const plan = buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ); + + expect(plan.components).toEqual([firstLayout, firstBase, secondLayout]); + expect(plan.componentParentDestinations).toEqual([ + 'src/components/layout', + 'src/components/base', + ]); + }); + + it('displays the project root when it is the component parent', () => { + const variant = buildVariant({ + structureImplementations: [{ name: 'base', directory: '.' }], + components: [button], + }); + + expect( + buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ).componentParentDestinations, + ).toEqual(['.']); + }); + + it('returns unique directory and file display destinations with raw asset counts', () => { + const variant = buildVariant({ + directories: [ + { + name: 'fonts', + path: 'assets/fonts', + destinationPath: 'public/fonts', + }, + { + name: 'font aliases', + path: 'assets/font-aliases', + destinationPath: 'public/fonts/', + }, + { + name: 'images', + path: 'assets/images', + destinationPath: 'public/images', + }, + ], + files: [ + { + name: 'tokens', + path: 'assets/tokens.json', + destinationPath: 'public/tokens.json', + }, + { + name: 'token aliases', + path: 'assets/token-aliases.json', + destinationPath: 'public/tokens.json', + }, + { + name: 'manifest', + path: 'assets/manifest.json', + destinationPath: 'public/manifest.json', + }, + ], + }); + const plan = buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ); + + expect(plan.directoryAssetDestinations).toEqual([ + 'public/fonts/', + 'public/images/', + ]); + expect(plan.fileAssetDestinations).toEqual([ + 'public/tokens.json', + 'public/manifest.json', + ]); + expect(plan.directoryAssetCount).toBe(3); + expect(plan.fileAssetCount).toBe(3); + expect(plan.totalAssetCount).toBe(6); + }); + + it('rejects a component destination outside the project', () => { + const variant = buildVariant({ + structureImplementations: [{ name: 'base', directory: '../outside' }], + components: [button], + }); + + expect(() => + buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ), + ).toThrow('Component destination "../outside/button"'); + }); + + it.each([ + { + assetType: 'directory', + unsafeDestination: '../outside', + overrides: { + directories: [ + { + name: 'outside', + path: 'assets/outside', + destinationPath: '../outside', + }, + ], + }, + }, + { + assetType: 'file', + unsafeDestination: '../outside.json', + overrides: { + files: [ + { + name: 'outside', + path: 'assets/outside.json', + destinationPath: '../outside.json', + }, + ], + }, + }, + ])( + 'rejects an unsafe $assetType asset destination', + ({ overrides, unsafeDestination }) => { + const variant = buildVariant({ + components: [], + ...overrides, + }); + + expect(() => + buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ), + ).toThrow(`General asset destination "${unsafeDestination}"`); + }, + ); + + it('rejects a component whose structure is not implemented by the variant', () => { + const variant = buildVariant({ + structureImplementations: [], + components: [button], + }); + + expect(() => + buildSystemInstallPlan( + buildSystem(variant), + variant, + false, + projectConfigPath, + ), + ).toThrow( + 'The structure (base) specified within the component button is invalid.', + ); + }); + + it('rejects a component whose structure is not declared by the system', () => { + const variant = buildVariant({ components: [button] }); + + expect(() => + buildSystemInstallPlan( + buildSystem(variant, { + structure: [{ name: 'layout', description: 'Layout components' }], + }), + variant, + false, + projectConfigPath, + ), + ).toThrow( + 'The structure (base) specified within the component button is invalid.', + ); + }); +}); diff --git a/src/util/system/buildSystemInstallPlan.ts b/src/util/system/buildSystemInstallPlan.ts new file mode 100644 index 0000000..03b11eb --- /dev/null +++ b/src/util/system/buildSystemInstallPlan.ts @@ -0,0 +1,119 @@ +import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; + +import { dirname, relative, sep } from 'path'; +import safeResolveWithin from '../fs/safeResolveWithin.js'; +import { getComponentDestination } from '../project/installComponentFromCache.js'; + +type SystemComponent = EmulsifyVariant['components'][number]; + +export type SystemInstallPlan = { + components: SystemComponent[]; + requiredComponentCount: number; + totalComponentCount: number; + componentParentDestinations: string[]; + directoryAssetDestinations: string[]; + fileAssetDestinations: string[]; + directoryAssetCount: number; + fileAssetCount: number; + totalAssetCount: number; +}; + +function toProjectRelativeDisplayPath( + projectRoot: string, + destination: string, +): string { + const displayPath = relative(projectRoot, destination).split(sep).join('/'); + return displayPath || '.'; +} + +function uniqueInOrder(values: string[]): string[] { + return [...new Set(values)]; +} + +function assertSystemStructure( + systemConf: EmulsifySystem, + component: SystemComponent, +): void { + if (!systemConf.structure.some(({ name }) => name === component.structure)) { + throw new Error( + `The structure (${component.structure}) specified within the component ${component.name} is invalid.`, + ); + } +} + +/** + * Build a filesystem-independent review plan for a system installation. + * + * All returned destinations are project-relative display paths. Resolving the + * destinations here validates that the reviewed installation remains within + * the project before any project files are changed. + */ +export default function buildSystemInstallPlan( + systemConf: EmulsifySystem, + variantConf: EmulsifyVariant, + installAll: boolean, + projectConfigPath: string, +): SystemInstallPlan { + const projectRoot = dirname(projectConfigPath); + const requiredComponents = variantConf.components.filter( + ({ required }) => required === true, + ); + const components = installAll + ? [...variantConf.components] + : requiredComponents; + + const componentParentDestinations = uniqueInOrder( + components.map((component) => { + assertSystemStructure(systemConf, component); + return toProjectRelativeDisplayPath( + projectRoot, + dirname( + getComponentDestination( + variantConf, + component.name, + projectConfigPath, + ), + ), + ); + }), + ); + + const directories = variantConf.directories || []; + const files = variantConf.files || []; + const directoryAssetDestinations = uniqueInOrder( + directories.map(({ destinationPath }) => { + const destination = safeResolveWithin( + projectRoot, + destinationPath, + 'General asset destination', + ); + return `${toProjectRelativeDisplayPath(projectRoot, destination)}/`; + }), + ); + const fileAssetDestinations = uniqueInOrder( + files.map(({ destinationPath }) => + toProjectRelativeDisplayPath( + projectRoot, + safeResolveWithin( + projectRoot, + destinationPath, + 'General asset destination', + ), + ), + ), + ); + const directoryAssetCount = directories.length; + const fileAssetCount = files.length; + + return { + components, + requiredComponentCount: requiredComponents.length, + totalComponentCount: variantConf.components.length, + componentParentDestinations, + directoryAssetDestinations, + fileAssetDestinations, + directoryAssetCount, + fileAssetCount, + totalAssetCount: directoryAssetCount + fileAssetCount, + }; +} diff --git a/src/util/system/getAvailableSystems.test.ts b/src/util/system/getAvailableSystems.test.ts index 75002ed..98825bf 100644 --- a/src/util/system/getAvailableSystems.test.ts +++ b/src/util/system/getAvailableSystems.test.ts @@ -6,11 +6,15 @@ describe('getAvailableSystems', () => { await expect(getAvailableSystems()).resolves.toEqual([ { name: 'compound', + label: 'Compound', + description: 'Accessible, tested components. Drupal, WordPress, plain.', repository: 'https://github.com/emulsify-ds/compound.git', platforms: ['none', 'drupal', 'wordpress'], }, { name: 'emulsify-ui-kit', + label: 'Emulsify UI Kit', + description: 'Broader design-system starter kit.', repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', platforms: ['none', 'drupal', 'wordpress'], }, diff --git a/src/util/system/getAvailableSystems.ts b/src/util/system/getAvailableSystems.ts index 787f737..d729158 100644 --- a/src/util/system/getAvailableSystems.ts +++ b/src/util/system/getAvailableSystems.ts @@ -15,11 +15,15 @@ export default async function getAvailableSystems(): Promise< return [ { name: 'compound', + label: 'Compound', + description: 'Accessible, tested components. Drupal, WordPress, plain.', repository: 'https://github.com/emulsify-ds/compound.git', platforms: ['none', 'drupal', 'wordpress'], }, { name: 'emulsify-ui-kit', + label: 'Emulsify UI Kit', + description: 'Broader design-system starter kit.', repository: 'https://github.com/emulsify-ds/emulsify-ui-kit.git', platforms: ['none', 'drupal', 'wordpress'], }, diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index e350f84..fcd7cb6 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -33,6 +33,7 @@ let tempRoot; let isolatedHome; let projectsRoot; let projectRoot; +let nonInteractiveInstallProjectRoot; let starterRepository; let systemRepository; let systemHookSentinel; @@ -172,6 +173,25 @@ describe('built Emulsify CLI', { concurrency: false }, () => { }); starterRepository = pathToFileURL(starterPath).href; + nonInteractiveInstallProjectRoot = join( + projectsRoot, + 'non-interactive-install-project', + ); + mkdirSync(nonInteractiveInstallProjectRoot, { recursive: true }); + writeFileSync( + join(nonInteractiveInstallProjectRoot, 'project.emulsify.json'), + json({ + project: { + platform: 'none', + name: 'Non-interactive Install Project', + machineName: 'non-interactive-install-project', + }, + starter: { + repository: starterRepository, + }, + }), + ); + const structureImplementations = [ { name: 'components', @@ -290,6 +310,25 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(existsSync(join(tempRoot, 'custom-system')), false); }); + test('fails fast without changing the project when system install has no source outside a TTY', () => { + const projectConfigPath = join( + nonInteractiveInstallProjectRoot, + 'project.emulsify.json', + ); + const configBefore = readFileSync(projectConfigPath, 'utf8'); + const result = runCli(nonInteractiveInstallProjectRoot, [ + 'system', + 'install', + ]); + + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /positional argument/); + assert.match(result.stderr, /--repository/); + assert.match(result.stderr, /--checkout/); + assert.equal(readFileSync(projectConfigPath, 'utf8'), configBefore); + }); + test('creates a standalone system and installs it from a local path', () => { const systemName = 'round-trip-system'; const generatedSystemRoot = join(tempRoot, systemName); diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 2308001..de80dee 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -52,13 +52,14 @@ Commands: -y, --yes Accept defaults for every missing value. system install [name] - Install a built-in or repository-backed component system. With no name or repository in - an interactive terminal, prompts for compound, emulsify-ui-kit, or cancel. + Install a built-in or repository-backed component system. With no source in an interactive + terminal, guides you through system, component-set, scope, and review choices. Options: -r, --repository Install from a remote .git URL or local repository path. -c, --checkout Checkout to use with --repository. --variant Select an exact variant platform expression. -a, --all Install every component in the selected variant. + -y, --yes Accept the final guided-install review without prompting. component list List components available from the installed system and selected variant. Alias: component ls. From eeaa5b84c37d71a66403ee1a6a40918764c9c84c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 02:21:45 -0500 Subject: [PATCH 05/33] docs: align the CLI description across the package, readme, and help --- README.md | 2 +- docs/README.md | 2 ++ package.json | 2 +- src/index.ts | 2 +- test/e2e/root-help.txt | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cf9cf0e..d6d8cd7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Emulsify CLI -Command line interface for creating Emulsify projects, authoring and installing component systems, installing system components, generating local components, and routing project audits to Emulsify Core. +Build and use component systems in Drupal, WordPress, or standalone front ends. ## Requirements diff --git a/docs/README.md b/docs/README.md index a4e2867..b15962d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,7 @@ # Emulsify CLI Documentation +Build and use component systems in Drupal, WordPress, or standalone front ends. + These docs expand on the short project README and are organized by the task a project user or maintainer is usually trying to complete. | Topic | Use This When | diff --git a/package.json b/package.json index 5c89e63..58a9b73 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@emulsify/cli", "productName": "Emulsify CLI", "version": "2.3.1", - "description": "Command line interface for Emulsify", + "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 ", "license": "GPL-2.0", diff --git a/src/index.ts b/src/index.ts index c570e6b..20d691b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ function getRootHelp(): string { return [ `${packageInfo.productName} ${packageInfo.version}`, '', - 'Create Emulsify projects, choose component systems, install components, generate local components, and route audits to Emulsify Core.', + 'Build and use component systems in Drupal, WordPress, or standalone front ends.', '', 'Usage:', ' emulsify', diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index de80dee..36b0025 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -1,6 +1,6 @@ Emulsify CLI {{version}} -Create Emulsify projects, choose component systems, install components, generate local components, and route audits to Emulsify Core. +Build and use component systems in Drupal, WordPress, or standalone front ends. Usage: emulsify From fc4b1041819b55c2d3ca650f5de0fe5f63051447 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 02:39:38 -0500 Subject: [PATCH 06/33] feat(cli): group root help output by task --- docs/cli-reference.md | 2 +- src/handlers/audit.test.ts | 2 +- src/index.ts | 158 +++--------- src/lib/log.test.ts | 4 + src/lib/log.ts | 35 ++- src/lib/rootHelp.test.ts | 78 ++++++ src/lib/rootHelp.ts | 349 ++++++++++++++++++++++++++ src/lib/terminalColors.test.ts | 63 +++++ src/lib/terminalColors.ts | 30 +++ src/util/project/generateComponent.ts | 25 +- test/e2e/cli.test.mjs | 26 +- test/e2e/root-help.txt | 132 +++------- 12 files changed, 653 insertions(+), 251 deletions(-) create mode 100644 src/lib/rootHelp.test.ts create mode 100644 src/lib/rootHelp.ts create mode 100644 src/lib/terminalColors.test.ts create mode 100644 src/lib/terminalColors.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3c1b47f..a014878 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -362,7 +362,7 @@ Options: | Option | Description | | ----------------------------- | ------------------------------------------------------------------------------------- | | `-d, --directory ` | Variant structure name where the component should be created. | -| `-f, --format ` | Component format to generate. Supported values are `default` and `sdc`. | +| `-f, --format ` | Component format to generate. | | `-y, --yes` | Replace an existing generated component without prompting. | | `--dry-run` | Preview destination and generated files without writing, removing, or creating files. | | `--refresh` | Check the system's remote ref before reusing its local cache entry. | diff --git a/src/handlers/audit.test.ts b/src/handlers/audit.test.ts index 33c4f86..003360d 100644 --- a/src/handlers/audit.test.ts +++ b/src/handlers/audit.test.ts @@ -461,7 +461,7 @@ test('advertises audit in root help', () => { const result = runSourceCli(repositoryRoot, ['--help']); expect(result.status).toBe(0); - expect(result.stdout).toContain('emulsify audit [...args]'); + expect(result.stdout).toContain('audit [args...]'); expect(result.stderr).toBe(''); }); diff --git a/src/index.ts b/src/index.ts index 20d691b..ce5c574 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,115 +12,16 @@ import audit from './handlers/audit.js'; import cacheClear from './handlers/cacheClear.js'; import CliError from './lib/CliError.js'; import log from './lib/log.js'; +import getRootHelp from './lib/rootHelp.js'; +import getTerminalColors, { + terminalSupportsColor, +} from './lib/terminalColors.js'; import { isExitPromptError } from './util/prompt/index.js'; import { createRequire } from 'module'; -import { cyan, green } from 'colorette'; import boxen from 'boxen'; const packageInfo = createRequire(import.meta.url)('../package.json'); -function getRootHelp(): string { - return [ - `${packageInfo.productName} ${packageInfo.version}`, - '', - 'Build and use component systems in Drupal, WordPress, or standalone front ends.', - '', - 'Usage:', - ' emulsify', - ' emulsify --help', - ' emulsify --help', - ' emulsify init [name] [path] [options]', - ' emulsify audit [...args]', - ' emulsify system create [name] [options]', - ' emulsify system install [name] [options]', - ' emulsify component [options]', - ' emulsify cache clear [options]', - '', - 'Common workflow:', - ' emulsify init', - ' emulsify system install', - ' emulsify component list', - ' emulsify component install ', - ' emulsify component create ', - '', - 'Commands:', - ' init [name] [path]', - ' Create a project from a starter repository. Prompts for project name, target directory,', - ' and platform when values are missing in an interactive terminal.', - ' Options:', - ' -m, --machineName Set the project folder/config machine name.', - ' -s, --starter Use a custom starter repository.', - ' -c, --checkout Checkout for the starter repository.', - ' -p, --platform ', - ' Select the project platform when auto-detection is unavailable.', - ' Built-in platforms: drupal, wordpress, none.', - ' -y, --yes Accept defaults for missing init values without prompting.', - '', - ' audit [...args]', - ' Run the project-installed Emulsify Core audit with unchanged arguments, output, and exit status.', - ' Run "emulsify audit --help" for Core-owned audit options.', - '', - ' system list', - ' List built-in component systems available for installation. Alias: system ls.', - '', - ' system create [name]', - ' Scaffold a standalone component-system repository. Missing values prompt in interactive terminals.', - ' Options:', - ' -d, --directory Parent directory for the new system repository.', - ' -p, --platform Platform targets for the first variant.', - ' --git Initialize a Git repository on branch main.', - ' --no-git Do not initialize a Git repository.', - ' --homepage Homepage URI for system.emulsify.json.', - ' --repository Repository URI for system.emulsify.json.', - ' -y, --yes Accept defaults for every missing value.', - '', - ' system install [name]', - ' Install a built-in or repository-backed component system. With no source in an interactive', - ' terminal, guides you through system, component-set, scope, and review choices.', - ' Options:', - ' -r, --repository Install from a remote .git URL or local repository path.', - ' -c, --checkout Checkout to use with --repository.', - ' --variant Select an exact variant platform expression.', - ' -a, --all Install every component in the selected variant.', - ' -y, --yes Accept the final guided-install review without prompting.', - '', - ' component list', - ' List components available from the installed system and selected variant. Alias: component ls.', - ' Options:', - ' --refresh Check the system remote before reusing the local cache.', - '', - ' component install [name]', - ' Install one component, dependencies, or all components from the installed system. Alias: component i.', - ' Options:', - ' -f, --force Replace an existing component destination.', - ' -a, --all Install all available components.', - ' --dry-run Preview installs without writing files.', - ' --refresh Check the system remote before reusing the local cache.', - '', - ' component create [name]', - ' Generate a new local component in this project. Alias: component c.', - ' Options:', - ' -d, --directory Variant structure where the component should be created.', - ' -f, --format Component format to generate.', - ' -y, --yes Replace existing generated components without prompting.', - ' --dry-run Preview generated files without writing them.', - ' --refresh Check the system remote before reusing the local cache.', - '', - ' cache clear', - ' Remove all locally cached Emulsify repositories.', - ' Options:', - ' --dry-run Report cache contents without removing files.', - '', - ' help [command]', - ' Show help for a command.', - '', - 'Global options:', - ' -V, --version Show the installed CLI version.', - ' -h, --help Show this help output.', - '', - ].join('\n'); -} - // Main program commands. program.name('emulsify').enablePositionalOptions(); @@ -128,20 +29,20 @@ program .command('init [name] [path]') .description('Create a new Emulsify project from a starter repository') .option( - '-m --machineName ', + '-m, --machineName ', 'Machine-friendly project folder and config name. If omitted, this is generated from the project name.', ) - .option('-s --starter ', 'Starter Git repository to clone.') + .option('-s, --starter ', 'Starter Git repository to clone.') .option( - '-c --checkout ', + '-c, --checkout ', 'Starter commit, branch, or tag to check out after clone.', ) .option( - '-p --platform ', + '-p, --platform ', 'Project platform to use when auto-detection is unavailable or should be overridden.', ) .option( - '-y --yes', + '-y, --yes', 'Accept default init values for any missing options without prompting.', ) .action(withProgressBar(init)); @@ -167,11 +68,11 @@ system .command('create [name]') .description('Scaffold a standalone component-system repository') .option( - '-d --directory ', + '-d, --directory ', 'Parent directory in which to create the new system repository.', ) .option( - '-p --platform ', + '-p, --platform ', 'Platform compatibility expression for the first variant.', ) .option('--git', 'Initialize a Git repository on branch main.') @@ -179,7 +80,7 @@ system .option('--homepage ', 'Homepage URI for system.emulsify.json.') .option('--repository ', 'Repository URI for system.emulsify.json.') .option( - '-y --yes', + '-y, --yes', 'Accept defaults for all missing system scaffold values without prompting.', ) .action(systemCreate); @@ -187,11 +88,11 @@ system .command('install [name]') .description('Install a component system or open the guided installer') .option( - '-r --repository ', + '-r, --repository ', 'Git repository containing the system to install. Remote URLs must end in .git; local paths are accepted.', ) .option( - '-c --checkout ', + '-c, --checkout ', 'Commit, branch, or tag to check out. Required when --repository is used.', ) .option( @@ -199,11 +100,11 @@ system 'Exact system variant platform expression to install.', ) .option( - '-a --all', + '-a, --all', 'Install every component in the selected variant. Without this flag, only required components are installed.', ) .option( - '-y --yes', + '-y, --yes', 'Accept the final guided-install review without prompting.', ) .action(systemInstall); @@ -226,9 +127,9 @@ component component .command('install [name]') .description('Install one component from the installed system and variant') - .option('-f --force', 'Replace an existing component destination.') + .option('-f, --force', 'Replace an existing component destination.') .option( - '-a --all', + '-a, --all', 'Install all available components instead of one named component.', ) .option( @@ -244,15 +145,15 @@ component component .command('create [name]') .option( - '-d --directory ', + '-d, --directory ', 'Variant structure name where the component should be created.', ) .option( - '-f --format ', + '-f, --format ', 'Component format to generate. Supported values: default, sdc.', ) .option( - '-y --yes', + '-y, --yes', 'Skip overwrite confirmation prompts and replace existing components.', ) .option( @@ -287,16 +188,18 @@ cache * │ │ * ╰────────────────────╯ */ +const { cyan, green } = getTerminalColors(); const title = cyan(packageInfo.productName); const message = `Version: ${green(packageInfo.version)}`; const boxedMessage = boxen(message, { title: title, - backgroundColor: 'black', borderStyle: 'round', - borderColor: 'blue', padding: 1, margin: 1, + ...(terminalSupportsColor() + ? { backgroundColor: 'black', borderColor: 'blue' } + : {}), }); program.version(boxedMessage); @@ -306,7 +209,16 @@ const rootHelpRequested = ['--help', '-h', 'help'].includes(process.argv[2])); if (rootHelpRequested) { - process.stdout.write(getRootHelp()); + process.stdout.write( + getRootHelp({ + colors: getTerminalColors(), + columns: + process.stdout.isTTY === true ? process.stdout.columns : undefined, + description: packageInfo.description, + productName: packageInfo.productName, + version: packageInfo.version, + }), + ); process.exit(0); } diff --git a/src/lib/log.test.ts b/src/lib/log.test.ts index 189fd82..bdf887f 100644 --- a/src/lib/log.test.ts +++ b/src/lib/log.test.ts @@ -28,6 +28,10 @@ const exitMock = jest import log from './log.js'; describe('log', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('can log info messages', () => { expect.assertions(1); log('info', 'information'); diff --git a/src/lib/log.ts b/src/lib/log.ts index 3533b2a..5c74a3c 100644 --- a/src/lib/log.ts +++ b/src/lib/log.ts @@ -3,32 +3,29 @@ * Exports methods that MUST be used when writing to the console. */ -import { cyan, red, yellow, green, dim, bold } from 'colorette'; import consolaGlobalInstance, { type ConsolaInstance } from 'consola'; +import getTerminalColors from './terminalColors.js'; export type LogMethod = - | 'info' - | 'error' - | 'warn' - | 'debug' - | 'verbose' - | 'success'; - -const logMethodColorMap: { - [name in LogMethod]: (message: string) => string; -} = { - info: cyan, - error: (message: string) => bold(red(message)), - warn: (message: string) => bold(yellow(message)), - debug: dim, - verbose: dim, - success: green, -}; + 'info' | 'error' | 'warn' | 'debug' | 'verbose' | 'success'; const withColor = (logger: ConsolaInstance['log']) => - (method: LogMethod, message: string): void => + (method: LogMethod, message: string): void => { + const { bold, cyan, dim, green, red, yellow } = getTerminalColors(); + const logMethodColorMap: { + [name in LogMethod]: (value: string) => string; + } = { + info: cyan, + error: (value: string) => bold(red(value)), + warn: (value: string) => bold(yellow(value)), + debug: dim, + verbose: dim, + success: green, + }; + logger(logMethodColorMap[method](message)); + }; /** * Lib function that allows for info, error, warn, debug, verbose, and success messages diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts new file mode 100644 index 0000000..bc32931 --- /dev/null +++ b/src/lib/rootHelp.test.ts @@ -0,0 +1,78 @@ +import { createColors } from 'colorette'; +import getRootHelp from './rootHelp.js'; + +const description = + 'Build and use component systems in Drupal, WordPress, or standalone front ends.'; +const plainColors = createColors({ useColor: false }); +const coloredColors = createColors({ useColor: true }); +const ansiPattern = /\u001b\[[0-?]*[ -/]*[@-~]/gu; + +function render(columns?: number, colors = plainColors): string { + return getRootHelp({ + colors, + columns, + description, + productName: 'Emulsify CLI', + version: '2.4.0', + }); +} + +function visibleLines(value: string): string[] { + return value.replace(ansiPattern, '').trimEnd().split('\n'); +} + +describe('getRootHelp', () => { + it('renders the canonical grouped layout when columns are unavailable', () => { + const help = render(); + + expect(help).toContain('Emulsify CLI 2.4.0'); + expect(help).toContain(description); + expect(help).toContain('PROJECTS'); + expect(help).toContain('COMPONENTS'); + expect(help).toContain('SYSTEMS'); + expect(help).toContain('system create [name]'); + expect(help).toContain('MAINTENANCE'); + expect(help).toContain('audit [args...]'); + expect(help).toContain('--refresh works on every component command.'); + expect(help).not.toContain('component ls'); + expect( + Math.max(...visibleLines(help).map((line) => line.length)), + ).toBeLessThanOrEqual(80); + }); + + it('applies the provided color palette without changing visible copy', () => { + const colored = render(80, coloredColors); + + expect(colored).toContain('\u001b['); + expect(colored.replace(ansiPattern, '')).toBe(render(80)); + }); + + it('stacks two-column rows and wraps prose at 60 columns', () => { + const help = render(60); + + expect(help).toContain( + ' component create [name]\n Generate a new local component', + ); + expect(help).toContain( + ' -f, --format \n Component format', + ); + expect( + Math.max(...visibleLines(help).map((line) => line.length)), + ).toBeLessThanOrEqual(60); + }); + + it('hard-wraps indivisible values in extremely narrow terminals', () => { + const help = render(12); + + expect( + Math.max(...visibleLines(help).map((line) => line.length)), + ).toBeLessThanOrEqual(12); + }); + + it.each([0, Number.NaN, Number.POSITIVE_INFINITY])( + 'uses the canonical width for invalid column count %s', + (columns) => { + expect(render(columns)).toBe(render()); + }, + ); +}); diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts new file mode 100644 index 0000000..838ec95 --- /dev/null +++ b/src/lib/rootHelp.ts @@ -0,0 +1,349 @@ +import type { Colorette } from 'colorette'; + +const NATURAL_WIDTH = 80; +const COMMAND_COLUMN_WIDTH = 32; +const OPTION_COLUMN_WIDTH = 31; +const STEP_COMMAND_WIDTH = 36; + +type HelpColors = Pick; + +type HelpRow = { + description: string; + kind: 'command' | 'option'; + label: string; +}; + +type HelpSection = { + heading: string; + rows: HelpRow[]; +}; + +export type RootHelpOptions = { + colors: HelpColors; + columns?: number; + description: string; + productName: string; + version: string; +}; + +const steps = [ + ['emulsify init', 'create the project'], + ['emulsify system install', 'pick a component system'], + ['emulsify component list', 'see what you can install'], + ['emulsify component install ', 'add a component'], +] as const; + +const sections: HelpSection[] = [ + { + heading: 'PROJECTS', + rows: [ + { + kind: 'command', + label: 'init [name] [path]', + description: 'Create a project from a starter repository', + }, + { + kind: 'option', + label: '-m, --machineName ', + description: 'Folder and config machine name', + }, + { + kind: 'option', + label: '-s, --starter ', + description: 'Use a custom starter repository', + }, + { + kind: 'option', + label: '-c, --checkout ', + description: 'Starter commit, branch, or tag', + }, + { + kind: 'option', + label: '-p, --platform ', + description: 'none | drupal | wordpress', + }, + { + kind: 'option', + label: '-y, --yes', + description: 'Accept defaults without prompting', + }, + ], + }, + { + heading: 'COMPONENTS', + rows: [ + { + kind: 'command', + label: 'component list', + description: 'Show what the installed system offers', + }, + { + kind: 'command', + label: 'component install [name]', + description: 'Copy a component into your project', + }, + { + kind: 'option', + label: '-a, --all', + description: 'Install every available component', + }, + { + kind: 'option', + label: '-f, --force', + description: 'Replace an existing destination', + }, + { + kind: 'option', + label: '--dry-run', + description: 'Preview without writing files', + }, + { + kind: 'command', + label: 'component create [name]', + description: 'Generate a new local component', + }, + { + kind: 'option', + label: '-d, --directory ', + description: 'Variant structure to create it in', + }, + { + kind: 'option', + label: '-f, --format ', + description: 'Component format', + }, + { + kind: 'option', + label: '--dry-run', + description: 'Preview without writing files', + }, + ], + }, + { + heading: 'SYSTEMS', + rows: [ + { + kind: 'command', + label: 'system list', + description: 'Show built-in systems', + }, + { + kind: 'command', + label: 'system install [name]', + description: 'Add a component system to this project', + }, + { + kind: 'option', + label: '-r, --repository ', + description: 'Install from a git repository', + }, + { + kind: 'option', + label: '-c, --checkout ', + description: 'Commit, branch, or tag', + }, + { + kind: 'option', + label: '-a, --all', + description: 'Install every component, not just required', + }, + { + kind: 'command', + label: 'system create [name]', + description: 'Scaffold a system others can install from', + }, + ], + }, + { + heading: 'MAINTENANCE', + rows: [ + { + kind: 'command', + label: 'audit [args...]', + description: 'Run the Emulsify Core audit', + }, + { + kind: 'command', + label: 'cache clear', + description: 'Remove cached system repositories', + }, + { + kind: 'option', + label: '--dry-run', + description: 'Preview cache removal', + }, + ], + }, +]; + +function normalizeWidth(columns: number | undefined): number { + return typeof columns === 'number' && Number.isFinite(columns) && columns > 0 + ? Math.floor(columns) + : NATURAL_WIDTH; +} + +function wrapWords(text: string, width: number): string[] { + const lines: string[] = []; + let current = ''; + + for (const word of text.split(/\s+/)) { + if (current && current.length + word.length + 1 <= width) { + current += ` ${word}`; + continue; + } + + if (current) { + lines.push(current); + current = ''; + } + + let remainder = word; + while (remainder.length > width) { + lines.push(remainder.slice(0, width)); + remainder = remainder.slice(width); + } + current = remainder; + } + + if (current) { + lines.push(current); + } + + return lines; +} + +function wrapIndented(text: string, indent: number, width: number): string[] { + const usableIndent = Math.min(indent, Math.max(0, width - 1)); + const prefix = ' '.repeat(usableIndent); + const contentWidth = Math.max(1, width - usableIndent); + + return wrapWords(text, contentWidth).map((line) => `${prefix}${line}`); +} + +function renderStackedRow( + row: HelpRow, + width: number, + colors: HelpColors, +): string[] { + const labelIndent = row.kind === 'option' ? 6 : 2; + const descriptionIndent = row.kind === 'option' ? 8 : 4; + const style = row.kind === 'option' ? colors.dim : colors.bold; + const descriptionStyle = + row.kind === 'option' ? colors.dim : (value: string) => value; + + return [ + ...wrapIndented(row.label, labelIndent, width).map(style), + ...wrapIndented(row.description, descriptionIndent, width).map( + descriptionStyle, + ), + ]; +} + +function renderRow(row: HelpRow, width: number, colors: HelpColors): string[] { + const indent = row.kind === 'option' ? ' ' : ' '; + const columnWidth = + row.kind === 'option' ? OPTION_COLUMN_WIDTH : COMMAND_COLUMN_WIDTH; + const gap = ' '.repeat(Math.max(2, columnWidth - row.label.length)); + const plainLine = `${indent}${row.label}${gap}${row.description}`; + + if (width < NATURAL_WIDTH || plainLine.length > width) { + return renderStackedRow(row, width, colors); + } + + if (row.kind === 'option') { + return [colors.dim(plainLine)]; + } + + return [`${indent}${colors.bold(row.label)}${gap}${row.description}`]; +} + +function renderStep( + step: (typeof steps)[number], + index: number, + width: number, + colors: HelpColors, +): string[] { + const [command, description] = step; + const prefix = ` ${index + 1} `; + const gap = ' '.repeat(Math.max(2, STEP_COMMAND_WIDTH - command.length)); + const plainLine = `${prefix}${command}${gap}${description}`; + + if (width < NATURAL_WIDTH || plainLine.length > width) { + const commandPrefix = ` ${index + 1} `; + const continuationPrefix = ' '.repeat(commandPrefix.length); + const commandLines = wrapWords( + command, + Math.max(1, width - commandPrefix.length), + ); + + return [ + ...commandLines.map((line, commandLineIndex) => + colors.bold( + `${commandLineIndex === 0 ? commandPrefix : continuationPrefix}${line}`, + ), + ), + ...wrapIndented(description, 7, width), + ]; + } + + return [`${prefix}${colors.bold(command)}${gap}${description}`]; +} + +/** + * Render the root help text without writing to stdout. + */ +export default function getRootHelp({ + colors, + columns, + description, + productName, + version, +}: RootHelpOptions): string { + const width = normalizeWidth(columns); + const lines = [ + ...wrapWords(`${productName} ${version}`, width).map((line) => + colors.bold(colors.cyan(line)), + ), + '', + ...wrapWords(description, width), + '', + ...wrapIndented('New here? Run these in order:', 2, width).map(colors.bold), + ]; + + for (const [index, step] of steps.entries()) { + lines.push(...renderStep(step, index, width, colors)); + } + lines.push(''); + + for (const section of sections) { + lines.push(colors.bold(colors.cyan(section.heading))); + for (const row of section.rows) { + lines.push(...renderRow(row, width, colors)); + } + lines.push(''); + } + + const refresh = '--refresh works on every component command.'; + const switches = '-V, --version -h, --help'; + if (width >= NATURAL_WIDTH) { + lines.push(colors.dim(` ${refresh} ${switches}`)); + } else { + lines.push(...wrapIndented(refresh, 2, width).map(colors.dim)); + const switchLine = ` ${switches}`; + lines.push( + ...(switchLine.length <= width + ? [colors.dim(switchLine)] + : wrapIndented(switches, 2, width).map(colors.dim)), + ); + } + lines.push( + ...wrapIndented( + 'emulsify --help for the full option list.', + 2, + width, + ), + '', + ); + + return lines.join('\n'); +} diff --git a/src/lib/terminalColors.test.ts b/src/lib/terminalColors.test.ts new file mode 100644 index 0000000..2fb174e --- /dev/null +++ b/src/lib/terminalColors.test.ts @@ -0,0 +1,63 @@ +import getTerminalColors, { terminalSupportsColor } from './terminalColors.js'; + +const output = (isTTY: boolean | undefined) => + ({ isTTY }) as Pick; +const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY', +); +const originalNoColor = process.env.NO_COLOR; +const hadNoColor = Object.prototype.hasOwnProperty.call( + process.env, + 'NO_COLOR', +); + +describe('terminal colors', () => { + afterEach(() => { + if (originalIsTTYDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTYDescriptor); + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } + + if (hadNoColor) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } + }); + + it.each([ + ['stdout is not a TTY', false, {}, false], + ['stdout TTY state is unavailable', undefined, {}, false], + ['NO_COLOR is unset in a TTY', true, {}, true], + ['NO_COLOR has a value in a TTY', true, { NO_COLOR: '1' }, false], + ['NO_COLOR is empty in a TTY', true, { NO_COLOR: '' }, false], + ['NO_COLOR is zero in a TTY', true, { NO_COLOR: '0' }, false], + ])('%s', (_label, isTTY, environment, expected) => { + expect(terminalSupportsColor(output(isTTY), environment)).toBe(expected); + }); + + it('creates an ANSI-enabled palette when color is supported', () => { + expect(getTerminalColors(output(true), {}).cyan('message')).toContain( + '\u001b[', + ); + }); + + it('creates a plain palette when color is suppressed', () => { + expect( + getTerminalColors(output(true), { NO_COLOR: '1' }).cyan('message'), + ).toBe('message'); + }); + + it('uses the current process output and environment by default', () => { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + delete process.env.NO_COLOR; + + expect(terminalSupportsColor()).toBe(true); + expect(getTerminalColors().bold('message')).toContain('\u001b['); + }); +}); diff --git a/src/lib/terminalColors.ts b/src/lib/terminalColors.ts new file mode 100644 index 0000000..644ec0a --- /dev/null +++ b/src/lib/terminalColors.ts @@ -0,0 +1,30 @@ +import { createColors, type Colorette } from 'colorette'; + +type TerminalOutput = Pick; + +/** + * Determine whether CLI-authored color is appropriate for the current output. + * Color is deliberately limited to interactive stdout and disabled whenever + * NO_COLOR is present, regardless of its value. + */ +export function terminalSupportsColor( + output: TerminalOutput = process.stdout, + environment: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + output.isTTY === true && + !Object.prototype.hasOwnProperty.call(environment, 'NO_COLOR') + ); +} + +/** + * Return a Colorette palette configured with the CLI's shared color policy. + */ +export default function getTerminalColors( + output: TerminalOutput = process.stdout, + environment: NodeJS.ProcessEnv = process.env, +): Colorette { + return createColors({ + useColor: terminalSupportsColor(output, environment), + }); +} diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index 9b4b9fd..4044a1d 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -5,9 +5,9 @@ import { select, confirm } from '@inquirer/prompts'; import { promises as fs } from 'fs'; import { dirname } from 'path'; import { pathExists, remove } from 'fs-extra'; -import { cyan, green, bold, yellow } from 'colorette'; import log from '../../lib/log.js'; +import getTerminalColors from '../../lib/terminalColors.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import safeResolveWithin from '../fs/safeResolveWithin.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; @@ -25,17 +25,6 @@ import { buildYmlTemplate, } from './componentTemplates/index.js'; -const COMPONENT_FORMAT_CHOICES = [ - { - name: `${bold('Default')} (Standard Emulsify component)`, - value: 'default', - }, - { - name: `${bold('SDC')} (Single Directory Component for Drupal)`, - value: 'sdc', - }, -]; - const COMPONENT_FORMATS = ['default', 'sdc'] as const; type ComponentFormat = (typeof COMPONENT_FORMATS)[number]; @@ -83,6 +72,7 @@ export default async function generateComponent( componentName: string, options: CreateComponentHandlerOptions = {}, ): Promise { + const { bold, cyan, green, yellow } = getTerminalColors(); const { filename, className, camelName, snakeName, humanName } = deriveComponentNames(componentName); const providedFormat = options.format @@ -108,7 +98,16 @@ export default async function generateComponent( prompt: () => select({ message: cyan('Choose the component format:'), - choices: COMPONENT_FORMAT_CHOICES, + choices: [ + { + name: `${bold('Default')} (Standard Emulsify component)`, + value: 'default', + }, + { + name: `${bold('SDC')} (Single Directory Component for Drupal)`, + value: 'sdc', + }, + ], }), nonInteractive: { error: diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index fcd7cb6..95a7f85 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -95,11 +95,11 @@ function createGitRepository(directory, files) { ]); } -function runCli(cwd, args) { +function runCli(cwd, args, environment = isolatedEnvironment()) { const result = spawnSync(process.execPath, [cliPath, ...args], { cwd, encoding: 'utf8', - env: isolatedEnvironment(), + env: environment, shell: false, timeout: 15_000, windowsHide: true, @@ -264,6 +264,28 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(result.stdout, expectedRootHelp); }); + test('does not emit ANSI escapes when root help is piped', () => { + const environment = isolatedEnvironment(); + delete environment.NO_COLOR; + const result = runCli(tempRoot, [], environment); + + assert.equal(result.status, 0, commandFailure('piped root help', result)); + assert.equal(result.stderr, ''); + assert.doesNotMatch(result.stdout, /\u001b\[/u); + }); + + test('uses the supported format values in detailed component create help', () => { + const result = runCli(tempRoot, ['component', 'create', '--help']); + + assert.equal( + result.status, + 0, + commandFailure('component create --help', result), + ); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /--format /u); + }); + test('prints the package version', () => { const result = runCli(tempRoot, ['--version']); diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 36b0025..60695d7 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -2,95 +2,43 @@ Emulsify CLI {{version}} Build and use component systems in Drupal, WordPress, or standalone front ends. -Usage: - emulsify - emulsify --help - emulsify --help - emulsify init [name] [path] [options] - emulsify audit [...args] - emulsify system create [name] [options] - emulsify system install [name] [options] - emulsify component [options] - emulsify cache clear [options] - -Common workflow: - emulsify init - emulsify system install - emulsify component list - emulsify component install - emulsify component create - -Commands: - init [name] [path] - Create a project from a starter repository. Prompts for project name, target directory, - and platform when values are missing in an interactive terminal. - Options: - -m, --machineName Set the project folder/config machine name. - -s, --starter Use a custom starter repository. - -c, --checkout Checkout for the starter repository. - -p, --platform - Select the project platform when auto-detection is unavailable. - Built-in platforms: drupal, wordpress, none. - -y, --yes Accept defaults for missing init values without prompting. - - audit [...args] - Run the project-installed Emulsify Core audit with unchanged arguments, output, and exit status. - Run "emulsify audit --help" for Core-owned audit options. - - system list - List built-in component systems available for installation. Alias: system ls. - - system create [name] - Scaffold a standalone component-system repository. Missing values prompt in interactive terminals. - Options: - -d, --directory Parent directory for the new system repository. - -p, --platform Platform targets for the first variant. - --git Initialize a Git repository on branch main. - --no-git Do not initialize a Git repository. - --homepage Homepage URI for system.emulsify.json. - --repository Repository URI for system.emulsify.json. - -y, --yes Accept defaults for every missing value. - - system install [name] - Install a built-in or repository-backed component system. With no source in an interactive - terminal, guides you through system, component-set, scope, and review choices. - Options: - -r, --repository Install from a remote .git URL or local repository path. - -c, --checkout Checkout to use with --repository. - --variant Select an exact variant platform expression. - -a, --all Install every component in the selected variant. - -y, --yes Accept the final guided-install review without prompting. - - component list - List components available from the installed system and selected variant. Alias: component ls. - Options: - --refresh Check the system remote before reusing the local cache. - - component install [name] - Install one component, dependencies, or all components from the installed system. Alias: component i. - Options: - -f, --force Replace an existing component destination. - -a, --all Install all available components. - --dry-run Preview installs without writing files. - --refresh Check the system remote before reusing the local cache. - - component create [name] - Generate a new local component in this project. Alias: component c. - Options: - -d, --directory Variant structure where the component should be created. - -f, --format Component format to generate. - -y, --yes Replace existing generated components without prompting. - --dry-run Preview generated files without writing them. - --refresh Check the system remote before reusing the local cache. - - cache clear - Remove all locally cached Emulsify repositories. - Options: - --dry-run Report cache contents without removing files. - - help [command] - Show help for a command. - -Global options: - -V, --version Show the installed CLI version. - -h, --help Show this help output. + New here? Run these in order: + 1 emulsify init create the project + 2 emulsify system install pick a component system + 3 emulsify component list see what you can install + 4 emulsify component install add a component + +PROJECTS + init [name] [path] Create a project from a starter repository + -m, --machineName Folder and config machine name + -s, --starter Use a custom starter repository + -c, --checkout Starter commit, branch, or tag + -p, --platform none | drupal | wordpress + -y, --yes Accept defaults without prompting + +COMPONENTS + component list Show what the installed system offers + component install [name] Copy a component into your project + -a, --all Install every available component + -f, --force Replace an existing destination + --dry-run Preview without writing files + component create [name] Generate a new local component + -d, --directory Variant structure to create it in + -f, --format Component format + --dry-run Preview without writing files + +SYSTEMS + system list Show built-in systems + system install [name] Add a component system to this project + -r, --repository Install from a git repository + -c, --checkout Commit, branch, or tag + -a, --all Install every component, not just required + system create [name] Scaffold a system others can install from + +MAINTENANCE + audit [args...] Run the Emulsify Core audit + cache clear Remove cached system repositories + --dry-run Preview cache removal + + --refresh works on every component command. -V, --version -h, --help + emulsify --help for the full option list. From ff0f143ace2b103187e790b3f7c1c56c3647dee2 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 02:56:58 -0500 Subject: [PATCH 07/33] feat(system): add a command to detach the configured system --- README.md | 18 +- docs/README.md | 26 +-- docs/cli-reference.md | 39 ++++ docs/systems.md | 42 ++++ src/handlers/hofs/withEmulsifySystem.test.ts | 4 +- src/handlers/hofs/withEmulsifySystem.ts | 2 +- src/handlers/systemDetach.test.ts | 204 +++++++++++++++++ src/handlers/systemDetach.ts | 78 +++++++ src/handlers/systemInstall.test.ts | 2 +- src/handlers/systemInstall.ts | 2 +- src/index.ts | 8 +- src/lib/rootHelp.test.ts | 2 + src/lib/rootHelp.ts | 10 + src/types/handlers.d.ts | 5 + src/util/project/unsetEmulsifyConfig.test.ts | 119 ++++++++++ src/util/project/unsetEmulsifyConfig.ts | 36 +++ test/e2e/cli.test.mjs | 218 +++++++++++++++++++ test/e2e/root-help.txt | 2 + 18 files changed, 797 insertions(+), 20 deletions(-) create mode 100644 src/handlers/systemDetach.test.ts create mode 100644 src/handlers/systemDetach.ts create mode 100644 src/util/project/unsetEmulsifyConfig.test.ts create mode 100644 src/util/project/unsetEmulsifyConfig.ts diff --git a/README.md b/README.md index d6d8cd7..3e0922b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,20 @@ This creates `./systems/my-system` with valid system and variant configuration, an installable `example-card` component, repository documentation, a `.gitignore`, and a license placeholder to replace before distribution. +When components installed from another system have evolved into the basis of +your own, detach the configured system before authoring a replacement: + +```bash +emulsify system detach +``` + +Detaching removes only the `system` and `variant` entries from +`project.emulsify.json`. Components, project assets, and the cached system +repository stay in place. Run `system create` to scaffold a new system +repository, then move or copy the preserved components into that scaffold and +update `system.emulsify.json`; `system create` does not import them +automatically. + Interactive terminals can run `emulsify component create` with no arguments to walk through the component name, format, and directory prompts. Likewise, `emulsify component install` with no name presents the components available in @@ -74,6 +88,7 @@ emulsify component install card --force # Or install every available component: emulsify component install --all emulsify component create promo-card --directory molecules --format default --yes +emulsify system detach --yes ``` For component installation, provide either a component name or `--all`, and use @@ -89,7 +104,7 @@ Detailed documentation lives in [docs](./docs/README.md). | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | [CLI Reference](./docs/cli-reference.md) | Looking up commands, aliases, options, and examples. | | [Project Initialization](./docs/project-initialization.md) | Creating a new Emulsify project from a starter. | -| [Systems](./docs/systems.md) | Listing, installing, or authoring component systems. | +| [Systems](./docs/systems.md) | Listing, installing, detaching, or authoring component systems. | | [Components](./docs/components.md) | Listing, installing, dry-running, or creating components. | | [Project Configuration](./docs/configuration.md) | Understanding `project.emulsify.json`, variants, and structure mappings. | | [Component Template Overrides](./docs/component-template-overrides.md) | Customizing files generated by `emulsify component create`. | @@ -106,6 +121,7 @@ Detailed documentation lives in [docs](./docs/README.md). | `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | | `emulsify system create [name]` | | Creates a standalone component-system repository. | | `emulsify system install [name]` | | Installs a system in the current Emulsify project. | +| `emulsify system detach` | | Detaches the system and keeps project components. | | `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. | diff --git a/docs/README.md b/docs/README.md index b15962d..1228d8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,16 +4,16 @@ Build and use component systems in Drupal, WordPress, or standalone front ends. These docs expand on the short project README and are organized by the task a project user or maintainer is usually trying to complete. -| Topic | Use This When | -| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| [CLI Reference](./cli-reference.md) | Looking up commands, aliases, options, and examples. | -| [Project Initialization](./project-initialization.md) | Creating a new Emulsify project from a starter, including Drupal, WordPress, and non-interactive examples. | -| [Systems](./systems.md) | Listing built-in systems, installing systems, understanding variant compatibility, or using custom system repositories. | -| [Components](./components.md) | Listing installable components, installing components and dependencies, using dry runs, and creating local components. | -| [Project Configuration](./configuration.md) | Understanding `project.emulsify.json`, system and variant references, structure mappings, and validation. | -| [Component Template Overrides](./component-template-overrides.md) | Replacing the built-in `component create` templates with project-level templates. | -| [Hooks And Cache](./hooks-and-cache.md) | Understanding starter hooks, system install hooks, script execution, and the `~/.emulsify/cache` repository cache. | -| [Development](./development.md) | Setting up this repository, understanding source layout, and running checks. | -| [Release](./release.md) | Understanding CI, develop version bumps, semantic-release, and npm publishing. | -| [Contributors](./contributors.md) | Viewing project contributors moved out of the root README. | -| [Website Usage Copy](./emulsify-info-cli-updates.md) | Copy-ready usage content for the `emulsify.info` CLI page. | +| Topic | Use This When | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| [CLI Reference](./cli-reference.md) | Looking up commands, aliases, options, and examples. | +| [Project Initialization](./project-initialization.md) | Creating a new Emulsify project from a starter, including Drupal, WordPress, and non-interactive examples. | +| [Systems](./systems.md) | Listing, installing, detaching, or authoring systems, including custom repositories and variant compatibility. | +| [Components](./components.md) | Listing installable components, installing components and dependencies, using dry runs, and creating local components. | +| [Project Configuration](./configuration.md) | Understanding `project.emulsify.json`, system and variant references, structure mappings, and validation. | +| [Component Template Overrides](./component-template-overrides.md) | Replacing the built-in `component create` templates with project-level templates. | +| [Hooks And Cache](./hooks-and-cache.md) | Understanding starter hooks, system install hooks, script execution, and the `~/.emulsify/cache` repository cache. | +| [Development](./development.md) | Setting up this repository, understanding source layout, and running checks. | +| [Release](./release.md) | Understanding CI, develop version bumps, semantic-release, and npm publishing. | +| [Contributors](./contributors.md) | Viewing project contributors moved out of the root README. | +| [Website Usage Copy](./emulsify-info-cli-updates.md) | Copy-ready usage content for the `emulsify.info` CLI page. | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a014878..24bafe9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -19,6 +19,7 @@ The examples below reflect the command definitions in `src/index.ts` and the gen | `emulsify system list` | `emulsify system ls` | List built-in systems available for installation. | | `emulsify system create [name]` | | Create a standalone, distributable component system. | | `emulsify system install [name]` | | Install a system in the current Emulsify project. | +| `emulsify system detach` | | Detach the system and keep project components. | | `emulsify component list` | `emulsify component ls` | List components available from the installed system and variant. | | `emulsify component install [name]` | `emulsify component i [name]` | Install a component from the installed system and variant. | | `emulsify component create [name]` | `emulsify component c [name]` | Generate a new local component in the current project. | @@ -298,6 +299,44 @@ Project configuration uses only concrete `project.platform` values: `drupal`, `w Pass `--variant` to choose an exact variant platform expression instead of automatic compatibility selection. Quote a shared expression at the shell, for example `--variant "drupal || wordpress"`. +## `system detach` + +```bash +emulsify system detach +``` + +Detaches the configured component system from the current Emulsify project. The +command rewrites only `project.emulsify.json`, removing its top-level `system` +and `variant` entries while preserving every other configuration value. +Components, project assets, generated files, and the cached system repository +are not edited or removed. + +Options: + +| Option | Description | +| ----------- | --------------------------------------------------------- | +| `-y, --yes` | Confirm detachment without opening an interactive prompt. | + +Interactive terminals ask for confirmation before changing the configuration. +Declining reports cancellation and leaves the project unchanged. When standard +input is not a TTY, omit `--yes` and the command fails immediately with a message +naming the flag instead of prompting or proceeding silently: + +```bash +emulsify system detach --yes +``` + +The command also fails when no Emulsify project can be found or when the project +has no configured system; the latter is reported as a no-op rather than success. +After a successful detach, `emulsify system install` can configure a system +again. Use `emulsify cache clear` separately if all cached repositories should +be removed. + +To turn refined project components into a system, detach first, run +`emulsify system create` to create a fresh repository, then move or copy the +preserved components into that scaffold and update its `system.emulsify.json`. +`system create` does not import project components automatically. + ## `component list` ```bash diff --git a/docs/systems.md b/docs/systems.md index 39cc9c7..b85e319 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -172,6 +172,48 @@ the essentials-only default remain deterministic unless `--variant` or `--all` is passed. Because `--yes` only accepts the final guided review, it does not supply a missing source or make bare `system install --yes` valid outside a TTY. +## Detach A System + +Detach a system when components installed from it have been refined into the +basis of a new system: + +```bash +emulsify system detach +``` + +The command removes only the top-level `system` and `variant` entries from the +nearest `project.emulsify.json`. It does not edit or remove components, project +assets, generated files, or any other configuration. The cached system clone is +also retained, making it quick to install the same system again. Use +`emulsify cache clear` separately when every cached repository should be +removed. + +Interactive terminals ask for confirmation before writing. Declining leaves the +project unchanged. In CI, scripts, and terminals without an interactive input, +pass `--yes`; without it, the command fails immediately and names the required +flag: + +```bash +emulsify system detach --yes +``` + +The command reports the detached system by name and confirms that project +components remain in place. With the system and variant references gone, +`emulsify system install` can configure a system again. + +For the refine-then-publish workflow: + +1. Install a system and refine its copied components in the project. +2. Run `emulsify system detach`; the refined files remain byte-for-byte intact. +3. Run `emulsify system create` to scaffold a new system repository. +4. Move or copy the preserved components into the scaffold, replace the example + component, and update the variant mappings and component definitions in + `system.emulsify.json`. +5. Commit, tag, and install the new repository in another project. + +`system create` creates a fresh scaffold and does not import components from the +detached project automatically. + ## Author A Standalone System `system create` generates a complete, distributable system repository. It is a standalone command: run it inside or outside an Emulsify project, and it will not read or update `project.emulsify.json`. diff --git a/src/handlers/hofs/withEmulsifySystem.test.ts b/src/handlers/hofs/withEmulsifySystem.test.ts index bab173d..154f0a9 100644 --- a/src/handlers/hofs/withEmulsifySystem.test.ts +++ b/src/handlers/hofs/withEmulsifySystem.test.ts @@ -183,7 +183,7 @@ describe('withEmulsifySystem', () => { expect(error).toBeInstanceOf(EmulsifySystemError); expect(error).toMatchObject({ message: - 'The cached copy of the compound system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, remove the existing system and variant entries from project.emulsify.json, then re-run "emulsify system install".', + 'The cached copy of the compound system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, run "emulsify system detach" followed by "emulsify system install".', }); }); @@ -202,7 +202,7 @@ describe('withEmulsifySystem', () => { expect(error).not.toBeInstanceOf(TypeError); expect(error).toMatchObject({ message: - 'The cached copy of the compound system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, remove the existing system and variant entries from project.emulsify.json, then re-run "emulsify system install".', + 'The cached copy of the compound system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, run "emulsify system detach" followed by "emulsify system install".', }); }); diff --git a/src/handlers/hofs/withEmulsifySystem.ts b/src/handlers/hofs/withEmulsifySystem.ts index 47664c9..e2ce0cb 100644 --- a/src/handlers/hofs/withEmulsifySystem.ts +++ b/src/handlers/hofs/withEmulsifySystem.ts @@ -175,7 +175,7 @@ export async function withEmulsifySystem( if (!validation.valid) { throw new EmulsifySystemError( - `The cached copy of the ${systemName} system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, remove the existing system and variant entries from project.emulsify.json, then re-run "emulsify system install".`, + `The cached copy of the ${systemName} system is invalid. Run "emulsify cache clear" and retry this command to re-clone it. To reinstall the system instead, run "emulsify system detach" followed by "emulsify system install".`, ); } diff --git a/src/handlers/systemDetach.test.ts b/src/handlers/systemDetach.test.ts new file mode 100644 index 0000000..4858db0 --- /dev/null +++ b/src/handlers/systemDetach.test.ts @@ -0,0 +1,204 @@ +/** + * @file Unit tests for the system detach handler. + */ + +jest.mock('../lib/log', () => jest.fn()); +jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); +jest.mock('../util/project/unsetEmulsifyConfig', () => jest.fn()); +jest.mock('@inquirer/prompts'); + +import { confirm } from '@inquirer/prompts'; + +import log from '../lib/log.js'; +import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; +import unsetEmulsifyConfig from '../util/project/unsetEmulsifyConfig.js'; +import systemDetach from './systemDetach.js'; + +const confirmMock = confirm as jest.Mock; +const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; +const logMock = log as jest.Mock; +const unsetEmulsifyConfigMock = unsetEmulsifyConfig as jest.Mock; +const originalStdinIsTTY = process.stdin.isTTY; + +const projectConfig = { + project: { + platform: 'none', + name: 'Fixture Project', + machineName: 'fixture-project', + }, + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter.git', + }, + system: { + repository: 'https://github.com/example/fixture-system.git', + checkout: 'main', + }, + variant: { + platform: 'none', + structureImplementations: [ + { + name: 'components', + directory: 'components', + }, + ], + }, +}; + +function setStdinIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + }); +} + +function expectNoConfigMutation(): void { + expect(unsetEmulsifyConfigMock).not.toHaveBeenCalled(); +} + +describe('systemDetach', () => { + beforeEach(() => { + jest.clearAllMocks(); + setStdinIsTTY(false); + confirmMock.mockReset(); + getEmulsifyConfigMock.mockResolvedValue(projectConfig); + unsetEmulsifyConfigMock.mockResolvedValue(undefined); + }); + + afterAll(() => { + setStdinIsTTY(originalStdinIsTTY); + }); + + it('confirms interactively, removes only the system keys, and reassures the user', async () => { + setStdinIsTTY(true); + confirmMock.mockResolvedValueOnce(true); + + await systemDetach(); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('fixture-system'), + }), + ); + expect(unsetEmulsifyConfigMock).toHaveBeenCalledTimes(1); + expect(unsetEmulsifyConfigMock).toHaveBeenCalledWith('system', 'variant'); + expect(logMock).toHaveBeenCalledWith( + 'success', + 'Detached the fixture-system system at main. All component files were left in place.', + ); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'Next: run "emulsify system create" to scaffold your own system repository, then replace its example content with the components preserved in this project.', + ); + }); + + it('uses --yes in a non-interactive terminal without opening a prompt', async () => { + setStdinIsTTY(undefined); + + await systemDetach({ yes: true }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(unsetEmulsifyConfigMock).toHaveBeenCalledWith('system', 'variant'); + expect(logMock).toHaveBeenCalledWith( + 'success', + 'Detached the fixture-system system at main. All component files were left in place.', + ); + }); + + it('throws a clear error when no Emulsify project is detected', async () => { + getEmulsifyConfigMock.mockResolvedValueOnce(undefined); + + await expect(systemDetach({ yes: true })).rejects.toMatchObject({ + name: 'CliError', + message: + 'No Emulsify project detected. Run this command within an existing Emulsify project.', + exitCode: 1, + }); + + expect(confirmMock).not.toHaveBeenCalled(); + expectNoConfigMutation(); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('states plainly when no component system is configured', async () => { + const { system: _system, ...configWithoutSystem } = projectConfig; + getEmulsifyConfigMock.mockResolvedValueOnce(configWithoutSystem); + + await expect(systemDetach({ yes: true })).rejects.toMatchObject({ + name: 'CliError', + message: 'No component system is configured for this Emulsify project.', + exitCode: 1, + }); + + expect(confirmMock).not.toHaveBeenCalled(); + expectNoConfigMutation(); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('leaves the configuration untouched when confirmation is declined', async () => { + setStdinIsTTY(true); + confirmMock.mockResolvedValueOnce(false); + + await systemDetach(); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expectNoConfigMutation(); + expect(logMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + 'info', + 'System detach cancelled. No project files were changed.', + ); + }); + + it('fails before prompting or writing in a non-interactive terminal without --yes', async () => { + setStdinIsTTY(false); + + await expect(systemDetach()).rejects.toMatchObject({ + name: 'CliError', + message: + 'System detachment requires confirmation in non-interactive mode. Pass --yes to detach the configured system.', + exitCode: 1, + }); + + expect(confirmMock).not.toHaveBeenCalled(); + expectNoConfigMutation(); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('reports a configuration write failure without claiming success', async () => { + unsetEmulsifyConfigMock.mockRejectedValueOnce(new Error('disk full')); + + await expect(systemDetach({ yes: true })).rejects.toMatchObject({ + name: 'CliError', + message: + 'Unable to detach the configured system from project.emulsify.json.', + exitCode: 1, + }); + + expect(unsetEmulsifyConfigMock).toHaveBeenCalledWith('system', 'variant'); + expect(logMock).not.toHaveBeenCalled(); + }); + + it.each([ + 'https://github.com/example/.git', + 'https://github.com/example/fixture-system', + ])( + 'uses a safe label when the configured repository name cannot be parsed: %s', + async (repository) => { + getEmulsifyConfigMock.mockResolvedValueOnce({ + ...projectConfig, + system: { + ...projectConfig.system, + repository, + }, + }); + + await systemDetach({ yes: true }); + + expect(logMock).toHaveBeenCalledWith( + 'success', + 'Detached the configured component system at main. All component files were left in place.', + ); + }, + ); +}); diff --git a/src/handlers/systemDetach.ts b/src/handlers/systemDetach.ts new file mode 100644 index 0000000..01664ce --- /dev/null +++ b/src/handlers/systemDetach.ts @@ -0,0 +1,78 @@ +import type { DetachSystemHandlerOptions } from '@emulsify-cli/handlers'; + +import { confirm } from '@inquirer/prompts'; + +import CliError from '../lib/CliError.js'; +import log from '../lib/log.js'; +import getGitRepoNameFromUrl from '../util/getGitRepoNameFromUrl.js'; +import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; +import unsetEmulsifyConfig from '../util/project/unsetEmulsifyConfig.js'; +import { runPrompt } from '../util/prompt/index.js'; + +const CONFIGURED_SYSTEM_LABEL = 'configured component system'; + +function getSystemLabel(repository: string): string { + try { + const name = getGitRepoNameFromUrl(repository); + return name ? `${name} system` : CONFIGURED_SYSTEM_LABEL; + } catch { + return CONFIGURED_SYSTEM_LABEL; + } +} + +/** + * Detach the configured component system without changing component files. + */ +export default async function systemDetach({ + yes = false, +}: DetachSystemHandlerOptions = {}): Promise { + const projectConfig = await getEmulsifyConfig(); + if (!projectConfig) { + throw new CliError( + 'No Emulsify project detected. Run this command within an existing Emulsify project.', + ); + } + + if (!projectConfig.system) { + throw new CliError( + 'No component system is configured for this Emulsify project.', + ); + } + + const systemLabel = getSystemLabel(projectConfig.system.repository); + const systemReference = `${systemLabel} at ${projectConfig.system.checkout}`; + const confirmed = await runPrompt({ + prompt: () => + confirm({ + message: `Detach the ${systemReference} from this project? Component files will be left in place.`, + default: false, + }), + nonInteractive: { + error: + 'System detachment requires confirmation in non-interactive mode. Pass --yes to detach the configured system.', + }, + accept: { when: yes, value: true }, + }); + + if (!confirmed) { + log('info', 'System detach cancelled. No project files were changed.'); + return; + } + + try { + await unsetEmulsifyConfig('system', 'variant'); + } catch { + throw new CliError( + 'Unable to detach the configured system from project.emulsify.json.', + ); + } + + log( + 'success', + `Detached the ${systemReference}. All component files were left in place.`, + ); + log( + 'info', + 'Next: run "emulsify system create" to scaffold your own system repository, then replace its example content with the components preserved in this project.', + ); +} diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 4329d1e..17a3a8f 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -366,7 +366,7 @@ describe('systemInstall', () => { }); await expect(systemInstall('compound', {})).rejects.toThrow( - 'This Emulsify project already has a component system configured. Run "emulsify component list" to see what is available. To choose a different system, remove the existing "system" and "variant" entries from project.emulsify.json, then run "emulsify system install" again.', + 'This Emulsify project already has a component system configured. Run "emulsify component list" to see what is available. To choose a different system, run "emulsify system detach" first.', ); }); diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index b01929e..e1f45d8 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -563,7 +563,7 @@ export default async function systemInstall( if (projectConfig.system) { throw new CliError( - 'This Emulsify project already has a component system configured. Run "emulsify component list" to see what is available. To choose a different system, remove the existing "system" and "variant" entries from project.emulsify.json, then run "emulsify system install" again.', + 'This Emulsify project already has a component system configured. Run "emulsify component list" to see what is available. To choose a different system, run "emulsify system detach" first.', ); } diff --git a/src/index.ts b/src/index.ts index ce5c574..88316d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import init from './handlers/init.js'; import systemList from './handlers/systemList.js'; import systemInstall from './handlers/systemInstall.js'; import systemCreate from './handlers/systemCreate.js'; +import systemDetach from './handlers/systemDetach.js'; import componentList from './handlers/componentList.js'; import componentInstall from './handlers/componentInstall.js'; import componentCreate from './handlers/componentCreate.js'; @@ -58,7 +59,7 @@ program // System sub-commands. const system = program .command('system') - .description('List, create, or install component systems'); + .description('List, create, install, or detach component systems'); system .command('list') .description('List built-in systems available for installation') @@ -108,6 +109,11 @@ system 'Accept the final guided-install review without prompting.', ) .action(systemInstall); +system + .command('detach') + .description('Detach the configured system and keep project components') + .option('-y, --yes', 'Detach without prompting for confirmation.') + .action(systemDetach); // Component sub-commands. const component = program diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index bc32931..1a27514 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -30,6 +30,8 @@ describe('getRootHelp', () => { expect(help).toContain('PROJECTS'); expect(help).toContain('COMPONENTS'); expect(help).toContain('SYSTEMS'); + expect(help).toContain('system detach'); + expect(help).toContain('Detach the system and keep project components'); expect(help).toContain('system create [name]'); expect(help).toContain('MAINTENANCE'); expect(help).toContain('audit [args...]'); diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index 838ec95..d92f0b3 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -147,6 +147,16 @@ const sections: HelpSection[] = [ label: '-a, --all', description: 'Install every component, not just required', }, + { + kind: 'command', + label: 'system detach', + description: 'Detach the system and keep project components', + }, + { + kind: 'option', + label: '-y, --yes', + description: 'Skip the confirmation prompt', + }, { kind: 'command', label: 'system create [name]', diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index a20db2a..8faad22 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -41,6 +41,11 @@ declare module '@emulsify-cli/handlers' { yes?: boolean; }; + export type DetachSystemHandlerOptions = { + /** Detach the configured system without prompting for confirmation. */ + yes?: boolean; + }; + export type ListComponentHandlerOptions = { /** Check the configured system's remote ref before reusing its local cache entry. */ refresh?: boolean; diff --git a/src/util/project/unsetEmulsifyConfig.test.ts b/src/util/project/unsetEmulsifyConfig.test.ts new file mode 100644 index 0000000..6c787b1 --- /dev/null +++ b/src/util/project/unsetEmulsifyConfig.test.ts @@ -0,0 +1,119 @@ +import type { EmulsifyProjectConfiguration } from '@emulsify-cli/config'; + +jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); +jest.mock('../fs/writeToJsonFile', () => jest.fn()); +jest.mock('./getEmulsifyConfig', () => jest.fn()); + +import { promises as fs } from 'fs'; +import { copy, remove } from 'fs-extra'; + +import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; +import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +import writeToJsonFile from '../fs/writeToJsonFile.js'; +import getEmulsifyConfig from './getEmulsifyConfig.js'; +import unsetEmulsifyConfig from './unsetEmulsifyConfig.js'; + +const configPath = '/project/project.emulsify.json'; +const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; +const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; +const writeToJsonFileMock = writeToJsonFile as jest.Mock; +const copyMock = copy as jest.Mock; +const removeMock = remove as jest.Mock; +const mkdirMock = fs.mkdir as jest.Mock; +const copyFileMock = fs.copyFile as jest.Mock; +const renameMock = fs.rename as jest.Mock; +const rmMock = fs.rm as jest.Mock; +const writeFileMock = fs.writeFile as jest.Mock; + +const projectConfig: EmulsifyProjectConfiguration = { + project: { + platform: 'none', + name: 'Fixture Project', + machineName: 'fixture-project', + description: 'Configuration fields unrelated to the system are preserved.', + }, + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter.git', + }, + assets: { + roots: ['./assets'], + rebase: true, + }, + system: { + repository: 'https://github.com/example/fixture-system.git', + checkout: 'main', + }, + variant: { + platform: 'none', + structureImplementations: [ + { + name: 'components', + directory: 'components', + }, + ], + }, +}; + +describe('unsetEmulsifyConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + findFileInCurrentPathMock.mockReturnValue(configPath); + getEmulsifyConfigMock.mockResolvedValue(projectConfig); + writeToJsonFileMock.mockResolvedValue(undefined); + }); + + it('writes the complete project configuration with only the requested keys removed', async () => { + await unsetEmulsifyConfig('system', 'variant'); + + expect(findFileInCurrentPathMock).toHaveBeenCalledTimes(1); + expect(findFileInCurrentPathMock).toHaveBeenCalledWith( + EMULSIFY_PROJECT_CONFIG_FILE, + ); + expect(getEmulsifyConfigMock).toHaveBeenCalledTimes(1); + expect(writeToJsonFileMock).toHaveBeenCalledTimes(1); + expect(writeToJsonFileMock).toHaveBeenCalledWith(configPath, { + project: projectConfig.project, + starter: projectConfig.starter, + assets: projectConfig.assets, + }); + + // The helper delegates its sole write to the project-config writer. It + // must never copy, rename, or remove project/component filesystem entries. + expect(writeFileMock).not.toHaveBeenCalled(); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(copyFileMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + expect(copyMock).not.toHaveBeenCalled(); + expect(removeMock).not.toHaveBeenCalled(); + }); + + it('throws without writing when no project configuration path is found', async () => { + findFileInCurrentPathMock.mockReturnValueOnce(undefined); + + await expect(unsetEmulsifyConfig('system')).rejects.toThrow(); + + expect(writeToJsonFileMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + expect(removeMock).not.toHaveBeenCalled(); + }); + + it('throws without writing when the project configuration cannot be loaded', async () => { + getEmulsifyConfigMock.mockResolvedValueOnce(undefined); + + await expect(unsetEmulsifyConfig('variant')).rejects.toThrow(); + + expect(writeToJsonFileMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); + expect(removeMock).not.toHaveBeenCalled(); + }); + + it('preserves the complete configuration when no keys are requested', async () => { + await unsetEmulsifyConfig(); + + expect(writeToJsonFileMock).toHaveBeenCalledTimes(1); + expect(writeToJsonFileMock).toHaveBeenCalledWith(configPath, projectConfig); + expect(rmMock).not.toHaveBeenCalled(); + expect(removeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/util/project/unsetEmulsifyConfig.ts b/src/util/project/unsetEmulsifyConfig.ts new file mode 100644 index 0000000..49d8b78 --- /dev/null +++ b/src/util/project/unsetEmulsifyConfig.ts @@ -0,0 +1,36 @@ +import type { EmulsifyProjectConfiguration } from '@emulsify-cli/config'; + +import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; +import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +import writeToJsonFile from '../fs/writeToJsonFile.js'; +import getEmulsifyConfig from './getEmulsifyConfig.js'; + +type OptionalProjectConfigKey = Exclude< + keyof EmulsifyProjectConfiguration, + 'project' | 'starter' +>; + +/** + * Remove optional top-level values from the current Emulsify project config. + * + * Unlike setEmulsifyConfig, this writes the complete remaining object because + * a recursive merge cannot remove existing keys. + */ +export default async function unsetEmulsifyConfig( + ...keys: OptionalProjectConfigKey[] +): Promise { + const path = findFileInCurrentPath(EMULSIFY_PROJECT_CONFIG_FILE); + const existingConfig = await getEmulsifyConfig(); + if (!path || !existingConfig) { + throw new Error( + `Unable to remove values from ${EMULSIFY_PROJECT_CONFIG_FILE} because you are not in an Emulsify project`, + ); + } + + const updatedConfig = { ...existingConfig }; + for (const key of keys) { + delete updatedConfig[key]; + } + + await writeToJsonFile(path, updatedConfig); +} diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 95a7f85..1bf0260 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from 'node:fs'; @@ -95,6 +96,38 @@ function createGitRepository(directory, files) { ]); } +function snapshotFiles(directory, excludedRelativePaths = new Set()) { + const files = {}; + + function visit(currentDirectory, relativeDirectory = '') { + const entries = readdirSync(currentDirectory, { + withFileTypes: true, + }).sort((first, second) => first.name.localeCompare(second.name)); + + for (const entry of entries) { + const relativePath = relativeDirectory + ? join(relativeDirectory, entry.name) + : entry.name; + if (excludedRelativePaths.has(relativePath)) { + continue; + } + + const absolutePath = join(currentDirectory, entry.name); + if (entry.isDirectory()) { + visit(absolutePath, relativePath); + } else { + files[relativePath] = readFileSync(absolutePath).toString('base64'); + } + } + } + + if (existsSync(directory)) { + visit(directory); + } + + return files; +} + function runCli(cwd, args, environment = isolatedEnvironment()) { const result = spawnSync(process.execPath, [cliPath, ...args], { cwd, @@ -455,6 +488,191 @@ describe('built Emulsify CLI', { concurrency: false }, () => { } }); + test('detaches a system without changing component files and permits the next system workflow', () => { + const detachProjectRoot = join(projectsRoot, 'detach-project'); + const projectConfigPath = join(detachProjectRoot, 'project.emulsify.json'); + const refinedComponentPath = join( + detachProjectRoot, + 'components', + 'card', + 'fixture.txt', + ); + const cacheRoot = join(isolatedHome, '.emulsify', 'cache'); + const generatedSystemName = 'detached-project-system'; + const generatedSystemRoot = join(tempRoot, generatedSystemName); + + mkdirSync(detachProjectRoot, { recursive: true }); + writeFileSync( + projectConfigPath, + json({ + project: { + platform: 'wordpress', + name: 'Detach Project', + machineName: 'detach-project', + }, + starter: { + repository: starterRepository, + }, + }), + ); + + const installResult = runCli(detachProjectRoot, [ + 'system', + 'install', + '--repository', + systemRepository, + '--checkout', + 'main', + '--variant', + 'wordpress', + ]); + assert.equal( + installResult.status, + 0, + commandFailure('detach fixture system install', installResult), + ); + assert.equal(readFileSync(refinedComponentPath, 'utf8'), 'card fixture\n'); + + const refinedComponent = Buffer.from( + 'locally refined card\nwith project-specific changes\n', + 'utf8', + ); + writeFileSync(refinedComponentPath, refinedComponent); + mkdirSync(join(detachProjectRoot, 'notes'), { recursive: true }); + writeFileSync( + join(detachProjectRoot, 'notes', 'unrelated.bin'), + Buffer.from([0, 1, 2, 127, 128, 254, 255]), + ); + + const installedConfigContents = readFileSync(projectConfigPath, 'utf8'); + const installedConfig = JSON.parse(installedConfigContents); + const projectFilesBefore = snapshotFiles( + detachProjectRoot, + new Set(['project.emulsify.json']), + ); + const cacheFilesBefore = snapshotFiles(cacheRoot); + assert.ok( + Object.keys(cacheFilesBefore).length > 0, + 'system install should populate the isolated cache', + ); + + const rejectedResult = runCli(detachProjectRoot, ['system', 'detach']); + assert.notEqual(rejectedResult.status, 0); + assert.equal(rejectedResult.stdout, ''); + assert.match(rejectedResult.stderr, /--yes/u); + assert.equal( + readFileSync(projectConfigPath, 'utf8'), + installedConfigContents, + ); + assert.deepEqual( + snapshotFiles(detachProjectRoot, new Set(['project.emulsify.json'])), + projectFilesBefore, + ); + assert.deepEqual(snapshotFiles(cacheRoot), cacheFilesBefore); + + const detachResult = runCli(detachProjectRoot, [ + 'system', + 'detach', + '--yes', + ]); + assert.equal( + detachResult.status, + 0, + commandFailure('system detach', detachResult), + ); + assert.equal(detachResult.stderr, ''); + assert.match(detachResult.stdout, /Detached the fixture-system system/u); + assert.match( + detachResult.stdout, + /All component files were left in place/u, + ); + + const detachedConfig = JSON.parse(readFileSync(projectConfigPath, 'utf8')); + const { + system: _installedSystem, + variant: _installedVariant, + ...expectedDetachedConfig + } = installedConfig; + assert.deepEqual(detachedConfig, expectedDetachedConfig); + assert.equal(Object.hasOwn(detachedConfig, 'system'), false); + assert.equal(Object.hasOwn(detachedConfig, 'variant'), false); + assert.deepEqual( + snapshotFiles(detachProjectRoot, new Set(['project.emulsify.json'])), + projectFilesBefore, + 'detach must not write or remove any project file except project.emulsify.json', + ); + assert.deepEqual( + snapshotFiles(cacheRoot), + cacheFilesBefore, + 'detach must leave the cached clone intact', + ); + assert.deepEqual(readFileSync(refinedComponentPath), refinedComponent); + + const createResult = runCli(detachProjectRoot, [ + 'system', + 'create', + generatedSystemName, + '--directory', + tempRoot, + '--platform', + 'wordpress', + '--no-git', + ]); + assert.equal( + createResult.status, + 0, + commandFailure('system create from detached project', createResult), + ); + const generatedSystemConfig = JSON.parse( + readFileSync(join(generatedSystemRoot, 'system.emulsify.json'), 'utf8'), + ); + assert.deepEqual( + generatedSystemConfig.variants[0].components.map(({ name }) => name), + ['example-card'], + 'system create should make a fresh scaffold rather than importing project components', + ); + assert.equal( + existsSync(join(generatedSystemRoot, 'components', 'card')), + false, + ); + assert.deepEqual( + snapshotFiles(detachProjectRoot, new Set(['project.emulsify.json'])), + projectFilesBefore, + ); + + const reinstallResult = runCli(detachProjectRoot, [ + 'system', + 'install', + '--repository', + systemRepository, + '--checkout', + 'main', + '--variant', + 'none', + ]); + assert.equal( + reinstallResult.status, + 0, + commandFailure('system reinstall after detach', reinstallResult), + ); + const reinstalledConfig = JSON.parse( + readFileSync(projectConfigPath, 'utf8'), + ); + assert.deepEqual(reinstalledConfig.system, { + repository: systemRepository, + checkout: 'main', + }); + assert.equal(reinstalledConfig.variant.platform, 'none'); + assert.deepEqual(readFileSync(refinedComponentPath), refinedComponent); + assert.equal( + readFileSync( + join(detachProjectRoot, 'components', 'button', 'fixture.txt'), + 'utf8', + ), + 'button fixture\n', + ); + }); + test('initializes a WordPress project with starter hook metadata', () => { const result = runCli(tempRoot, [ 'init', diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 60695d7..d4d92d9 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -33,6 +33,8 @@ SYSTEMS -r, --repository Install from a git repository -c, --checkout Commit, branch, or tag -a, --all Install every component, not just required + system detach Detach the system and keep project components + -y, --yes Skip the confirmation prompt system create [name] Scaffold a system others can install from MAINTENANCE From 0bee17d90a84232c7a5494ef6306639444fa84df Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 03:31:33 -0500 Subject: [PATCH 08/33] feat(component): add component types for twig, react, and web components --- src/handlers/componentCreate.test.ts | 234 ++++----- src/handlers/componentCreate.ts | 35 +- src/index.ts | 6 +- src/lib/rootHelp.test.ts | 5 +- src/lib/rootHelp.ts | 7 +- src/types/handlers.d.ts | 4 +- src/util/deriveComponentNames.test.ts | 16 + src/util/deriveComponentNames.ts | 9 + src/util/deriveCustomElementTagName.test.ts | 58 +++ src/util/deriveCustomElementTagName.ts | 62 +++ .../componentTemplates/componentTypes.test.ts | 92 ++++ src/util/project/componentTemplates/index.ts | 4 + src/util/project/componentTemplates/react.ts | 39 ++ .../componentTemplates/reactStories.ts | 36 ++ .../componentTemplates/webComponent.ts | 69 +++ .../componentTemplates/webComponentStories.ts | 37 ++ src/util/project/componentTypes.test.ts | 162 ++++++ src/util/project/componentTypes.ts | 109 ++++ src/util/project/generateComponent.test.ts | 474 +++++++++++++++--- src/util/project/generateComponent.ts | 382 ++++++++++---- src/util/project/renderTemplate.test.ts | 7 +- src/util/project/renderTemplate.ts | 9 +- .../project/resolveComponentTemplate.test.ts | 100 +++- src/util/project/resolveComponentTemplate.ts | 62 ++- test/e2e/cli.test.mjs | 105 +++- test/e2e/root-help.txt | 3 +- 26 files changed, 1738 insertions(+), 388 deletions(-) create mode 100644 src/util/deriveCustomElementTagName.test.ts create mode 100644 src/util/deriveCustomElementTagName.ts create mode 100644 src/util/project/componentTemplates/componentTypes.test.ts create mode 100644 src/util/project/componentTemplates/react.ts create mode 100644 src/util/project/componentTemplates/reactStories.ts create mode 100644 src/util/project/componentTemplates/webComponent.ts create mode 100644 src/util/project/componentTemplates/webComponentStories.ts create mode 100644 src/util/project/componentTypes.test.ts create mode 100644 src/util/project/componentTypes.ts diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index 00409ff..1ca02e7 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -2,44 +2,28 @@ * @file Unit tests for the component create handler. */ -jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); -jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); +jest.mock('../util/project/generateComponent', () => jest.fn()); jest.mock('@inquirer/prompts'); -import fs from 'fs'; -import { join, normalize, resolve, sep } from 'path'; -import { pathExists, remove } from 'fs-extra'; -import { input, select, confirm } from '@inquirer/prompts'; +import { input } from '@inquirer/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; -import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; -import { - EMULSIFY_PROJECT_CONFIG_FILE, - EMULSIFY_PROJECT_TEMPLATES_FOLDER, - EMULSIFY_SYSTEM_CONFIG_FILE, -} from '../lib/constants.js'; +import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import getJsonFromCachedFile from '../util/cache/getJsonFromCachedFile.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; -import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; +import generateComponent from '../util/project/generateComponent.js'; import componentCreate from './componentCreate.js'; -const logMock = log as jest.Mock; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; const cloneSystemMock = jest.fn(); -const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; -const pathExistsMock = pathExists as jest.Mock; -const removeMock = remove as jest.Mock; +const generateComponentMock = generateComponent as jest.Mock; const inputMock = input as jest.Mock; -const selectMock = select as jest.Mock; -const confirmMock = confirm as jest.Mock; -const mkdirMock = fs.promises.mkdir as jest.Mock; -const writeFileMock = fs.promises.writeFile as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; function setStdinIsTTY(value: boolean | undefined) { @@ -49,20 +33,6 @@ function setStdinIsTTY(value: boolean | undefined) { }); } -function mockComponentExistsWithoutTemplateOverrides() { - const templatePathFragment = `${sep}${normalize( - EMULSIFY_PROJECT_TEMPLATES_FOLDER, - )}${sep}`; - pathExistsMock.mockImplementation( - (path) => !String(path).includes(templatePathFragment), - ); -} - -const projectRoot = resolve('/project'); -const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); -const componentBasePath = join(projectRoot, 'components', '00-base'); -const componentPath = (name: string) => join(componentBasePath, name); - const projectConfig = { project: { platform: 'drupal', @@ -115,19 +85,12 @@ describe('componentCreate', () => { beforeEach(() => { jest.clearAllMocks(); setStdinIsTTY(true); - // The handler clones systems through a higher-order cache helper. cloneIntoCacheMock.mockReturnValue(cloneSystemMock); cloneSystemMock.mockResolvedValue(undefined); getEmulsifyConfigMock.mockResolvedValue(projectConfig); getJsonFromCachedFileMock.mockResolvedValue(system); - findFileInCurrentPathMock.mockReturnValue(projectConfigPath); - pathExistsMock.mockResolvedValue(false); - removeMock.mockResolvedValue(undefined); + generateComponentMock.mockResolvedValue(undefined); inputMock.mockResolvedValue('button'); - selectMock.mockResolvedValue('default'); - confirmMock.mockResolvedValue(false); - mkdirMock.mockResolvedValue(undefined); - writeFileMock.mockResolvedValue(undefined); }); afterAll(() => { @@ -137,7 +100,7 @@ describe('componentCreate', () => { it('throws when no Emulsify project is detected', async () => { getEmulsifyConfigMock.mockResolvedValueOnce(undefined); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'No Emulsify project detected. You must run this command within an existing Emulsify project. For more information about creating Emulsify projects, run "emulsify init --help"', ); }); @@ -148,7 +111,7 @@ describe('componentCreate', () => { system: undefined, }); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'You must select and install a system before you can create components. To see a list of out-of-the-box systems, run "emulsify system list". You can install a system by running "emulsify system install [name]"', ); }); @@ -159,7 +122,7 @@ describe('componentCreate', () => { variant: undefined, }); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'You must select and install a system before you can create components. To see a list of out-of-the-box systems, run "emulsify system list". You can install a system by running "emulsify system install [name]"', ); }); @@ -173,7 +136,7 @@ describe('componentCreate', () => { }, }); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'The repository URL must end in .git.', ); }); @@ -187,7 +150,7 @@ describe('componentCreate', () => { }, }); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'The system specified in your project configuration is not valid. Please make sure your project.emulsify.json file contains a system.repository value that is a valid git url', ); }); @@ -195,7 +158,7 @@ describe('componentCreate', () => { it('throws when the system is not clone-able', async () => { cloneSystemMock.mockRejectedValueOnce(new Error('clone failed')); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'The system specified in your project configuration is not clone-able, or has an invalid checkout value.', ); }); @@ -203,7 +166,7 @@ describe('componentCreate', () => { it('throws when the cached system configuration is invalid', async () => { getJsonFromCachedFileMock.mockResolvedValueOnce(undefined); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'Unable to load configuration for the compound system. Please make sure the system is installed.', ); @@ -233,7 +196,7 @@ describe('componentCreate', () => { variants: [wordpressVariant], }); - await expect(componentCreate('button', {})).rejects.toThrow( + await expect(componentCreate('button', { type: 'twig' })).rejects.toThrow( 'Unable to find configuration for the variant none within the system compound.', ); }); @@ -241,16 +204,16 @@ describe('componentCreate', () => { it('throws a CliError before loading the system when no component name is provided non-interactively', async () => { setStdinIsTTY(false); - await expect(componentCreate('', { refresh: true })).rejects.toThrow( - CliError, - ); - await expect(componentCreate('', { refresh: true })).rejects.toThrow( - 'Please specify a name for the new component.', - ); + await expect( + componentCreate('', { refresh: true, type: 'twig' }), + ).rejects.toThrow(CliError); + await expect( + componentCreate('', { refresh: true, type: 'twig' }), + ).rejects.toThrow('Please specify a name for the new component.'); expect(inputMock).not.toHaveBeenCalled(); - expect(logMock).not.toHaveBeenCalled(); expect(getEmulsifyConfigMock).not.toHaveBeenCalled(); + expect(generateComponentMock).not.toHaveBeenCalled(); }); it('prompts for a missing component name and validates it before continuing', async () => { @@ -264,138 +227,117 @@ describe('componentCreate', () => { expect(validate('promo-card')).toBe(true); return 'promo-card'; }); - selectMock.mockResolvedValueOnce('default').mockResolvedValueOnce('base'); + const options = { directory: 'base', type: 'twig' }; - await componentCreate(undefined, {}); + await componentCreate(undefined, options); expect(inputMock).toHaveBeenCalledWith({ message: 'Component name:', validate: expect.any(Function), }); - expect(writeFileMock).toHaveBeenCalledWith( - join(componentPath('promo-card'), 'promo-card.twig'), - expect.stringContaining('promo-card.twig'), + expect(generateComponentMock).toHaveBeenCalledWith( + variant, + expect.objectContaining(projectConfig), + 'promo-card', + options, ); }); - it('prompts for format and directory when no directory is provided', async () => { - selectMock.mockResolvedValueOnce('default').mockResolvedValueOnce('base'); - - await componentCreate('button', {}); + it('rejects a missing type before loading the system outside a TTY', async () => { + setStdinIsTTY(false); - expect(selectMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Choose the component format:'), - }), - ); - expect(selectMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining( - 'Choose a directory for the new component:', - ), - }), + await expect( + componentCreate('button', { directory: 'base', refresh: true }), + ).rejects.toThrow( + 'Component type is required in non-interactive mode. Pass --type .', ); - }); - it('cancels overwrite when the user declines the confirm prompt', async () => { - mockComponentExistsWithoutTemplateOverrides(); - confirmMock.mockResolvedValue(false); + expect(getEmulsifyConfigMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + expect(generateComponentMock).not.toHaveBeenCalled(); + }); - await componentCreate('button', { directory: 'base' }); + it('rejects a missing directory before loading the system outside a TTY', async () => { + setStdinIsTTY(false); - expect(confirmMock).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('already exists'), - default: false, - }), - ); - expect(removeMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith( - 'info', - 'Component creation canceled.', + await expect( + componentCreate('button', { type: 'react', refresh: true }), + ).rejects.toThrow( + 'Component directory is required in non-interactive mode. Pass --directory .', ); + + expect(getEmulsifyConfigMock).not.toHaveBeenCalled(); + expect(cloneIntoCacheMock).not.toHaveBeenCalled(); + expect(generateComponentMock).not.toHaveBeenCalled(); }); - it('overwrites an existing component when the user accepts the confirm prompt', async () => { - mockComponentExistsWithoutTemplateOverrides(); - confirmMock.mockResolvedValue(true); + it('passes an interactive missing type through to component generation', async () => { + const options = { directory: 'base' }; - await componentCreate('button', { directory: 'base' }); + await componentCreate('button', options); - expect(removeMock).toHaveBeenCalledWith(componentPath('button')); - expect(logMock).toHaveBeenCalledWith( - 'success', - expect.stringContaining('Success!'), + expect(generateComponentMock).toHaveBeenCalledWith( + variant, + expect.objectContaining(projectConfig), + 'button', + options, ); }); - it('creates a component successfully on the happy path', async () => { - pathExistsMock.mockResolvedValue(false); + it('passes the deprecated format alias through to component generation outside a TTY', async () => { + setStdinIsTTY(false); + const options = { directory: 'base', format: 'sdc', yes: true }; - await componentCreate('button', { directory: 'base' }); + await componentCreate('button', options); - expect(mkdirMock).toHaveBeenCalledWith(componentBasePath, { - recursive: true, - }); - expect(writeFileMock).toHaveBeenCalledWith( - join(componentPath('button'), 'button.twig'), - expect.stringContaining('button.twig'), - ); - expect(logMock).toHaveBeenCalledWith( - 'success', - expect.stringContaining('Success!'), + expect(generateComponentMock).toHaveBeenCalledWith( + variant, + expect.objectContaining(projectConfig), + 'button', + options, ); }); - it('requests a remote freshness check when refresh is enabled', async () => { - await componentCreate('button', { directory: 'base', refresh: true }); - - expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + it('forwards project configuration and all generator options using the new signature', async () => { + const options = { + directory: 'base', + type: 'react', + yes: true, + dryRun: true, refresh: true, - }); - }); + }; - it('creates a component non-interactively when flags provide format, directory, and yes', async () => { - setStdinIsTTY(false); - mockComponentExistsWithoutTemplateOverrides(); + await componentCreate('button', options); - await componentCreate('button', { - directory: 'base', - format: 'sdc', - yes: true, + expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { + refresh: true, }); - - expect(selectMock).not.toHaveBeenCalled(); - expect(confirmMock).not.toHaveBeenCalled(); - expect(removeMock).toHaveBeenCalledWith(componentPath('button')); - expect(writeFileMock).toHaveBeenCalledWith( - join(componentPath('button'), 'button.component.yml'), - expect.stringContaining('name: Button'), - ); - expect(logMock).toHaveBeenCalledWith( - 'success', - expect.stringContaining('Success!'), + expect(generateComponentMock).toHaveBeenCalledTimes(1); + expect(generateComponentMock).toHaveBeenCalledWith( + variant, + expect.objectContaining(projectConfig), + 'button', + options, ); }); it('throws generateComponent failures as CliError messages', async () => { - findFileInCurrentPathMock.mockReturnValueOnce(undefined); + generateComponentMock.mockRejectedValueOnce(new Error('generation failed')); await expect( - componentCreate('button', { - directory: 'base', - format: 'default', - }), + componentCreate('button', { directory: 'base', type: 'twig' }), ).rejects.toThrow( - 'Unable to create the button component: Unable to find an Emulsify project to create the component into.', + 'Unable to create the button component: generation failed', ); }); it('preserves prompt cancellation for the top-level handler', async () => { const cancellation = new Error('User force closed the prompt'); cancellation.name = 'ExitPromptError'; - selectMock.mockRejectedValueOnce(cancellation); + generateComponentMock.mockRejectedValueOnce(cancellation); - await expect(componentCreate('button', {})).rejects.toBe(cancellation); + await expect(componentCreate('button', { directory: 'base' })).rejects.toBe( + cancellation, + ); }); }); diff --git a/src/handlers/componentCreate.ts b/src/handlers/componentCreate.ts index d6f4560..1bfed62 100644 --- a/src/handlers/componentCreate.ts +++ b/src/handlers/componentCreate.ts @@ -4,7 +4,15 @@ import generateComponent from '../util/project/generateComponent.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; import CliError from '../lib/CliError.js'; import deriveComponentNames from '../util/deriveComponentNames.js'; -import { isExitPromptError, runPrompt } from '../util/prompt/index.js'; +import { + isExitPromptError, + requireInteractiveTerminal, + runPrompt, +} from '../util/prompt/index.js'; +import { + MISSING_COMPONENT_DIRECTORY_ERROR, + MISSING_COMPONENT_TYPE_ERROR, +} from '../util/project/componentTypes.js'; const MISSING_COMPONENT_NAME_ERROR = 'Please specify a name for the new component.'; @@ -40,13 +48,30 @@ export default async function componentCreate( nonInteractive: { error: MISSING_COMPONENT_NAME_ERROR }, }); + // Missing prompt values can be rejected before loading or refreshing the + // configured system, keeping CI failures fast and offline. + if (!options.type && !options.format) { + requireInteractiveTerminal(MISSING_COMPONENT_TYPE_ERROR); + } + if (!options.directory) { + requireInteractiveTerminal(MISSING_COMPONENT_DIRECTORY_ERROR); + } + // Load the configured system and variant before generating the local component. - const { variantConf } = await withEmulsifySystem('create components', { - refresh: options.refresh, - }); + const { emulsifyConfig, variantConf } = await withEmulsifySystem( + 'create components', + { + refresh: options.refresh, + }, + ); try { - await generateComponent(variantConf, componentName, options); + await generateComponent( + variantConf, + emulsifyConfig, + componentName, + options, + ); } catch (e) { if (isExitPromptError(e)) { throw e; diff --git a/src/index.ts b/src/index.ts index 88316d3..0b3f27d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -154,9 +154,13 @@ component '-d, --directory ', 'Variant structure name where the component should be created.', ) + .option( + '-t, --type ', + 'Component implementation type to generate.', + ) .option( '-f, --format ', - 'Component format to generate. Supported values: default, sdc.', + 'Deprecated alias: default maps to twig and sdc maps to twig-sdc.', ) .option( '-y, --yes', diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index 1a27514..371891f 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -56,7 +56,10 @@ describe('getRootHelp', () => { ' component create [name]\n Generate a new local component', ); expect(help).toContain( - ' -f, --format \n Component format', + ' -t, --type \n twig | twig-sdc | react | web-component', + ); + expect(help).toContain( + ' -f, --format \n Deprecated Twig type alias', ); expect( Math.max(...visibleLines(help).map((line) => line.length)), diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index d92f0b3..1b25692 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -107,10 +107,15 @@ const sections: HelpSection[] = [ label: '-d, --directory ', description: 'Variant structure to create it in', }, + { + kind: 'option', + label: '-t, --type ', + description: 'twig | twig-sdc | react | web-component', + }, { kind: 'option', label: '-f, --format ', - description: 'Component format', + description: 'Deprecated Twig type alias', }, { kind: 'option', diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 8faad22..0cb7c5d 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -62,7 +62,9 @@ declare module '@emulsify-cli/handlers' { export type CreateComponentHandlerOptions = { /** Variant structure directory name where the new component should be created. */ directory?: string; - /** Component format to generate. Supported values are "default" and "sdc". */ + /** Component implementation type to generate. */ + type?: string; + /** Deprecated component format alias. "default" maps to "twig" and "sdc" maps to "twig-sdc". */ format?: string; /** Skip overwrite confirmation prompts and replace existing components. */ yes?: boolean; diff --git a/src/util/deriveComponentNames.test.ts b/src/util/deriveComponentNames.test.ts index 4542cde..abbba23 100644 --- a/src/util/deriveComponentNames.test.ts +++ b/src/util/deriveComponentNames.test.ts @@ -12,6 +12,7 @@ describe('deriveComponentNames', () => { filename: 'featured-item', className: 'featured-item', camelName: 'featuredItem', + pascalName: 'FeaturedItem', snakeName: 'featured_item', humanName: 'Featured Item', }); @@ -24,6 +25,7 @@ describe('deriveComponentNames', () => { filename: 'featured-item', className: 'featured-item', camelName: 'featuredItem', + pascalName: 'FeaturedItem', snakeName: 'featured_item', humanName: 'Featured Item', }); @@ -36,11 +38,25 @@ describe('deriveComponentNames', () => { filename: 'featured-item', className: 'featured-item', camelName: 'featuredItem', + pascalName: 'FeaturedItem', snakeName: 'featured_item', humanName: 'Featured Item', }); }); + it('prefixes a numeric-leading PascalCase identifier', () => { + expect.assertions(1); + + expect(deriveComponentNames('123-card')).toEqual({ + filename: '123-card', + className: '123-card', + camelName: '123Card', + pascalName: 'Component123Card', + snakeName: '123_card', + humanName: '123 Card', + }); + }); + it('throws when the component name is empty after trimming', () => { expect.assertions(1); diff --git a/src/util/deriveComponentNames.ts b/src/util/deriveComponentNames.ts index 081ebb6..e7ec3b7 100644 --- a/src/util/deriveComponentNames.ts +++ b/src/util/deriveComponentNames.ts @@ -8,6 +8,7 @@ export type DerivedComponentNames = { filename: string; className: string; camelName: string; + pascalName: string; snakeName: string; humanName: string; }; @@ -64,6 +65,13 @@ export default function deriveComponentNames( c.toUpperCase(), ); + // PascalCase identifier for generated JavaScript classes and components. + // Prefix numeric-leading names so every generated identifier is valid. + const pascalCandidate = `${camelName.charAt(0).toUpperCase()}${camelName.slice(1)}`; + const pascalName = /^[A-Za-z_$]/.test(pascalCandidate) + ? pascalCandidate + : `Component${pascalCandidate}`; + // snake_case for YAML prop keys (featured-item -> featured_item). const snakeName = filename.replace(/-/g, '_'); @@ -77,6 +85,7 @@ export default function deriveComponentNames( filename, className, camelName, + pascalName, snakeName, humanName, }; diff --git a/src/util/deriveCustomElementTagName.test.ts b/src/util/deriveCustomElementTagName.test.ts new file mode 100644 index 0000000..80e34e7 --- /dev/null +++ b/src/util/deriveCustomElementTagName.test.ts @@ -0,0 +1,58 @@ +import { + assertValidCustomElementTagName, + deriveCustomElementTagName, +} from './deriveCustomElementTagName.js'; + +describe('deriveCustomElementTagName', () => { + it('preserves a hyphenated component filename', () => { + expect.assertions(1); + + expect(deriveCustomElementTagName('featured-item', 'acme-theme')).toBe( + 'featured-item', + ); + }); + + it('namespaces a single-word filename with the project machine name', () => { + expect.assertions(1); + + expect(deriveCustomElementTagName('card', 'acme-theme')).toBe( + 'acme-theme-card', + ); + }); +}); + +describe('assertValidCustomElementTagName', () => { + it.each(['featured-item', 'acme-theme-card', 'emotion-😍'])( + 'accepts valid custom-element tag %p', + (tagName) => { + expect.assertions(1); + + expect(() => assertValidCustomElementTagName(tagName)).not.toThrow(); + }, + ); + + it.each([ + null, + 'Example-card', + '123-card', + 'examplecard', + '-example-card', + 'example card', + 'example/card', + 'example-:', + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph', + ])('rejects invalid custom-element tag %p', (tagName) => { + expect.assertions(1); + + expect(() => assertValidCustomElementTagName(tagName)).toThrow( + /Invalid custom element tag name.*ASCII lowercase letter.*hyphen.*browser-supported.*reserved name/u, + ); + }); +}); diff --git a/src/util/deriveCustomElementTagName.ts b/src/util/deriveCustomElementTagName.ts new file mode 100644 index 0000000..d56b387 --- /dev/null +++ b/src/util/deriveCustomElementTagName.ts @@ -0,0 +1,62 @@ +/** + * @file Derives and validates autonomous custom-element tag names. + */ + +const reservedCustomElementNames = new Set([ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph', +]); + +const validCustomElementNameCharacterPattern = + /^[-.0-9_a-z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}]*$/u; + +/** + * Derives the suggested custom-element tag for a component. + * + * Multi-word component filenames already satisfy the browser requirement for a + * hyphen, so they remain portable across projects. Single-word filenames use + * the project machine name as their namespace. + * + * @param filename Kebab-case component filename. + * @param projectMachineName Machine name from project.emulsify.json. + * @returns Suggested custom-element tag name. Validate it before use. + */ +export function deriveCustomElementTagName( + filename: string, + projectMachineName: string, +): string { + return filename.includes('-') + ? filename + : `${projectMachineName}-${filename}`; +} + +/** + * Enforces the same autonomous custom-element name rules as Emulsify Core. + * + * @param tagName Candidate tag name. + * @throws {SyntaxError} when the value cannot be registered as an autonomous + * custom element. + */ +export function assertValidCustomElementTagName( + tagName: unknown, +): asserts tagName is string { + if ( + typeof tagName !== 'string' || + !/^[a-z]/u.test(tagName) || + !validCustomElementNameCharacterPattern.test(tagName.slice(1)) || + !tagName.includes('-') || + reservedCustomElementNames.has(tagName) + ) { + throw new SyntaxError( + `Invalid custom element tag name "${String( + tagName, + )}". Names must start with an ASCII lowercase letter, contain a hyphen, use browser-supported custom-element name characters, and must not be a reserved name.`, + ); + } +} diff --git a/src/util/project/componentTemplates/componentTypes.test.ts b/src/util/project/componentTemplates/componentTypes.test.ts new file mode 100644 index 0000000..297d305 --- /dev/null +++ b/src/util/project/componentTemplates/componentTypes.test.ts @@ -0,0 +1,92 @@ +import { + buildReactStoriesTemplate, + buildReactTemplate, + buildWebComponentStoriesTemplate, + buildWebComponentTemplate, +} from './index.js'; + +describe('React component templates', () => { + it('builds a named JSX component with the shared component class names', () => { + expect.assertions(6); + + const template = buildReactTemplate( + 'FeaturedItem', + 'featured-item', + 'featured-item', + 'Featured Item', + ); + + expect(template).toContain('* featured-item.jsx'); + expect(template).toContain("import React from 'react';"); + expect(template).toContain('export function FeaturedItem({'); + expect(template).toContain('className="featured-item"'); + expect(template).toContain('className="featured-item__heading"'); + expect(template).toContain('className="featured-item__content"'); + }); + + it('builds a standard React CSF story', () => { + expect.assertions(5); + + const template = buildReactStoriesTemplate( + 'FeaturedItem', + 'featured-item', + 'Featured Item', + 'components', + ); + + expect(template).toContain( + "import { FeaturedItem } from './featured-item.jsx';", + ); + expect(template).toContain("title: 'Components/Featured Item'"); + expect(template).toContain('component: FeaturedItem'); + expect(template).toContain("heading: 'Featured Item Component'"); + expect(template).toContain('export const Default = {};'); + }); +}); + +describe('web component templates', () => { + it('builds an HTMLElement subclass with guarded native registration', () => { + expect.assertions(8); + + const template = buildWebComponentTemplate( + 'FeaturedItem', + 'featured-item', + 'featured-item', + 'Featured Item', + 'featured-item', + ); + + expect(template).toContain('* featured-item.js'); + expect(template).toContain( + 'export class FeaturedItemElement extends HTMLElement', + ); + expect(template).toContain('set heading(value)'); + expect(template).toContain('set content(value)'); + expect(template).toContain('connectedCallback()'); + expect(template).toContain('class="featured-item"'); + expect(template).toContain("if (!customElements.get('featured-item'))"); + expect(template).toContain( + "customElements.define('featured-item', FeaturedItemElement);", + ); + }); + + it('builds a story with the public Emulsify Core renderer', () => { + expect.assertions(6); + + const template = buildWebComponentStoriesTemplate( + 'featured-item', + 'Featured Item', + 'components', + 'featured-item', + ); + + expect(template).toContain( + "import { renderWebComponent } from '@emulsify/core/storybook';", + ); + expect(template).toContain("import './featured-item.js';"); + expect(template).toContain("title: 'Components/Featured Item'"); + expect(template).toContain("render: renderWebComponent('featured-item')"); + expect(template).toContain("heading: 'Featured Item Component'"); + expect(template).toContain('export const Default = {};'); + }); +}); diff --git a/src/util/project/componentTemplates/index.ts b/src/util/project/componentTemplates/index.ts index 62e1f7f..6b2fbc5 100644 --- a/src/util/project/componentTemplates/index.ts +++ b/src/util/project/componentTemplates/index.ts @@ -3,9 +3,13 @@ */ export { buildScssTemplate } from './scss.js'; +export { buildReactStoriesTemplate } from './reactStories.js'; +export { buildReactTemplate } from './react.js'; export { buildSdcJsTemplate } from './sdcJs.js'; export { buildSdcMetadataTemplate } from './sdcMetadata.js'; export { buildSdcStoriesTemplate } from './sdcStories.js'; export { buildStoriesTemplate } from './stories.js'; export { buildTwigTemplate } from './twig.js'; +export { buildWebComponentStoriesTemplate } from './webComponentStories.js'; +export { buildWebComponentTemplate } from './webComponent.js'; export { buildYmlTemplate } from './yml.js'; diff --git a/src/util/project/componentTemplates/react.ts b/src/util/project/componentTemplates/react.ts new file mode 100644 index 0000000..0069b57 --- /dev/null +++ b/src/util/project/componentTemplates/react.ts @@ -0,0 +1,39 @@ +/** + * @file Builds React component templates. + */ + +/** + * Generates a React component that Storybook can render through its standard + * React support. + * + * @param pascalName PascalCase JavaScript component identifier. + * @param filename Kebab-case component file and folder name. + * @param className CSS base class name used by the component markup. + * @param humanName Human-readable component name used in default content. + * @returns JSX source content for the generated React component. + */ +export function buildReactTemplate( + pascalName: string, + filename: string, + className: string, + humanName: string, +): string { + return `/** + * @file + * ${filename}.jsx + */ +import React from 'react'; + +export function ${pascalName}({ + heading = '${humanName} Component', + content = 'This is the content area of the ${humanName} React component. Replace it with your markup and data.', +}) { + return ( +
+ {heading &&

{heading}

} +
{content}
+
+ ); +} +`; +} diff --git a/src/util/project/componentTemplates/reactStories.ts b/src/util/project/componentTemplates/reactStories.ts new file mode 100644 index 0000000..cdba1d6 --- /dev/null +++ b/src/util/project/componentTemplates/reactStories.ts @@ -0,0 +1,36 @@ +/** + * @file Builds Storybook stories for generated React components. + */ + +/** + * Generates a CSF story using Storybook's standard React component support. + * + * @param pascalName PascalCase JavaScript component identifier. + * @param filename Kebab-case component file and folder name. + * @param humanName Human-readable component name shown in Storybook. + * @param directory Component structure name used as the Storybook title group. + * @returns JSX source content for the generated React Storybook story. + */ +export function buildReactStoriesTemplate( + pascalName: string, + filename: string, + humanName: string, + directory: string, +): string { + return `import { ${pascalName} } from './${filename}.jsx'; + +/** + * Storybook Definition. + */ +export default { + title: '${directory[0].toUpperCase() + directory.slice(1)}/${humanName}', + component: ${pascalName}, + args: { + heading: '${humanName} Component', + content: 'This is the content area of the ${humanName} React component. Replace it with your markup and data.', + }, +}; + +export const Default = {}; +`; +} diff --git a/src/util/project/componentTemplates/webComponent.ts b/src/util/project/componentTemplates/webComponent.ts new file mode 100644 index 0000000..b8b81b4 --- /dev/null +++ b/src/util/project/componentTemplates/webComponent.ts @@ -0,0 +1,69 @@ +/** + * @file Builds autonomous web component templates. + */ + +/** + * Generates an autonomous custom element registered under a validated tag. + * + * @param pascalName PascalCase JavaScript component identifier. + * @param filename Kebab-case component file and folder name. + * @param className CSS base class name used by the component markup. + * @param humanName Human-readable component name used in default content. + * @param tagName Valid autonomous custom-element tag name. + * @returns JavaScript source content for the generated web component. + */ +export function buildWebComponentTemplate( + pascalName: string, + filename: string, + className: string, + humanName: string, + tagName: string, +): string { + return `/** + * @file + * ${filename}.js + */ + +export class ${pascalName}Element extends HTMLElement { + set heading(value) { + this.headingValue = value; + this.render(); + } + + get heading() { + return this.headingValue; + } + + set content(value) { + this.contentValue = value; + this.render(); + } + + get content() { + return this.contentValue; + } + + connectedCallback() { + this.render(); + } + + render() { + const heading = this.headingValue ?? '${humanName} Component'; + const content = + this.contentValue ?? + 'This is the content area of the ${humanName} web component. Replace it with your markup and data.'; + + this.innerHTML = \` +
+ \${heading ? \`

\${heading}

\` : ''} +
\${content}
+
+ \`; + } +} + +if (!customElements.get('${tagName}')) { + customElements.define('${tagName}', ${pascalName}Element); +} +`; +} diff --git a/src/util/project/componentTemplates/webComponentStories.ts b/src/util/project/componentTemplates/webComponentStories.ts new file mode 100644 index 0000000..ae8cf99 --- /dev/null +++ b/src/util/project/componentTemplates/webComponentStories.ts @@ -0,0 +1,37 @@ +/** + * @file Builds Storybook stories for generated web components. + */ + +/** + * Generates a CSF story using Emulsify Core's custom-element renderer. + * + * @param filename Kebab-case component file and folder name. + * @param humanName Human-readable component name shown in Storybook. + * @param directory Component structure name used as the Storybook title group. + * @param tagName Valid autonomous custom-element tag name. + * @returns JavaScript source content for the generated web component story. + */ +export function buildWebComponentStoriesTemplate( + filename: string, + humanName: string, + directory: string, + tagName: string, +): string { + return `import { renderWebComponent } from '@emulsify/core/storybook'; +import './${filename}.js'; + +/** + * Storybook Definition. + */ +export default { + title: '${directory[0].toUpperCase() + directory.slice(1)}/${humanName}', + render: renderWebComponent('${tagName}'), + args: { + heading: '${humanName} Component', + content: 'This is the content area of the ${humanName} web component. Replace it with your markup and data.', + }, +}; + +export const Default = {}; +`; +} diff --git a/src/util/project/componentTypes.test.ts b/src/util/project/componentTypes.test.ts new file mode 100644 index 0000000..ddc22e7 --- /dev/null +++ b/src/util/project/componentTypes.test.ts @@ -0,0 +1,162 @@ +/** + * @file Unit tests for component type normalization and project availability. + */ + +jest.mock('../fs/loadJsonFile', () => jest.fn()); + +import { join, resolve } from 'path'; + +import loadJsonFile from '../fs/loadJsonFile.js'; +import { + componentTypeFromLegacyFormat, + getAvailableComponentTypes, + getCompatibleFormatToken, + normalizeComponentType, + projectDeclaresEmulsifyCore, +} from './componentTypes.js'; + +const loadJsonFileMock = loadJsonFile as jest.Mock; +const projectRoot = resolve('/project'); + +describe('component type utilities', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('normalizeComponentType', () => { + it.each([ + [' twig ', 'twig'], + ['TWIG-SDC', 'twig-sdc'], + ['React', 'react'], + ['WEB-COMPONENT', 'web-component'], + ])('normalizes %p to %p', (value, expected) => { + expect.assertions(1); + + expect(normalizeComponentType(value)).toBe(expected); + }); + + it('rejects unsupported component types', () => { + expect.assertions(1); + + expect(() => normalizeComponentType('sdc')).toThrow( + 'Invalid component type "sdc". Supported types are: twig, twig-sdc, react, web-component.', + ); + }); + }); + + describe('componentTypeFromLegacyFormat', () => { + it.each([ + [' default ', 'twig'], + ['SDC', 'twig-sdc'], + ])('maps legacy format %p to %p', (value, expected) => { + expect.assertions(1); + + expect(componentTypeFromLegacyFormat(value)).toBe(expected); + }); + + it('rejects unsupported legacy formats', () => { + expect.assertions(1); + + expect(() => componentTypeFromLegacyFormat('react')).toThrow( + 'Invalid component format "react". Supported formats are: default, sdc.', + ); + }); + }); + + it.each([ + ['twig', 'default'], + ['twig-sdc', 'sdc'], + ['react', 'react'], + ['web-component', 'web-component'], + ] as const)('uses the compatible format token for %s', (type, expected) => { + expect.assertions(1); + + expect(getCompatibleFormatToken(type)).toBe(expected); + }); + + describe('getAvailableComponentTypes', () => { + it.each([ + ['drupal', true, ['twig', 'twig-sdc', 'react', 'web-component']], + ['drupal', false, ['twig', 'twig-sdc']], + ['wordpress', true, ['twig', 'react', 'web-component']], + ['wordpress', false, ['twig']], + ] as const)( + 'filters the %s wizard choices when Core detection is %s', + (platform, hasEmulsifyCore, expected) => { + expect.assertions(1); + + expect(getAvailableComponentTypes(platform, hasEmulsifyCore)).toEqual( + expected, + ); + }, + ); + + it('treats a platform-independent project like other non-Drupal projects', () => { + expect.assertions(1); + + expect(getAvailableComponentTypes('none', true)).toEqual([ + 'twig', + 'react', + 'web-component', + ]); + }); + }); + + describe('projectDeclaresEmulsifyCore', () => { + it('detects Core in project dependencies', async () => { + expect.assertions(2); + loadJsonFileMock.mockResolvedValueOnce({ + dependencies: { '@emulsify/core': '^4.4.0' }, + }); + + await expect(projectDeclaresEmulsifyCore(projectRoot)).resolves.toBe( + true, + ); + expect(loadJsonFileMock).toHaveBeenCalledWith( + join(projectRoot, 'package.json'), + ); + }); + + it('detects Core in project development dependencies', async () => { + expect.assertions(1); + loadJsonFileMock.mockResolvedValueOnce({ + devDependencies: { '@emulsify/core': 'workspace:*' }, + }); + + await expect(projectDeclaresEmulsifyCore(projectRoot)).resolves.toBe( + true, + ); + }); + + it('returns false when package.json is missing', async () => { + expect.assertions(1); + loadJsonFileMock.mockResolvedValueOnce(undefined); + + await expect(projectDeclaresEmulsifyCore(projectRoot)).resolves.toBe( + false, + ); + }); + + it('returns false when package.json is malformed', async () => { + expect.assertions(1); + loadJsonFileMock.mockRejectedValueOnce( + new SyntaxError('Unexpected end of JSON input'), + ); + + await expect(projectDeclaresEmulsifyCore(projectRoot)).resolves.toBe( + false, + ); + }); + + it('returns false when package.json cannot be read', async () => { + expect.assertions(1); + loadJsonFileMock.mockRejectedValueOnce( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + + await expect(projectDeclaresEmulsifyCore(projectRoot)).resolves.toBe( + false, + ); + }); + }); +}); diff --git a/src/util/project/componentTypes.ts b/src/util/project/componentTypes.ts new file mode 100644 index 0000000..0aae7fe --- /dev/null +++ b/src/util/project/componentTypes.ts @@ -0,0 +1,109 @@ +import { join } from 'path'; + +import type { Platform } from '@emulsify-cli/config'; + +import loadJsonFile from '../fs/loadJsonFile.js'; + +export const COMPONENT_TYPES = [ + 'twig', + 'twig-sdc', + 'react', + 'web-component', +] as const; + +export type ComponentType = (typeof COMPONENT_TYPES)[number]; + +export const MISSING_COMPONENT_TYPE_ERROR = + 'Component type is required in non-interactive mode. Pass --type .'; +export const MISSING_COMPONENT_DIRECTORY_ERROR = + 'Component directory is required in non-interactive mode. Pass --directory .'; + +const LEGACY_FORMAT_TO_TYPE = { + default: 'twig', + sdc: 'twig-sdc', +} as const satisfies Record; + +type LegacyComponentFormat = keyof typeof LEGACY_FORMAT_TO_TYPE; + +type ProjectPackage = { + dependencies?: Record; + devDependencies?: Record; +}; + +function normalizeOption(value: string): string { + return value.trim().toLowerCase(); +} + +/** Validate and normalize a canonical component type option. */ +export function normalizeComponentType(value: string): ComponentType { + const normalized = normalizeOption(value); + if (!COMPONENT_TYPES.includes(normalized as ComponentType)) { + throw new Error( + `Invalid component type "${value}". Supported types are: ${COMPONENT_TYPES.join(', ')}.`, + ); + } + + return normalized as ComponentType; +} + +/** Map a deprecated component format option onto its canonical type. */ +export function componentTypeFromLegacyFormat(value: string): ComponentType { + const normalized = normalizeOption(value); + if (!(normalized in LEGACY_FORMAT_TO_TYPE)) { + throw new Error( + `Invalid component format "${value}". Supported formats are: default, sdc.`, + ); + } + + return LEGACY_FORMAT_TO_TYPE[normalized as LegacyComponentFormat]; +} + +/** Preserve the legacy format token for existing Twig template overrides. */ +export function getCompatibleFormatToken(type: ComponentType): string { + if (type === 'twig') return 'default'; + if (type === 'twig-sdc') return 'sdc'; + return type; +} + +/** Return the types the interactive wizard can safely recommend. */ +export function getAvailableComponentTypes( + platform: Platform, + hasEmulsifyCore: boolean, +): ComponentType[] { + return COMPONENT_TYPES.filter((type) => { + if (type === 'twig-sdc') return platform === 'drupal'; + if (type === 'react' || type === 'web-component') { + return hasEmulsifyCore; + } + return true; + }); +} + +function hasDependency( + dependencies: Record | undefined, +): boolean { + const version = dependencies?.['@emulsify/core']; + return typeof version === 'string' && version.trim().length > 0; +} + +/** + * Heuristically detect whether a project declares Emulsify Core. + * + * Missing or unreadable package metadata means "not detected" rather than a + * scaffolding failure; explicit component types are still honored by callers. + */ +export async function projectDeclaresEmulsifyCore( + projectRoot: string, +): Promise { + try { + const packageInfo = await loadJsonFile( + join(projectRoot, 'package.json'), + ); + return ( + hasDependency(packageInfo?.dependencies) || + hasDependency(packageInfo?.devDependencies) + ); + } catch { + return false; + } +} diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index e98c4bd..78a4def 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -14,8 +14,9 @@ jest.mock('fs-extra', () => ({ jest.mock('@inquirer/prompts'); jest.mock('../../lib/log.js'); jest.mock('../fs/findFileInCurrentPath.js'); +jest.mock('../fs/loadJsonFile.js'); -import { select, confirm } from '@inquirer/prompts'; +import { confirm, input, select } from '@inquirer/prompts'; import { promises as fs } from 'fs'; import { join, normalize, resolve, sep } from 'path'; import { pathExists, remove } from 'fs-extra'; @@ -25,8 +26,12 @@ import { EMULSIFY_PROJECT_TEMPLATES_FOLDER, } from '../../lib/constants.js'; import generateComponent from './generateComponent.js'; -import { EmulsifyVariant } from '@emulsify-cli/config'; +import type { + EmulsifyProjectConfiguration, + EmulsifyVariant, +} from '@emulsify-cli/config'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; +import loadJsonFile from '../fs/loadJsonFile.js'; const projectRoot = resolve( '/home/uname/Projects/cornflake/web/themes/custom/themename', @@ -61,11 +66,26 @@ const variant = { ], } as EmulsifyVariant; +const projectConfig: EmulsifyProjectConfiguration = { + project: { + platform: 'drupal', + name: 'Cornflake', + machineName: 'cornflake', + }, + starter: { + repository: 'https://github.com/emulsify-ds/emulsify-starter.git', + }, +}; + 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 loadJsonFileMock = loadJsonFile as jest.Mock; +const inputMock = input as jest.Mock; +const selectMock = select as jest.Mock; +const confirmMock = confirm as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; function setStdinIsTTY(value: boolean | undefined) { @@ -114,6 +134,10 @@ describe('generateComponent', () => { setStdinIsTTY(true); pathExistsMock.mockImplementation((path) => !isTemplatePath(path)); readFileMock.mockResolvedValue(''); + loadJsonFileMock.mockResolvedValue({ + dependencies: { '@emulsify/core': '^4.4.0' }, + }); + inputMock.mockResolvedValue('cornflake-button'); }); afterAll(() => { @@ -123,7 +147,9 @@ describe('generateComponent', () => { it('throws an error if the user is not within an Emulsify project', async () => { expect.assertions(1); findFileMock.mockReturnValueOnce(undefined); - await expect(generateComponent(variant, 'button')).rejects.toThrow( + await expect( + generateComponent(variant, projectConfig, 'button', { type: 'twig' }), + ).rejects.toThrow( 'Unable to find an Emulsify project to create the component into.', ); }); @@ -132,7 +158,10 @@ describe('generateComponent', () => { expect.assertions(4); await expect( - generateComponent(variant, ' ', { directory: 'base' }), + generateComponent(variant, projectConfig, ' ', { + directory: 'base', + type: 'twig', + }), ).rejects.toThrow( 'Component name must include at least one letter or number.', ); @@ -146,7 +175,10 @@ describe('generateComponent', () => { expect.assertions(4); await expect( - generateComponent(variant, 'featured item', { directory: 'base' }), + generateComponent(variant, projectConfig, 'featured item', { + directory: 'base', + type: 'twig', + }), ).rejects.toThrow( 'Component name may only include letters, numbers, and single hyphens between words.', ); @@ -156,46 +188,187 @@ describe('generateComponent', () => { expect(pathExists).not.toHaveBeenCalled(); }); - it('should prompt for the format and then the directory if not provided', async () => { - expect.assertions(2); - (select as jest.Mock) - .mockResolvedValueOnce('default') // format - .mockResolvedValueOnce('base'); // directory + it('prompts for all four component types in a Drupal project with Core, then prompts for the directory', async () => { + expect.assertions(4); + selectMock.mockResolvedValueOnce('twig').mockResolvedValueOnce('base'); + + await generateComponent(variant, projectConfig, 'button'); - await generateComponent(variant, 'button'); - expect(select).toHaveBeenCalledWith( + expect(selectMock).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ - message: expect.stringContaining('Choose the component format:'), + message: expect.stringContaining('Choose the component type:'), + choices: expect.arrayContaining([ + expect.objectContaining({ value: 'twig' }), + expect.objectContaining({ value: 'twig-sdc' }), + expect.objectContaining({ value: 'react' }), + expect.objectContaining({ value: 'web-component' }), + ]), }), ); - expect(select).toHaveBeenCalledWith( + expect(selectMock.mock.calls[0][0].choices).toHaveLength(4); + expect(selectMock).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ message: expect.stringContaining( 'Choose a directory for the new component:', ), }), ); + expect(inputMock).not.toHaveBeenCalled(); + }); + + it('offers Twig and Twig SDC in a Drupal project without Core and explains the omission', async () => { + expect.assertions(3); + loadJsonFileMock.mockResolvedValueOnce(undefined); + selectMock.mockResolvedValueOnce('twig'); + + await generateComponent(variant, projectConfig, 'button', { + directory: 'base', + }); + + expect( + selectMock.mock.calls[0][0].choices.map( + ({ value }: { value: string }) => value, + ), + ).toEqual(['twig', 'twig-sdc']); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining( + 'React and Web Component are not shown because @emulsify/core is not declared', + ), + ); + expect(inputMock).not.toHaveBeenCalled(); + }); + + it('offers Twig, React, and Web Component in a non-Drupal project with Core and explains the omission', async () => { + expect.assertions(2); + const wordpressProjectConfig: EmulsifyProjectConfiguration = { + ...projectConfig, + project: { ...projectConfig.project, platform: 'wordpress' }, + }; + selectMock.mockResolvedValueOnce('twig'); + + await generateComponent(variant, wordpressProjectConfig, 'button', { + directory: 'base', + }); + + expect( + selectMock.mock.calls[0][0].choices.map( + ({ value }: { value: string }) => value, + ), + ).toEqual(['twig', 'react', 'web-component']); + expect(log).toHaveBeenCalledWith( + 'info', + 'Twig SDC is available only for Drupal projects, so it is not shown.', + ); + }); + + it('skips a one-item type prompt and explains every omitted type', async () => { + expect.assertions(5); + const neutralProjectConfig: EmulsifyProjectConfiguration = { + ...projectConfig, + project: { ...projectConfig.project, platform: 'none' }, + }; + loadJsonFileMock.mockResolvedValueOnce(undefined); + pathExistsMock.mockResolvedValue(false); + + await generateComponent(variant, neutralProjectConfig, 'button', { + directory: 'base', + }); + + expect(selectMock).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + 'info', + 'Twig SDC is available only for Drupal projects, so it is not shown.', + ); + expect(log).toHaveBeenCalledWith( + 'info', + expect.stringContaining( + 'React and Web Component are not shown because @emulsify/core is not declared', + ), + ); + expect(log).toHaveBeenCalledWith( + 'info', + 'Using Twig, the only detected compatible component type.', + ); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('button', 'button.twig'), + expect.any(String), + ); }); - it('uses a provided format and directory without prompting', async () => { + it('uses a provided canonical type and directory without prompting', async () => { expect.assertions(3); setStdinIsTTY(false); pathExistsMock.mockResolvedValue(false); - await generateComponent(variant, 'button', { + await generateComponent(variant, projectConfig, 'button', { directory: 'base', - format: 'sdc', + type: 'twig-sdc', }); - expect(select).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); expect(writeFileMock).toHaveBeenCalledWith( componentPath('button', 'button.component.yml'), expect.stringContaining('name: Button'), ); }); - it('previews a default component without writing files in dry-run mode', async () => { + it.each([ + ['default', 'twig', 'button.yml'], + ['sdc', 'twig-sdc', 'button.component.yml'], + ])( + 'maps legacy --format %s to %s and warns', + async (format, type, expectedFile) => { + expect.assertions(3); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(false); + + await generateComponent(variant, projectConfig, 'button', { + directory: 'base', + format, + }); + + expect(selectMock).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith( + 'warn', + `The --format option is deprecated; use --type ${type} instead.`, + ); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('button', expectedFile), + expect.any(String), + ); + }, + ); + + it('gives --type precedence when both type and deprecated format are provided', async () => { + expect.assertions(3); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(false); + + await generateComponent(variant, projectConfig, 'button', { + directory: 'base', + type: 'react', + format: 'sdc', + }); + + expect(log).toHaveBeenCalledWith( + 'warn', + 'The --format option is deprecated and was ignored because --type react was also provided.', + ); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('button', 'button.jsx'), + expect.any(String), + ); + expect(writeFileMock).not.toHaveBeenCalledWith( + componentPath('button', 'button.twig'), + expect.anything(), + ); + }); + + it('previews a Twig component without writing files in dry-run mode', async () => { expect.assertions(6); setStdinIsTTY(false); pathExistsMock.mockImplementation((path) => { @@ -203,9 +376,9 @@ describe('generateComponent', () => { return !isTemplatePath(value) && !value.endsWith(componentPath('card')); }); - await generateComponent(variant, 'card', { + await generateComponent(variant, projectConfig, 'card', { directory: 'base', - format: 'default', + type: 'twig', dryRun: true, }); @@ -223,7 +396,7 @@ describe('generateComponent', () => { ); }); - it('previews an SDC component without writing files in dry-run mode', async () => { + it('previews a Twig SDC component without writing files in dry-run mode', async () => { expect.assertions(5); setStdinIsTTY(false); pathExistsMock.mockImplementation((path) => { @@ -231,9 +404,9 @@ describe('generateComponent', () => { return !isTemplatePath(value) && !value.endsWith(componentPath('teaser')); }); - await generateComponent(variant, 'teaser', { + await generateComponent(variant, projectConfig, 'teaser', { directory: 'base', - format: 'sdc', + type: 'twig-sdc', dryRun: true, }); @@ -242,7 +415,7 @@ describe('generateComponent', () => { expect(writeFileMock).not.toHaveBeenCalled(); expect(log).toHaveBeenCalledWith( 'info', - expect.stringContaining('Format: sdc'), + expect.stringContaining('Type: twig-sdc'), ); expect(log).toHaveBeenCalledWith( 'info', @@ -255,9 +428,9 @@ describe('generateComponent', () => { setStdinIsTTY(false); pathExistsMock.mockImplementation((path) => !isTemplatePath(path)); - await generateComponent(variant, 'link', { + await generateComponent(variant, projectConfig, 'link', { directory: 'base', - format: 'default', + type: 'twig', dryRun: true, }); @@ -276,12 +449,30 @@ describe('generateComponent', () => { ); }); - it('throws a clear error when a provided format is invalid', async () => { + it('throws a clear error when a provided type is invalid', async () => { expect.assertions(4); setStdinIsTTY(false); await expect( - generateComponent(variant, 'button', { + generateComponent(variant, projectConfig, 'button', { + directory: 'base', + type: 'bad', + }), + ).rejects.toThrow( + 'Invalid component type "bad". Supported types are: twig, twig-sdc, react, web-component.', + ); + + expect(selectMock).not.toHaveBeenCalled(); + expect(findFileInCurrentPath).not.toHaveBeenCalled(); + expect(pathExists).not.toHaveBeenCalled(); + }); + + it('throws a clear error when a provided legacy format is invalid', async () => { + expect.assertions(4); + setStdinIsTTY(false); + + await expect( + generateComponent(variant, projectConfig, 'button', { directory: 'base', format: 'bad', }), @@ -289,22 +480,24 @@ describe('generateComponent', () => { 'Invalid component format "bad". Supported formats are: default, sdc.', ); - expect(select).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); expect(findFileInCurrentPath).not.toHaveBeenCalled(); expect(pathExists).not.toHaveBeenCalled(); }); - it('throws when format is missing in non-interactive mode', async () => { + it('throws when type is missing in non-interactive mode', async () => { expect.assertions(2); setStdinIsTTY(false); await expect( - generateComponent(variant, 'button', { directory: 'base' }), + generateComponent(variant, projectConfig, 'button', { + directory: 'base', + }), ).rejects.toThrow( - 'Component format is required in non-interactive mode. Pass --format default or --format sdc.', + 'Component type is required in non-interactive mode. Pass --type .', ); - expect(select).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); }); it('throws when directory is missing in non-interactive mode', async () => { @@ -312,19 +505,21 @@ describe('generateComponent', () => { setStdinIsTTY(false); await expect( - generateComponent(variant, 'button', { format: 'default' }), + generateComponent(variant, projectConfig, 'button', { type: 'twig' }), ).rejects.toThrow( 'Component directory is required in non-interactive mode. Pass --directory .', ); - expect(select).not.toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); }); it('throws an error if the component structure is invalid', async () => { expect.assertions(1); - (select as jest.Mock).mockResolvedValueOnce('default'); // format await expect( - generateComponent(variant, 'button', { directory: 'cornpop' }), + generateComponent(variant, projectConfig, 'button', { + directory: 'cornpop', + type: 'twig', + }), ).rejects.toThrow( 'The structure (cornpop) specified within the component button is invalid.', ); @@ -346,10 +541,11 @@ describe('generateComponent', () => { }, ], } as EmulsifyVariant, + projectConfig, 'link', { directory: 'base', - format: 'default', + type: 'twig', yes: true, }, ), @@ -363,11 +559,11 @@ describe('generateComponent', () => { it('should cancel component creation if user declines overwrite', async () => { expect.assertions(2); - (select as jest.Mock).mockResolvedValueOnce('default'); // format - (confirm as jest.Mock).mockResolvedValueOnce(false); // decline overwrite + confirmMock.mockResolvedValueOnce(false); - const result = await generateComponent(variant, 'link', { + const result = await generateComponent(variant, projectConfig, 'link', { directory: 'base', + type: 'twig', }); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ @@ -381,9 +577,9 @@ describe('generateComponent', () => { expect.assertions(3); setStdinIsTTY(false); - await generateComponent(variant, 'link', { + await generateComponent(variant, projectConfig, 'link', { directory: 'base', - format: 'default', + type: 'twig', yes: true, }); @@ -397,10 +593,12 @@ describe('generateComponent', () => { it('should continue creation if user confirms overwrite', async () => { expect.assertions(2); - (select as jest.Mock).mockResolvedValueOnce('default'); // format - (confirm as jest.Mock).mockResolvedValueOnce(true); // confirm overwrite + confirmMock.mockResolvedValueOnce(true); - await generateComponent(variant, 'link', { directory: 'base' }); + await generateComponent(variant, projectConfig, 'link', { + directory: 'base', + type: 'twig', + }); expect(confirm).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith( 'success', @@ -408,47 +606,159 @@ describe('generateComponent', () => { ); }); - it('should create an SDC component structure', async () => { - expect.assertions(1); - (select as jest.Mock) - .mockResolvedValueOnce('sdc') // format - .mockResolvedValueOnce('base'); // directory - // Mock parent path exists, but destination does NOT exist - pathExistsMock.mockImplementation( - (path) => !isTemplatePath(path) && !String(path).endsWith('mario'), + it.each([ + [ + 'twig', + [ + 'featured-item.twig', + 'featured-item.scss', + 'featured-item.yml', + 'featured-item.stories.js', + ], + ], + [ + 'twig-sdc', + [ + 'featured-item.twig', + 'featured-item.scss', + 'featured-item.component.yml', + 'featured-item.js', + 'featured-item.stories.js', + ], + ], + [ + 'react', + ['featured-item.jsx', 'featured-item.scss', 'featured-item.stories.jsx'], + ], + [ + 'web-component', + ['featured-item.js', 'featured-item.scss', 'featured-item.stories.js'], + ], + ] as const)('writes only the exact %s artifact set', async (type, files) => { + expect.assertions(2); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(false); + + await generateComponent(variant, projectConfig, 'featured-item', { + directory: 'base', + type, + }); + + expect( + writeFileMock.mock.calls.map(([path]) => String(path).split(sep).at(-1)), + ).toEqual(files); + if (type === 'react' || type === 'web-component') { + expect(files.some((file) => file.endsWith('.twig'))).toBe(false); + } else { + expect(files.some((file) => file.endsWith('.twig'))).toBe(true); + } + }); + + it.each(['react', 'web-component'] as const)( + 'warns and proceeds with an explicit %s type when Core is not detected', + async (type) => { + expect.assertions(2); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(false); + loadJsonFileMock.mockResolvedValueOnce(undefined); + + await generateComponent(variant, projectConfig, 'featured-item', { + directory: 'base', + type, + }); + + expect(log).toHaveBeenCalledWith( + 'warn', + expect.stringContaining( + `The generated ${type} component may require installing @emulsify/core`, + ), + ); + expect(writeFileMock).toHaveBeenCalled(); + }, + ); + + it('prompts with the derived web component tag and accepts a validated override', async () => { + expect.assertions(5); + pathExistsMock.mockResolvedValue(false); + inputMock.mockImplementationOnce( + async ({ default: defaultValue, validate }) => { + expect(defaultValue).toBe('cornflake-button'); + expect(validate('button')).toContain('contain a hyphen'); + expect(validate('custom-button')).toBe(true); + return ' custom-button '; + }, ); - await generateComponent(variant, 'mario'); - expect(log).toHaveBeenCalledWith( - 'success', - expect.stringContaining('Success!'), + await generateComponent(variant, projectConfig, 'button', { + directory: 'base', + type: 'web-component', + }); + + expect(inputMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Custom element tag name:'), + default: 'cornflake-button', + validate: expect.any(Function), + }), + ); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('button', 'button.js'), + expect.stringContaining( + "customElements.define('custom-button', ButtonElement);", + ), ); }); - it('should generate a standard (Default) component when selected', async () => { - (select as jest.Mock) - .mockResolvedValueOnce('default') // Format selection - .mockResolvedValueOnce('base'); // Directory selection + it('silently uses the filename as a valid hyphenated tag outside a TTY', async () => { + expect.assertions(2); + setStdinIsTTY(false); pathExistsMock.mockResolvedValue(false); - await generateComponent(variant, 'my-button'); + await generateComponent(variant, projectConfig, 'featured-item', { + directory: 'base', + type: 'web-component', + }); - expect(log).toHaveBeenCalledWith( - 'success', - expect.stringContaining('Success!'), + expect(inputMock).not.toHaveBeenCalled(); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('featured-item', 'featured-item.js'), + expect.stringContaining( + "customElements.define('featured-item', FeaturedItemElement);", + ), + ); + }); + + it('rejects an invalid derived tag outside a TTY without writing files', async () => { + expect.assertions(3); + setStdinIsTTY(false); + const invalidMachineNameConfig: EmulsifyProjectConfiguration = { + ...projectConfig, + project: { ...projectConfig.project, machineName: '123theme' }, + }; + + await expect( + generateComponent(variant, invalidMachineNameConfig, 'button', { + directory: 'base', + type: 'web-component', + }), + ).rejects.toThrow( + 'Invalid custom element tag name "123theme-button". Names must start with an ASCII lowercase letter', ); + + expect(inputMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); }); it('uses a rendered project template override when present', async () => { expect.assertions(4); - (select as jest.Mock).mockResolvedValueOnce('default'); mockTemplateOverrides({ 'default/component.twig': '
{{ humanName }} {{ filename }} {{ directory }} {{ format }}
', }); - await generateComponent(variant, 'featuredItem', { + await generateComponent(variant, projectConfig, 'featuredItem', { directory: 'base', + type: 'twig', }); expect(readFileMock).toHaveBeenCalledWith( @@ -471,13 +781,13 @@ describe('generateComponent', () => { it('allows partial project template overrides per artifact', async () => { expect.assertions(2); - (select as jest.Mock).mockResolvedValueOnce('default'); mockTemplateOverrides({ 'default/component.scss': '.{{ className }} { color: red; }\n', }); - await generateComponent(variant, 'featuredItem', { + await generateComponent(variant, projectConfig, 'featuredItem', { directory: 'base', + type: 'twig', }); expect(writeFileMock).toHaveBeenCalledWith( @@ -492,13 +802,13 @@ describe('generateComponent', () => { it('falls back to the built-in template and warns when an override is empty', async () => { expect.assertions(2); - (select as jest.Mock).mockResolvedValueOnce('default'); mockTemplateOverrides({ 'default/component.yml': '\n ', }); - await generateComponent(variant, 'featuredItem', { + await generateComponent(variant, projectConfig, 'featuredItem', { directory: 'base', + type: 'twig', }); expect(writeFileMock).toHaveBeenCalledWith( @@ -515,13 +825,13 @@ featured_item__content: 'This is the content area of the Featured Item component it('keeps unknown override tokens and logs a warning', async () => { expect.assertions(2); - (select as jest.Mock).mockResolvedValueOnce('default'); mockTemplateOverrides({ 'default/component.twig': '{{ humanName }} {{ unknownToken }}', }); - await generateComponent(variant, 'featuredItem', { + await generateComponent(variant, projectConfig, 'featuredItem', { directory: 'base', + type: 'twig', }); expect(writeFileMock).toHaveBeenCalledWith( @@ -534,13 +844,13 @@ featured_item__content: 'This is the content area of the Featured Item component ); }); - it('writes default component files with byte-for-byte template content', async () => { + it('writes Twig component files with byte-for-byte template content', async () => { expect.assertions(1); - (select as jest.Mock).mockResolvedValueOnce('default'); pathExistsMock.mockResolvedValue(false); - await generateComponent(variant, 'featuredItem', { + await generateComponent(variant, projectConfig, 'featuredItem', { directory: 'base', + type: 'twig', }); expect(writeFileMock.mock.calls).toEqual([ @@ -627,9 +937,8 @@ export const featuredItem = () => featuredItemTwig(featuredItemData); ]); }); - it('writes SDC component files with byte-for-byte template content', async () => { + it('writes Twig SDC component files with byte-for-byte template content', async () => { expect.assertions(2); - (select as jest.Mock).mockResolvedValueOnce('sdc'); pathExistsMock.mockResolvedValue(false); const expectedSdcJs = `/** * @file @@ -645,8 +954,9 @@ Drupal.behaviors.featuredItem = { }; `; - await generateComponent(variant, 'featuredItem', { + await generateComponent(variant, projectConfig, 'featuredItem', { directory: 'base', + type: 'twig-sdc', }); expect(expectedSdcJs).not.toContain('\t'); diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index 4044a1d..8462ab6 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -1,7 +1,10 @@ -import type { EmulsifyVariant } from '@emulsify-cli/config'; +import type { + EmulsifyProjectConfiguration, + EmulsifyVariant, +} from '@emulsify-cli/config'; import type { CreateComponentHandlerOptions } from '@emulsify-cli/handlers'; -import { select, confirm } from '@inquirer/prompts'; +import { confirm, input, select } from '@inquirer/prompts'; import { promises as fs } from 'fs'; import { dirname } from 'path'; import { pathExists, remove } from 'fs-extra'; @@ -12,56 +15,134 @@ 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 { + assertValidCustomElementTagName, + deriveCustomElementTagName, +} from '../deriveCustomElementTagName.js'; import { runPrompt } from '../prompt/index.js'; +import { + componentTypeFromLegacyFormat, + getAvailableComponentTypes, + getCompatibleFormatToken, + MISSING_COMPONENT_DIRECTORY_ERROR, + MISSING_COMPONENT_TYPE_ERROR, + normalizeComponentType, + projectDeclaresEmulsifyCore, + type ComponentType, +} from './componentTypes.js'; import resolveComponentTemplate from './resolveComponentTemplate.js'; import type { ComponentTemplateVars } from './renderTemplate.js'; import { + buildReactStoriesTemplate, + buildReactTemplate, buildScssTemplate, buildSdcJsTemplate, buildSdcMetadataTemplate, buildSdcStoriesTemplate, buildStoriesTemplate, buildTwigTemplate, + buildWebComponentStoriesTemplate, + buildWebComponentTemplate, buildYmlTemplate, } from './componentTemplates/index.js'; -const COMPONENT_FORMATS = ['default', 'sdc'] as const; - -type ComponentFormat = (typeof COMPONENT_FORMATS)[number]; - type ComponentArtifact = { logicalName: string; destinationName: string; build: () => string; }; -/** - * Validates and normalizes a component format option. - * - * @param format Raw format value provided by a CLI option. - * @returns Normalized component format. - * @throws {Error} if the format is not one of the supported component formats. - */ -function getComponentFormat(format: string): ComponentFormat { - const normalizedFormat = format.toLowerCase(); +const TYPE_LABELS: Record = { + twig: 'Twig', + 'twig-sdc': 'Twig SDC', + react: 'React', + 'web-component': 'Web Component', +}; - if (!COMPONENT_FORMATS.includes(normalizedFormat as ComponentFormat)) { - throw new Error( - `Invalid component format "${format}". Supported formats are: default, sdc.`, +function resolveProvidedComponentType( + options: CreateComponentHandlerOptions, +): ComponentType | undefined { + if (options.type) { + const type = normalizeComponentType(options.type); + if (options.format) { + log( + 'warn', + `The --format option is deprecated and was ignored because --type ${type} was also provided.`, + ); + } + return type; + } + + if (!options.format) return undefined; + + const type = componentTypeFromLegacyFormat(options.format); + log('warn', `The --format option is deprecated; use --type ${type} instead.`); + return type; +} + +async function promptForComponentType( + projectRoot: string, + platform: EmulsifyProjectConfiguration['project']['platform'], + bold: (value: string) => string, + cyan: (value: string) => string, +): Promise { + const hasEmulsifyCore = await projectDeclaresEmulsifyCore(projectRoot); + const availableTypes = getAvailableComponentTypes(platform, hasEmulsifyCore); + + if (platform !== 'drupal') { + log( + 'info', + 'Twig SDC is available only for Drupal projects, so it is not shown.', ); } + if (!hasEmulsifyCore) { + log( + 'info', + "React and Web Component are not shown because @emulsify/core is not declared in this project's package.json. Pass --type explicitly to scaffold either one anyway.", + ); + } + + if (availableTypes.length === 1) { + log('info', 'Using Twig, the only detected compatible component type.'); + return 'twig'; + } + + const descriptions: Record = { + twig: 'Twig template with YAML data and a Storybook story', + 'twig-sdc': 'Drupal Single Directory Component built with Twig', + react: "React component using Storybook's standard React support", + 'web-component': 'Custom element rendered by @emulsify/core', + }; - return normalizedFormat as ComponentFormat; + return select({ + message: cyan('Choose the component type:'), + choices: availableTypes.map((type) => ({ + name: bold(TYPE_LABELS[type]), + value: type, + description: descriptions[type], + })), + }); +} + +function validateCustomElementTagName(value: string): true | string { + try { + assertValidCustomElementTagName(value.trim()); + return true; + } catch (error) { + return (error as Error).message; + } } /** - * Installs a specified component within the Emulsify project the user is currently within. + * Generates a specified component within the current Emulsify project. * * @param variant EmulsifyVariant object containing information about the component, where it lives, and how it should be created. + * @param projectConfig current Emulsify project configuration. * @param componentName string name of the component that should be created. * @param options commander options object. * @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.type canonical component type to generate. + * @param options.format deprecated component format alias. "default" maps to "twig" and "sdc" maps to "twig-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 @@ -69,15 +150,14 @@ function getComponentFormat(format: string): ComponentFormat { */ export default async function generateComponent( variant: EmulsifyVariant, + projectConfig: EmulsifyProjectConfiguration, componentName: string, options: CreateComponentHandlerOptions = {}, ): Promise { const { bold, cyan, green, yellow } = getTerminalColors(); - const { filename, className, camelName, snakeName, humanName } = + const { filename, className, camelName, pascalName, snakeName, humanName } = deriveComponentNames(componentName); - const providedFormat = options.format - ? getComponentFormat(options.format) - : undefined; + const providedType = resolveProvidedComponentType(options); let directory = options.directory || ''; // Gather information about the current Emulsify project. If none exists, @@ -92,29 +172,50 @@ export default async function generateComponent( // Prompts are only used for interactive TTY sessions; CI must provide flags so // the command never waits for input it cannot receive. - const format = providedFormat - ? providedFormat + const type = providedType + ? providedType : await runPrompt({ prompt: () => - select({ - message: cyan('Choose the component format:'), - choices: [ - { - name: `${bold('Default')} (Standard Emulsify component)`, - value: 'default', - }, - { - name: `${bold('SDC')} (Single Directory Component for Drupal)`, - value: 'sdc', - }, - ], - }), - nonInteractive: { - error: - 'Component format is required in non-interactive mode. Pass --format default or --format sdc.', - }, + promptForComponentType( + projectRoot, + projectConfig.project.platform, + bold, + cyan, + ), + nonInteractive: { error: MISSING_COMPONENT_TYPE_ERROR }, }); + if ( + providedType && + (type === 'react' || type === 'web-component') && + !(await projectDeclaresEmulsifyCore(projectRoot)) + ) { + log( + 'warn', + `@emulsify/core was not detected in this project's package.json. The generated ${type} component may require installing @emulsify/core before its Storybook story can run.`, + ); + } + + let tagName = ''; + if (type === 'web-component') { + const derivedTagName = deriveCustomElementTagName( + filename, + projectConfig.project.machineName, + ); + tagName = ( + await runPrompt({ + prompt: () => + input({ + message: cyan('Custom element tag name:'), + default: derivedTagName, + validate: validateCustomElementTagName, + }), + nonInteractive: { value: derivedTagName }, + }) + ).trim(); + assertValidCustomElementTagName(tagName); + } + // Choose the component's parent structure within the given variant configuration. if (!directory) { directory = await runPrompt({ @@ -127,8 +228,7 @@ export default async function generateComponent( })), }), nonInteractive: { - error: - 'Component directory is required in non-interactive mode. Pass --directory .', + error: MISSING_COMPONENT_DIRECTORY_ERROR, }, }); } @@ -167,66 +267,138 @@ export default async function generateComponent( filename, className, camelName, + pascalName, snakeName, humanName, directory, - format, + format: getCompatibleFormatToken(type), + type, + tagName, }; - const formatLabel = format.toUpperCase(); - const sharedArtifacts: ComponentArtifact[] = [ - { - logicalName: 'component.twig', - destinationName: `${filename}.twig`, - build: () => - buildTwigTemplate(filename, snakeName, className, formatLabel), - }, - { - logicalName: 'component.scss', - destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, formatLabel), - }, - ]; - const formatArtifacts: ComponentArtifact[] = - format === 'sdc' - ? [ - { - logicalName: 'component.component.yml', - destinationName: `${filename}.component.yml`, - build: () => buildSdcMetadataTemplate(snakeName, humanName), - }, - { - logicalName: 'component.js', - destinationName: `${filename}.js`, - build: () => buildSdcJsTemplate(camelName, filename, className), - }, - { - logicalName: 'component.stories.js', - destinationName: `${filename}.stories.js`, - build: () => - buildSdcStoriesTemplate( - camelName, - filename, - snakeName, - humanName, - directory, - ), - }, - ] - : [ - { - logicalName: 'component.yml', - destinationName: `${filename}.yml`, - build: () => buildYmlTemplate(snakeName, humanName), - }, - { - logicalName: 'component.stories.js', - destinationName: `${filename}.stories.js`, - build: () => - buildStoriesTemplate(camelName, filename, humanName, directory), - }, - ]; - - const artifacts = [...sharedArtifacts, ...formatArtifacts]; + let artifacts: ComponentArtifact[]; + + switch (type) { + case 'twig': + artifacts = [ + { + logicalName: 'component.twig', + destinationName: `${filename}.twig`, + build: () => + buildTwigTemplate(filename, snakeName, className, 'DEFAULT'), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + build: () => buildScssTemplate(className, 'DEFAULT'), + }, + { + logicalName: 'component.yml', + destinationName: `${filename}.yml`, + build: () => buildYmlTemplate(snakeName, humanName), + }, + { + logicalName: 'component.stories.js', + destinationName: `${filename}.stories.js`, + build: () => + buildStoriesTemplate(camelName, filename, humanName, directory), + }, + ]; + break; + case 'twig-sdc': + artifacts = [ + { + logicalName: 'component.twig', + destinationName: `${filename}.twig`, + build: () => buildTwigTemplate(filename, snakeName, className, 'SDC'), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + build: () => buildScssTemplate(className, 'SDC'), + }, + { + logicalName: 'component.component.yml', + destinationName: `${filename}.component.yml`, + build: () => buildSdcMetadataTemplate(snakeName, humanName), + }, + { + logicalName: 'component.js', + destinationName: `${filename}.js`, + build: () => buildSdcJsTemplate(camelName, filename, className), + }, + { + logicalName: 'component.stories.js', + destinationName: `${filename}.stories.js`, + build: () => + buildSdcStoriesTemplate( + camelName, + filename, + snakeName, + humanName, + directory, + ), + }, + ]; + break; + case 'react': + artifacts = [ + { + logicalName: 'component.jsx', + destinationName: `${filename}.jsx`, + build: () => + buildReactTemplate(pascalName, filename, className, humanName), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + build: () => buildScssTemplate(className, 'REACT'), + }, + { + logicalName: 'component.stories.jsx', + destinationName: `${filename}.stories.jsx`, + build: () => + buildReactStoriesTemplate( + pascalName, + filename, + humanName, + directory, + ), + }, + ]; + break; + case 'web-component': + artifacts = [ + { + logicalName: 'component.js', + destinationName: `${filename}.js`, + build: () => + buildWebComponentTemplate( + pascalName, + filename, + className, + humanName, + tagName, + ), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + build: () => buildScssTemplate(className, 'WEB COMPONENT'), + }, + { + logicalName: 'component.stories.js', + destinationName: `${filename}.stories.js`, + build: () => + buildWebComponentStoriesTemplate( + filename, + humanName, + directory, + tagName, + ), + }, + ]; + break; + } const artifactDestinations = artifacts.map((artifact) => safeResolveWithin( projectRoot, @@ -249,7 +421,7 @@ export default async function generateComponent( 'info', [ `Dry run: component create "${filename}"`, - `Format: ${format}`, + `Type: ${type}`, `Directory: ${directory}`, `Structure path: ${structure.directory}`, `Parent directory: ${parentPath} (${parentExists ? 'exists' : 'would be created'})`, @@ -298,7 +470,7 @@ export default async function generateComponent( const templateFile = (await resolveComponentTemplate( projectRoot, - format, + type, artifact.logicalName, templateVars, )) ?? artifact.build(); @@ -309,6 +481,6 @@ export default async function generateComponent( return log( 'success', - `${bold(green('Success!'))} The ${bold(cyan(componentName))} component (${yellow(format.toUpperCase())}) has been created in ${bold(directory)}.`, + `${bold(green('Success!'))} The ${bold(cyan(componentName))} component (${yellow(TYPE_LABELS[type].toUpperCase())}) has been created in ${bold(directory)}.`, ); } diff --git a/src/util/project/renderTemplate.test.ts b/src/util/project/renderTemplate.test.ts index 7f88e08..6add0e7 100644 --- a/src/util/project/renderTemplate.test.ts +++ b/src/util/project/renderTemplate.test.ts @@ -12,10 +12,13 @@ const vars: ComponentTemplateVars = { filename: 'featured-item', className: 'featured-item', camelName: 'featuredItem', + pascalName: 'FeaturedItem', snakeName: 'featured_item', humanName: 'Featured Item', directory: 'base', format: 'default', + type: 'web-component', + tagName: 'featured-item', }; describe('renderTemplate', () => { @@ -28,11 +31,11 @@ describe('renderTemplate', () => { expect( renderTemplate( - '{{ filename }}|{{ className }}|{{ camelName }}|{{ snakeName }}|{{ humanName }}|{{ directory }}|{{ format }}', + '{{ filename }}|{{ className }}|{{ camelName }}|{{ pascalName }}|{{ snakeName }}|{{ humanName }}|{{ directory }}|{{ format }}|{{ type }}|{{ tagName }}', vars, ), ).toBe( - 'featured-item|featured-item|featuredItem|featured_item|Featured Item|base|default', + 'featured-item|featured-item|featuredItem|FeaturedItem|featured_item|Featured Item|base|default|web-component|featured-item', ); }); diff --git a/src/util/project/renderTemplate.ts b/src/util/project/renderTemplate.ts index 7ff7b3a..b14cbff 100644 --- a/src/util/project/renderTemplate.ts +++ b/src/util/project/renderTemplate.ts @@ -8,10 +8,13 @@ export type ComponentTemplateVars = { filename: string; className: string; camelName: string; + pascalName: string; snakeName: string; humanName: string; directory: string; format: string; + type: string; + tagName: string; }; const tokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; @@ -20,7 +23,8 @@ const tokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; * Renders double-brace tokens in a component template override. * * Supported tokens are `{{ filename }}`, `{{ className }}`, `{{ camelName }}`, - * `{{ snakeName }}`, `{{ humanName }}`, `{{ directory }}`, and `{{ format }}`. + * `{{ pascalName }}`, `{{ snakeName }}`, `{{ humanName }}`, `{{ directory }}`, + * `{{ format }}`, `{{ type }}`, and `{{ tagName }}`. * Unknown tokens are left unchanged and logged as warnings. * * @param template raw template file content containing optional double-brace tokens. @@ -33,10 +37,13 @@ const tokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; * filename: 'featured-item', * className: 'featured-item', * camelName: 'featuredItem', + * pascalName: 'FeaturedItem', * snakeName: 'featured_item', * humanName: 'Featured Item', * directory: 'base', * format: 'default', + * type: 'twig', + * tagName: '', * }); * // returns '

Featured Item

' */ diff --git a/src/util/project/resolveComponentTemplate.test.ts b/src/util/project/resolveComponentTemplate.test.ts index cd9e349..12e2b75 100644 --- a/src/util/project/resolveComponentTemplate.test.ts +++ b/src/util/project/resolveComponentTemplate.test.ts @@ -15,64 +15,126 @@ import type { ComponentTemplateVars } from './renderTemplate.js'; const pathExistsMock = pathExists as jest.Mock; const readFileMock = fs.readFile as jest.Mock; const projectRoot = resolve('/project'); -const templatePath = join( - projectRoot, - EMULSIFY_PROJECT_TEMPLATES_FOLDER, - 'default', - 'component.twig', -); +const templatesRoot = join(projectRoot, EMULSIFY_PROJECT_TEMPLATES_FOLDER); +const twigTemplatePath = join(templatesRoot, 'twig', 'component.twig'); +const legacyTwigTemplatePath = join(templatesRoot, 'default', 'component.twig'); +const twigSdcTemplatePath = join(templatesRoot, 'twig-sdc', 'component.twig'); +const legacyTwigSdcTemplatePath = join(templatesRoot, 'sdc', 'component.twig'); +const reactTemplatePath = join(templatesRoot, 'react', 'component.jsx'); const vars: ComponentTemplateVars = { filename: 'featured-item', className: 'featured-item', camelName: 'featuredItem', + pascalName: 'FeaturedItem', snakeName: 'featured_item', humanName: 'Featured Item', directory: 'base', format: 'default', + type: 'twig', + tagName: '', }; describe('resolveComponentTemplate', () => { beforeEach(() => { jest.clearAllMocks(); + pathExistsMock.mockReset(); + readFileMock.mockReset(); }); - it('returns null when an override file is absent', async () => { - expect.assertions(2); - pathExistsMock.mockResolvedValueOnce(false); + it('checks the canonical Twig directory before its legacy alias', async () => { + expect.assertions(4); + pathExistsMock.mockResolvedValue(false); await expect( - resolveComponentTemplate(projectRoot, 'default', 'component.twig', vars), + resolveComponentTemplate(projectRoot, 'twig', 'component.twig', vars), ).resolves.toBeNull(); - expect(readFileMock).not.toHaveBeenCalled(); + expect(pathExistsMock).toHaveBeenCalledTimes(2); + expect(pathExistsMock).toHaveBeenNthCalledWith(1, twigTemplatePath); + expect(pathExistsMock).toHaveBeenNthCalledWith(2, legacyTwigTemplatePath); }); - it('renders an override when a matching file exists', async () => { - expect.assertions(2); + it('uses the canonical override without consulting the legacy alias', async () => { + expect.assertions(4); pathExistsMock.mockResolvedValueOnce(true); readFileMock.mockResolvedValueOnce('

{{ humanName }}

'); await expect( - resolveComponentTemplate(projectRoot, 'default', 'component.twig', vars), + resolveComponentTemplate(projectRoot, 'twig', 'component.twig', vars), + ).resolves.toBe('

Featured Item

'); + + expect(pathExistsMock).toHaveBeenCalledTimes(1); + expect(pathExistsMock).toHaveBeenCalledWith(twigTemplatePath); + expect(readFileMock).toHaveBeenCalledWith(twigTemplatePath, 'utf8'); + }); + + it('falls back from twig to the legacy default directory', async () => { + expect.assertions(4); + pathExistsMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + readFileMock.mockResolvedValueOnce('

{{ humanName }}

'); + + await expect( + resolveComponentTemplate(projectRoot, 'twig', 'component.twig', vars), ).resolves.toBe('

Featured Item

'); - expect(readFileMock).toHaveBeenCalledWith(templatePath, 'utf8'); + expect(pathExistsMock).toHaveBeenCalledTimes(2); + expect(pathExistsMock).toHaveBeenNthCalledWith(1, twigTemplatePath); + expect(readFileMock).toHaveBeenCalledWith(legacyTwigTemplatePath, 'utf8'); }); - it('falls back and warns when an override file is empty', async () => { - expect.assertions(3); + it('falls back from twig-sdc to the legacy sdc directory', async () => { + expect.assertions(4); + pathExistsMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + readFileMock.mockResolvedValueOnce('{{ type }}: {{ pascalName }}'); + + await expect( + resolveComponentTemplate(projectRoot, 'twig-sdc', 'component.twig', { + ...vars, + format: 'sdc', + type: 'twig-sdc', + }), + ).resolves.toBe('twig-sdc: FeaturedItem'); + + expect(pathExistsMock).toHaveBeenCalledTimes(2); + expect(pathExistsMock).toHaveBeenNthCalledWith(1, twigSdcTemplatePath); + expect(readFileMock).toHaveBeenCalledWith( + legacyTwigSdcTemplatePath, + 'utf8', + ); + }); + + it('does not fall through to a legacy alias when the canonical override is empty', async () => { + expect.assertions(5); pathExistsMock.mockResolvedValueOnce(true); readFileMock.mockResolvedValueOnce(' \n'); await expect( - resolveComponentTemplate(projectRoot, 'default', 'component.twig', vars), + resolveComponentTemplate(projectRoot, 'twig', 'component.twig', vars), ).resolves.toBeNull(); + expect(pathExistsMock).toHaveBeenCalledTimes(1); + expect(readFileMock).toHaveBeenCalledWith(twigTemplatePath, 'utf8'); expect(log).toHaveBeenCalledTimes(1); expect(log).toHaveBeenCalledWith( 'warn', - `Component template override "${templatePath}" is empty; using the built-in template instead.`, + `Component template override "${twigTemplatePath}" is empty; using the built-in template instead.`, ); }); + + it('checks only the canonical directory for a type without a legacy alias', async () => { + expect.assertions(4); + pathExistsMock.mockResolvedValueOnce(false); + + await expect( + resolveComponentTemplate(projectRoot, 'react', 'component.jsx', { + ...vars, + type: 'react', + }), + ).resolves.toBeNull(); + + expect(pathExistsMock).toHaveBeenCalledTimes(1); + expect(pathExistsMock).toHaveBeenCalledWith(reactTemplatePath); + expect(readFileMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/util/project/resolveComponentTemplate.ts b/src/util/project/resolveComponentTemplate.ts index 373f231..dc05e12 100644 --- a/src/util/project/resolveComponentTemplate.ts +++ b/src/util/project/resolveComponentTemplate.ts @@ -9,58 +9,76 @@ import { pathExists } from 'fs-extra'; import log from '../../lib/log.js'; import { EMULSIFY_PROJECT_TEMPLATES_FOLDER } from '../../lib/constants.js'; import renderTemplate, { ComponentTemplateVars } from './renderTemplate.js'; +import type { ComponentType } from './componentTypes.js'; // Component overrides intentionally mirror built-in artifacts one-for-one: -// .cli/templates// replaces only that known generated file. +// .cli/templates// replaces only that known generated file. // Missing overrides are normal partial customization; empty overrides fall back. +const LEGACY_TEMPLATE_DIRECTORIES: Partial> = { + twig: 'default', + 'twig-sdc': 'sdc', +}; + /** * Resolves and renders a component template override if one exists for an artifact. * * @param projectRoot absolute path to the Emulsify project root. - * @param format component format, such as `default` or `sdc`. + * @param type canonical component type, such as `twig` or `react`. * @param logicalName logical template file name, such as `component.twig`. * @param vars component template values available for token replacement. * @returns rendered override content, or null when the built-in template should be used. * @throws {Error} if an existing override file cannot be read. * * @example - * await resolveComponentTemplate('/project', 'default', 'component.twig', { + * await resolveComponentTemplate('/project', 'twig', 'component.twig', { * filename: 'featured-item', * className: 'featured-item', * camelName: 'featuredItem', + * pascalName: 'FeaturedItem', * snakeName: 'featured_item', * humanName: 'Featured Item', * directory: 'base', * format: 'default', + * type: 'twig', + * tagName: '', * }); - * // reads /project/.cli/templates/default/component.twig if it exists. + * // reads /project/.cli/templates/twig/component.twig, then falls back to + * // /project/.cli/templates/default/component.twig for compatibility. */ export default async function resolveComponentTemplate( projectRoot: string, - format: string, + type: ComponentType, logicalName: string, vars: ComponentTemplateVars, ): Promise { - const templatePath = join( - projectRoot, - EMULSIFY_PROJECT_TEMPLATES_FOLDER, - format, - logicalName, - ); - - if (!(await pathExists(templatePath))) { - return null; - } + const directories: string[] = [type]; + const legacyDirectory = LEGACY_TEMPLATE_DIRECTORIES[type]; + if (legacyDirectory) directories.push(legacyDirectory); - const template = await fs.readFile(templatePath, 'utf8'); - if (template.trim() === '') { - log( - 'warn', - `Component template override "${templatePath}" is empty; using the built-in template instead.`, + for (const directory of directories) { + const templatePath = join( + projectRoot, + EMULSIFY_PROJECT_TEMPLATES_FOLDER, + directory, + logicalName, ); - return null; + + if (!(await pathExists(templatePath))) { + continue; + } + + const template = await fs.readFile(templatePath, 'utf8'); + if (template.trim() === '') { + log( + 'warn', + `Component template override "${templatePath}" is empty; using the built-in template instead.`, + ); + return null; + } + + return renderTemplate(template, vars); } - return renderTemplate(template, vars); + return null; } diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 1bf0260..6107f76 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -307,7 +307,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.doesNotMatch(result.stdout, /\u001b\[/u); }); - test('uses the supported format values in detailed component create help', () => { + test('uses canonical type values and marks format as deprecated in detailed component create help', () => { const result = runCli(tempRoot, ['component', 'create', '--help']); assert.equal( @@ -316,7 +316,12 @@ describe('built Emulsify CLI', { concurrency: false }, () => { commandFailure('component create --help', result), ); assert.equal(result.stderr, ''); + assert.match( + result.stdout, + /--type /u, + ); assert.match(result.stdout, /--format /u); + assert.match(result.stdout, /Deprecated alias/u); }); test('prints the package version', () => { @@ -342,6 +347,37 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); }); + test('fails fast when component create has no type outside a TTY', () => { + const result = runCli(tempRoot, [ + 'component', + 'create', + 'card', + '--directory', + 'components', + ]); + + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + assert.match( + result.stderr, + /Pass --type /u, + ); + }); + + test('fails fast when component create has no directory outside a TTY', () => { + const result = runCli(tempRoot, [ + 'component', + 'create', + 'card', + '--type', + 'twig', + ]); + + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Pass --directory /u); + }); + test('fails fast when component install has no target outside a TTY', () => { const result = runCli(tempRoot, ['component', 'install']); @@ -755,6 +791,73 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(existsSync(join(isolatedHome, '.emulsify', 'cache')), true); }); + test('scaffolds the exact artifact set for every component type', () => { + const componentCases = [ + { + name: 'twig-example', + type: 'twig', + files: [ + 'twig-example.scss', + 'twig-example.stories.js', + 'twig-example.twig', + 'twig-example.yml', + ], + }, + { + name: 'twig-sdc-example', + type: 'twig-sdc', + files: [ + 'twig-sdc-example.component.yml', + 'twig-sdc-example.js', + 'twig-sdc-example.scss', + 'twig-sdc-example.stories.js', + 'twig-sdc-example.twig', + ], + }, + { + name: 'react-example', + type: 'react', + files: [ + 'react-example.jsx', + 'react-example.scss', + 'react-example.stories.jsx', + ], + }, + { + name: 'web-component-example', + type: 'web-component', + files: [ + 'web-component-example.js', + 'web-component-example.scss', + 'web-component-example.stories.js', + ], + }, + ]; + + for (const componentCase of componentCases) { + const result = runCli(projectRoot, [ + 'component', + 'create', + componentCase.name, + '--type', + componentCase.type, + '--directory', + 'components', + ]); + + assert.equal( + result.status, + 0, + commandFailure(`component create --type ${componentCase.type}`, result), + ); + assert.deepEqual( + readdirSync(join(projectRoot, 'components', componentCase.name)).sort(), + componentCase.files, + `${componentCase.type} should create only its documented artifacts`, + ); + } + }); + test('executes the project system-install hook', () => { assert.equal( existsSync(systemHookSentinel), diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index d4d92d9..449d9eb 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -24,7 +24,8 @@ COMPONENTS --dry-run Preview without writing files component create [name] Generate a new local component -d, --directory Variant structure to create it in - -f, --format Component format + -t, --type twig | twig-sdc | react | web-component + -f, --format Deprecated Twig type alias --dry-run Preview without writing files SYSTEMS From 3fc3bbbaa78474e872113e6d802c438b48031c44 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 03:31:55 -0500 Subject: [PATCH 09/33] docs(component): document component types and template overrides --- README.md | 18 +++-- docs/cli-reference.md | 46 ++++++++---- docs/component-template-overrides.md | 100 +++++++++++++++++++-------- docs/components.md | 89 ++++++++++++++++++++---- docs/configuration.md | 2 +- docs/emulsify-info-cli-updates.md | 86 ++++++++++++++++++----- 6 files changed, 259 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 3e0922b..1856772 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cd ./web/themes/custom/my_theme emulsify system install emulsify component list emulsify component install card -emulsify component create promo-card --directory molecules --format default +emulsify component create promo-card --directory molecules --type twig ``` Built-in platforms are `drupal`, `wordpress`, and `none`. For WordPress child themes, use the WordPress platform and starter: @@ -72,7 +72,11 @@ update `system.emulsify.json`; `system create` does not import them automatically. Interactive terminals can run `emulsify component create` with no arguments to -walk through the component name, format, and directory prompts. Likewise, +walk through the component name, type, and directory prompts. The type picker +always offers Twig, offers Twig SDC in Drupal projects, and offers React and Web +Component scaffolds when the project's `package.json` declares +`@emulsify/core`. When a choice is unavailable, the wizard explains why; when +Twig is the only suitable choice, it skips the one-item prompt. Likewise, `emulsify component install` with no name presents the components available in the installed system variant plus an explicit choice to install all components. @@ -87,14 +91,18 @@ emulsify system install compound emulsify component install card --force # Or install every available component: emulsify component install --all -emulsify component create promo-card --directory molecules --format default --yes +emulsify component create promo-card --directory molecules --type twig --yes emulsify system detach --yes ``` For component installation, provide either a component name or `--all`, and use `--force` when an existing destination should be replaced. For component -creation, provide the positional name plus `--format` and `--directory`, and use -`--yes` when an existing generated component should be replaced. +creation, provide the positional name plus `--type` and `--directory`, and use +`--yes` when an existing generated component should be replaced. Explicit +`--type` values are honored even when project detection would hide that choice +from the wizard. The deprecated `--format default` and `--format sdc` forms +remain available as aliases for `--type twig` and `--type twig-sdc`, +respectively, and print a deprecation warning. ## Documentation diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 24bafe9..f4656c5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -393,30 +393,48 @@ emulsify component c [name] Run without `[name]` in an interactive terminal to start the complete creation wizard. The CLI prompts for the component name first, explains invalid names and -prompts again, then asks for any missing format and directory values. Supplying +prompts again, then asks for any missing type and directory values. Supplying any of those values on the command line skips its corresponding prompt. +The interactive type picker always offers Twig. It offers Twig SDC only for a +Drupal project, and offers React and Web Component when the project's +`package.json` declares `@emulsify/core`. A hint explains omitted choices. If +Twig is the only suitable type, the CLI selects it without displaying a +one-choice prompt. These filters do not restrict explicit flags: `--type` is +always honored, with a warning rather than a failure when React or Web Component +is requested but Core is not detected. + Options: -| Option | Description | -| ----------------------------- | ------------------------------------------------------------------------------------- | -| `-d, --directory ` | Variant structure name where the component should be created. | -| `-f, --format ` | Component format to generate. | -| `-y, --yes` | Replace an existing generated component without prompting. | -| `--dry-run` | Preview destination and generated files without writing, removing, or creating files. | -| `--refresh` | Check the system's remote ref before reusing its local cache entry. | +| Option | Description | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `-d, --directory ` | Variant structure name where the component should be created. | +| `-t, --type ` | Component renderer and packaging type. | +| `-f, --format ` | Deprecated alias: `default` maps to `twig`; `sdc` maps to `twig-sdc`. Prints a deprecation warning. | +| `-y, --yes` | Replace an existing generated component without prompting. | +| `--dry-run` | Preview destination and generated files without writing, removing, or creating files. | +| `--refresh` | Check the system's remote ref before reusing its local cache entry. | Examples: ```bash -emulsify component create promo-card --directory molecules --format default -emulsify component create promo-card --directory molecules --format default --refresh -emulsify component create promo-card --directory molecules --format default --dry-run -emulsify component c teaser --directory molecules --format sdc --yes +emulsify component create promo-card --directory molecules --type twig +emulsify component create teaser --directory molecules --type twig-sdc +emulsify component create promo-card --directory molecules --type react +emulsify component create promo-card --directory molecules --type web-component +emulsify component create promo-card --directory molecules --type twig --dry-run ``` +React scaffolds use Storybook's standard React support. Web Component stories +use Emulsify Core's `renderWebComponent` helper. For Web Components, a +hyphenated component filename is also the custom element tag. A single-word +filename is prefixed with the project's machine name, so `card` in `acme-theme` +becomes ``. The wizard confirms this tag and allows an +override; non-interactive creation derives and validates it silently. + When standard input is not a TTY, provide the positional `[name]` plus both -`--directory` and `--format`; otherwise the command exits with an actionable +`--directory` and `--type`; otherwise the command exits with an actionable error instead of waiting for prompts that cannot be answered. If the generated component already exists, also pass `--yes` to replace it without an overwrite -prompt. +prompt. Existing scripts may continue using `--format default` or +`--format sdc`; both aliases work and print a deprecation warning. diff --git a/docs/component-template-overrides.md b/docs/component-template-overrides.md index 36be8f3..6f0a95e 100644 --- a/docs/component-template-overrides.md +++ b/docs/component-template-overrides.md @@ -6,40 +6,78 @@ Overrides replace known generated files one-for-one. They do not add arbitrary e ## Directory Layout -Default component overrides: +Twig overrides: ```text -.cli/templates/default/component.twig -.cli/templates/default/component.scss -.cli/templates/default/component.yml -.cli/templates/default/component.stories.js +.cli/templates/twig/component.twig +.cli/templates/twig/component.scss +.cli/templates/twig/component.yml +.cli/templates/twig/component.stories.js ``` -SDC component overrides: +Twig SDC overrides: ```text -.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 +.cli/templates/twig-sdc/component.twig +.cli/templates/twig-sdc/component.scss +.cli/templates/twig-sdc/component.component.yml +.cli/templates/twig-sdc/component.js +.cli/templates/twig-sdc/component.stories.js ``` -For each generated artifact, the CLI looks for the matching override first. If the file is missing, the built-in template is used. +React overrides: + +```text +.cli/templates/react/component.jsx +.cli/templates/react/component.scss +.cli/templates/react/component.stories.jsx +``` + +Web Component overrides: + +```text +.cli/templates/web-component/component.js +.cli/templates/web-component/component.scss +.cli/templates/web-component/component.stories.js +``` + +The directory name follows the canonical component `--type` value. + +### Legacy Twig Directory Aliases + +Existing override directories continue to work. For each Twig artifact, the +CLI looks in `.cli/templates/twig/` first and, when that artifact is absent, +looks in `.cli/templates/default/`. For each Twig SDC artifact, it looks in +`.cli/templates/twig-sdc/` first and, when absent, in +`.cli/templates/sdc/`. If neither path contains the artifact, the built-in +template is used. An override file that exists but is empty is ignored in favor +of the built-in template and produces a warning. + +This precedence applies one artifact at a time, so a partial canonical override +does not disable legacy overrides for the remaining files. `default/` and +`sdc/` are compatibility aliases; use `twig/` and `twig-sdc/` for new +customizations. React and Web Component overrides have no legacy aliases. ## Supported Tokens Override files can use double-brace tokens. -| Token | Example Value For `featured-item` | -| ----------------- | --------------------------------- | -| `{{ filename }}` | `featured-item` | -| `{{ className }}` | `featured-item` | -| `{{ camelName }}` | `featuredItem` | -| `{{ snakeName }}` | `featured_item` | -| `{{ humanName }}` | `Featured Item` | -| `{{ directory }}` | `base` | -| `{{ format }}` | `default` or `sdc` | +| Token | Example Value For `featured-item` | +| ------------------ | ---------------------------------------------------------------------------- | +| `{{ filename }}` | `featured-item` | +| `{{ className }}` | `featured-item` | +| `{{ camelName }}` | `featuredItem` | +| `{{ pascalName }}` | `FeaturedItem` | +| `{{ snakeName }}` | `featured_item` | +| `{{ humanName }}` | `Featured Item` | +| `{{ directory }}` | `base` | +| `{{ type }}` | `twig`, `twig-sdc`, `react`, or `web-component` | +| `{{ tagName }}` | `featured-item` for a Web Component; an empty string for every other type | +| `{{ format }}` | `default` for Twig, `sdc` for Twig SDC, otherwise `react` or `web-component` | + +`{{ type }}` is the canonical token for new overrides. `{{ format }}` remains +populated so existing Twig and Twig SDC overrides keep rendering the same +values after migrating from `--format` to `--type`. Whitespace inside the braces is optional: @@ -50,9 +88,9 @@ Whitespace inside the braces is optional: Unknown tokens are left unchanged and logged as warnings. Empty override files are ignored and the built-in template is used. -## Example Default Twig Override +## Example Twig Override -Create `.cli/templates/default/component.twig`: +Create `.cli/templates/twig/component.twig`: ```twig {% set classes = [ @@ -68,7 +106,7 @@ Create `.cli/templates/default/component.twig`: Then generate a component: ```bash -emulsify component create featured-item --directory base --format default +emulsify component create featured-item --directory base --type twig ``` The generated file is: @@ -79,7 +117,7 @@ components/00-base/featured-item/featured-item.twig ## Example SDC Metadata Override -Create `.cli/templates/sdc/component.component.yml`: +Create `.cli/templates/twig-sdc/component.component.yml`: ```yaml name: {{ humanName }} @@ -98,7 +136,7 @@ slots: Generate the SDC component: ```bash -emulsify component create featured-item --directory base --format sdc +emulsify component create featured-item --directory base --type twig-sdc ``` The generated file is: @@ -112,17 +150,19 @@ components/00-base/featured-item/featured-item.component.yml Override only the artifacts you need. For example, a project can override Twig and keep the built-in SCSS, data, and story templates: ```text -.cli/templates/default/component.twig +.cli/templates/twig/component.twig ``` -All missing override files fall back to the built-in builders. +All missing override files fall through the legacy directory alias, where one +exists, and then to the built-in builders. ## Dry-Run With Overrides -Dry runs do not write files, but they still resolve the selected format, structure, and output paths: +Dry runs do not write files, but they still resolve the selected type, +structure, and output paths: ```bash -emulsify component create featured-item --directory base --format default --dry-run +emulsify component create featured-item --directory base --type twig --dry-run ``` Use dry runs to confirm the component destination before replacing or adding override files. diff --git a/docs/components.md b/docs/components.md index eb4fbc7..86be364 100644 --- a/docs/components.md +++ b/docs/components.md @@ -115,12 +115,25 @@ emulsify component create ``` The CLI prompts for a component name first. Invalid names are explained and -prompted again, after which the CLI prompts for any missing format and directory +prompted again, after which the CLI prompts for any missing type and directory values. +The type choices adapt to the current project. Twig is always available. +`twig-sdc` appears only when `project.platform` is `drupal`, because Single +Directory Components are a Drupal feature. React and Web Component choices +appear when the project's `package.json` declares `@emulsify/core`, which +indicates that Core's Storybook workspace is available. The wizard explains why +it omitted choices, and it skips the type prompt entirely when Twig is the only +suitable option. + +This filtering applies only to the wizard. An explicit `--type` is always +honored. If React or Web Component is requested without a detected Core +dependency, the CLI warns and generates the component so monorepos and unusual +install layouts are not blocked. + ```bash -emulsify component create promo-card --directory molecules --format default -emulsify component c teaser --directory molecules --format sdc +emulsify component create promo-card --directory molecules --type twig +emulsify component c teaser --directory molecules --type twig-sdc ``` Component names may include letters, numbers, and single hyphens between words. The CLI derives reusable name forms from the input. @@ -139,7 +152,7 @@ The destination is: For a Drupal variant structure named `base` with directory `components/00-base`, this command: ```bash -emulsify component create featured-item --directory base --format default +emulsify component create featured-item --directory base --type twig ``` Creates: @@ -148,9 +161,19 @@ Creates: components/00-base/featured-item ``` -## Generated Formats +## Generated Component Types + +Choose a type based on how the component should render and, for Drupal SDC, +how it should be packaged: -Default components generate: +| Type | Use It For | +| --------------- | ---------------------------------------------------------------------------------------------------- | +| `twig` | A standard Twig component that can be used across Emulsify platforms. | +| `twig-sdc` | A Twig component packaged as a Drupal Single Directory Component. | +| `react` | A React component rendered with Storybook's standard React support. | +| `web-component` | A browser-native custom element whose story uses Emulsify Core's `renderWebComponent` Storybook API. | + +Twig components generate: ```text .twig @@ -159,7 +182,7 @@ Default components generate: .stories.js ``` -SDC components generate: +Twig SDC components generate: ```text .twig @@ -169,34 +192,72 @@ SDC components generate: .stories.js ``` +React components generate: + +```text +.jsx +.scss +.stories.jsx +``` + +Web Components generate: + +```text +.js +.scss +.stories.js +``` + +React and Web Component scaffolds do not generate Twig files. + +### Web Component Tag Names + +Custom element tag names must contain a hyphen. For a component whose derived +filename already contains one, that filename becomes the tag name. For example, +`featured-item` generates ``. + +For a single-word component, the CLI prefixes the filename with the project's +machine name. In a project whose machine name is `acme-theme`, `card` generates +``. The interactive wizard confirms the derived tag name and +lets you override it. In non-interactive mode, the CLI derives the value +silently and validates it before writing files; an invalid tag fails with an +actionable error rather than generating a custom element the browser would +reject. + ## Create Dry Runs Use `--dry-run` to preview component creation without writing files. ```bash -emulsify component create featured-item --directory base --format default --dry-run -emulsify component create featured-item --directory base --format sdc --dry-run +emulsify component create featured-item --directory base --type twig --dry-run +emulsify component create featured-item --directory base --type twig-sdc --dry-run ``` -Dry-run output includes the selected format, structure path, parent directory, final destination, whether the destination exists, and generated file paths. +Dry-run output includes the selected type, structure path, parent directory, +final destination, whether the destination exists, and generated file paths. ## Non-Interactive Creation Prompts only run when standard input is a TTY. In CI, scripts, and commands with piped or redirected input, provide the positional component name plus both -`--format` and `--directory`; otherwise the command exits with an actionable +`--type` and `--directory`; otherwise the command exits with an actionable error instead of waiting for input: ```bash -emulsify component create featured-item --directory base --format default +emulsify component create featured-item --directory base --type twig ``` +For compatibility with existing scripts, deprecated `--format default` maps to +`--type twig` and `--format sdc` maps to `--type twig-sdc`. Both legacy forms +print a deprecation warning. + Use `--yes` when the command should replace an existing generated component without asking: ```bash -emulsify component create featured-item --directory base --format default --yes +emulsify component create featured-item --directory base --type twig --yes ``` ## Template Overrides -Projects can override the generated files with `.cli/templates//...` files. See [Component Template Overrides](./component-template-overrides.md). +Projects can override the generated files with `.cli/templates//...` +files. See [Component Template Overrides](./component-template-overrides.md). diff --git a/docs/configuration.md b/docs/configuration.md index a9c5e3e..cc2bec2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,7 +134,7 @@ The destination is: For `component create`, the `--directory` option takes the structure implementation name, not the filesystem path: ```bash -emulsify component create promo --directory base --format default +emulsify component create promo --directory base --type twig ``` ## Validation diff --git a/docs/emulsify-info-cli-updates.md b/docs/emulsify-info-cli-updates.md index b638fee..ac7b026 100644 --- a/docs/emulsify-info-cli-updates.md +++ b/docs/emulsify-info-cli-updates.md @@ -132,48 +132,98 @@ emulsify component install --all Options: - `--directory `: Sets the variant structure where the component is created. -- `--format `: Sets the component format. Supported values are `default` and `sdc`. +- `--type `: Sets the component type. Supported values are `twig`, `twig-sdc`, `react`, and `web-component`. +- `--format `: Deprecated compatibility alias. `default` maps to `twig`; `sdc` maps to `twig-sdc`, and both print a warning. - `--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`. +In the interactive wizard, Twig is always available, Twig SDC is shown only +for Drupal projects, and React and Web Component are shown when the project's +`package.json` declares `@emulsify/core`. The wizard explains why choices were +omitted and skips the type prompt when Twig is the only suitable choice. +Explicit `--type` values are always honored; requesting React or Web Component +without a detected Core dependency warns and proceeds. + +In non-interactive environments, pass both `--directory` and `--type`. Examples: ```bash -emulsify component create promo-card --directory molecules --format default -emulsify component create promo-card --directory molecules --format default --dry-run -emulsify component create teaser --directory molecules --format sdc --yes -emulsify component create teaser --directory molecules --format sdc --dry-run +emulsify component create promo-card --directory molecules --type twig +emulsify component create teaser --directory molecules --type twig-sdc --yes +emulsify component create promo-card --directory molecules --type react +emulsify component create promo-card --directory molecules --type web-component +emulsify component create promo-card --directory molecules --type twig --dry-run ``` +Generated artifact sets: + +- `twig`: `.twig`, `.scss`, `.yml`, and `.stories.js`. +- `twig-sdc`: `.twig`, `.scss`, `.component.yml`, `.js`, and `.stories.js`. +- `react`: `.jsx`, `.scss`, and `.stories.jsx`; stories use standard Storybook React support. +- `web-component`: `.js`, `.scss`, and `.stories.js`; stories use Emulsify Core's `renderWebComponent` helper. + +React and Web Component scaffolds do not include a Twig file. Web Component +tag names must contain a hyphen. A hyphenated filename is used directly; +otherwise the project machine name is prefixed, so `card` in `acme-theme` +becomes ``. The wizard confirms and can override the tag. +Non-interactive creation derives and validates it. + ## 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: +Twig component overrides: + +- `.cli/templates/twig/component.twig` +- `.cli/templates/twig/component.scss` +- `.cli/templates/twig/component.yml` +- `.cli/templates/twig/component.stories.js` -- `.cli/templates/default/component.twig` -- `.cli/templates/default/component.scss` -- `.cli/templates/default/component.yml` -- `.cli/templates/default/component.stories.js` +Twig SDC component overrides: -SDC component overrides: +- `.cli/templates/twig-sdc/component.twig` +- `.cli/templates/twig-sdc/component.scss` +- `.cli/templates/twig-sdc/component.component.yml` +- `.cli/templates/twig-sdc/component.js` +- `.cli/templates/twig-sdc/component.stories.js` -- `.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` +React component overrides: + +- `.cli/templates/react/component.jsx` +- `.cli/templates/react/component.scss` +- `.cli/templates/react/component.stories.jsx` + +Web Component overrides: + +- `.cli/templates/web-component/component.js` +- `.cli/templates/web-component/component.scss` +- `.cli/templates/web-component/component.stories.js` + +For each Twig artifact, the CLI checks `twig/` and, if that artifact is absent, +the legacy `default/` alias. For Twig SDC it checks `twig-sdc/` and then the +legacy `sdc/` alias under the same rule. The fallback is resolved per artifact, +so partial legacy override sets continue working. If neither path contains the +artifact, the built-in template is used. Override files can use double-brace tokens: - `{{ filename }}` - `{{ className }}` - `{{ camelName }}` +- `{{ pascalName }}` - `{{ snakeName }}` - `{{ humanName }}` - `{{ directory }}` +- `{{ type }}` +- `{{ tagName }}` - `{{ 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. +`{{ type }}` contains the canonical type. `{{ tagName }}` contains the +validated Web Component tag and is empty for the other types. For compatibility, +`{{ format }}` remains `default` for Twig and `sdc` for Twig SDC; it contains +`react` or `web-component` for the new types. + +If an override is unavailable, the built-in template is used. If an override +exists but is empty, it is ignored and a warning is logged. Unknown tokens are +left unchanged and logged as warnings. Partial override sets are supported. From fafbf87e698fd2e115844018a31c2d5412db76ee Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 03:40:45 -0500 Subject: [PATCH 10/33] refactor(component): make derived template values token-expressible --- .../componentTemplates/componentTypes.test.ts | 64 ++++++++++++++++++- .../componentTemplates/reactStories.ts | 6 +- src/util/project/componentTemplates/scss.ts | 11 ++-- .../project/componentTemplates/sdcStories.ts | 6 +- .../project/componentTemplates/stories.ts | 6 +- src/util/project/componentTemplates/twig.ts | 8 +-- .../componentTemplates/webComponentStories.ts | 6 +- src/util/project/componentTypes.test.ts | 15 +++++ src/util/project/componentTypes.ts | 12 ++++ src/util/project/generateComponent.ts | 32 +++++++--- src/util/project/renderTemplate.test.ts | 6 +- src/util/project/renderTemplate.ts | 7 +- .../project/resolveComponentTemplate.test.ts | 2 + src/util/project/resolveComponentTemplate.ts | 2 + src/util/system/buildSystemScaffold.test.ts | 6 +- src/util/system/buildSystemScaffold.ts | 9 +-- 16 files changed, 154 insertions(+), 44 deletions(-) diff --git a/src/util/project/componentTemplates/componentTypes.test.ts b/src/util/project/componentTemplates/componentTypes.test.ts index 297d305..54ce0b5 100644 --- a/src/util/project/componentTemplates/componentTypes.test.ts +++ b/src/util/project/componentTemplates/componentTypes.test.ts @@ -1,10 +1,70 @@ import { buildReactStoriesTemplate, buildReactTemplate, + buildScssTemplate, + buildSdcStoriesTemplate, + buildStoriesTemplate, + buildTwigTemplate, buildWebComponentStoriesTemplate, buildWebComponentTemplate, } from './index.js'; +describe('derived display values', () => { + it('uses the supplied format label without transforming it', () => { + expect.assertions(2); + + expect( + buildTwigTemplate( + 'featured-item', + 'featured_item', + 'featured-item', + 'DEFAULT', + ), + ).toContain('* Format: DEFAULT'); + expect(buildScssTemplate('featured-item', 'DEFAULT')).toContain( + '(DEFAULT)', + ); + }); + + it('uses the supplied Storybook directory title without transforming it', () => { + expect.assertions(4); + + expect( + buildStoriesTemplate( + 'featuredItem', + 'featured-item', + 'Featured Item', + 'components', + ), + ).toContain("title: 'components/Featured Item'"); + expect( + buildSdcStoriesTemplate( + 'featuredItem', + 'featured-item', + 'featured_item', + 'Featured Item', + 'components', + ), + ).toContain("title: 'components/Featured Item'"); + expect( + buildReactStoriesTemplate( + 'FeaturedItem', + 'featured-item', + 'Featured Item', + 'components', + ), + ).toContain("title: 'components/Featured Item'"); + expect( + buildWebComponentStoriesTemplate( + 'featured-item', + 'Featured Item', + 'components', + 'featured-item', + ), + ).toContain("title: 'components/Featured Item'"); + }); +}); + describe('React component templates', () => { it('builds a named JSX component with the shared component class names', () => { expect.assertions(6); @@ -31,7 +91,7 @@ describe('React component templates', () => { 'FeaturedItem', 'featured-item', 'Featured Item', - 'components', + 'Components', ); expect(template).toContain( @@ -76,7 +136,7 @@ describe('web component templates', () => { const template = buildWebComponentStoriesTemplate( 'featured-item', 'Featured Item', - 'components', + 'Components', 'featured-item', ); diff --git a/src/util/project/componentTemplates/reactStories.ts b/src/util/project/componentTemplates/reactStories.ts index cdba1d6..c451450 100644 --- a/src/util/project/componentTemplates/reactStories.ts +++ b/src/util/project/componentTemplates/reactStories.ts @@ -8,14 +8,14 @@ * @param pascalName PascalCase JavaScript component identifier. * @param filename Kebab-case component file and folder name. * @param humanName Human-readable component name shown in Storybook. - * @param directory Component structure name used as the Storybook title group. + * @param directoryTitle Display-ready Storybook title group. * @returns JSX source content for the generated React Storybook story. */ export function buildReactStoriesTemplate( pascalName: string, filename: string, humanName: string, - directory: string, + directoryTitle: string, ): string { return `import { ${pascalName} } from './${filename}.jsx'; @@ -23,7 +23,7 @@ export function buildReactStoriesTemplate( * Storybook Definition. */ export default { - title: '${directory[0].toUpperCase() + directory.slice(1)}/${humanName}', + title: '${directoryTitle}/${humanName}', component: ${pascalName}, args: { heading: '${humanName} Component', diff --git a/src/util/project/componentTemplates/scss.ts b/src/util/project/componentTemplates/scss.ts index fa5a048..d071fe0 100644 --- a/src/util/project/componentTemplates/scss.ts +++ b/src/util/project/componentTemplates/scss.ts @@ -6,14 +6,15 @@ * Generates the base SCSS file for a component. * * @param className CSS base class name used by the component markup. - * @param format Uppercase component format label used in the file header. + * @param formatLabel Display-ready component format label used in the file header. * @returns SCSS source content for the generated component stylesheet. */ -export function buildScssTemplate(className: string, format: string): string { - const label = format === 'DEFAULT' ? 'STANDARD' : format; - +export function buildScssTemplate( + className: string, + formatLabel: string, +): string { return `/* - * Base Styles for ${className} (${label}) + * Base Styles for ${className} (${formatLabel}) * * These styles are provided as a starting point. * Replace or extend them to match your project's design system. diff --git a/src/util/project/componentTemplates/sdcStories.ts b/src/util/project/componentTemplates/sdcStories.ts index db42361..2edaf5a 100644 --- a/src/util/project/componentTemplates/sdcStories.ts +++ b/src/util/project/componentTemplates/sdcStories.ts @@ -9,7 +9,7 @@ * @param filename Kebab-case component file and folder name. * @param snakeName Snake-case prefix used by the generated SDC props. * @param humanName Human-readable component name shown in Storybook. - * @param directory Component structure name used as the Storybook title group. + * @param directoryTitle Display-ready Storybook title group. * @returns JavaScript source content for the generated SDC Storybook story. */ export function buildSdcStoriesTemplate( @@ -17,7 +17,7 @@ export function buildSdcStoriesTemplate( filename: string, snakeName: string, humanName: string, - directory: string, + directoryTitle: string, ): string { return `import ${camelName}Twig from './${filename}.twig'; import { props } from './${filename}.component.yml'; @@ -29,7 +29,7 @@ const ${camelName}Data = props.properties; * Storybook Definition. */ export default { - title: '${directory[0].toUpperCase() + directory.slice(1)}/${humanName}', + title: '${directoryTitle}/${humanName}', args: { heading: ${camelName}Data.${snakeName}__heading.data, content: ${camelName}Data.${snakeName}__content.data, diff --git a/src/util/project/componentTemplates/stories.ts b/src/util/project/componentTemplates/stories.ts index b1e3b62..c837e55 100644 --- a/src/util/project/componentTemplates/stories.ts +++ b/src/util/project/componentTemplates/stories.ts @@ -8,14 +8,14 @@ * @param camelName JavaScript-safe camelCase component identifier. * @param filename Kebab-case component file and folder name. * @param humanName Human-readable component name shown in Storybook. - * @param directory Component structure name used as the Storybook title group. + * @param directoryTitle Display-ready Storybook title group. * @returns JavaScript source content for the generated standard Storybook story. */ export function buildStoriesTemplate( camelName: string, filename: string, humanName: string, - directory: string, + directoryTitle: string, ): string { return `import ${camelName}Twig from './${filename}.twig'; import ${camelName}Data from './${filename}.yml'; @@ -23,7 +23,7 @@ import ${camelName}Data from './${filename}.yml'; /** * Storybook Definition. */ -export default { title: '${directory[0].toUpperCase() + directory.slice(1)}/${humanName}' }; +export default { title: '${directoryTitle}/${humanName}' }; export const ${camelName} = () => ${camelName}Twig(${camelName}Data); `; diff --git a/src/util/project/componentTemplates/twig.ts b/src/util/project/componentTemplates/twig.ts index b4c87b1..8e98ae2 100644 --- a/src/util/project/componentTemplates/twig.ts +++ b/src/util/project/componentTemplates/twig.ts @@ -8,22 +8,20 @@ * @param filename Kebab-case component file and folder name. * @param snakeName Snake-case prefix used by Twig variables and blocks. * @param className CSS base class name used by the component markup. - * @param format Uppercase component format label used in the file header. + * @param formatLabel Display-ready component format label used in the file header. * @returns Twig source content for the generated component markup file. */ export function buildTwigTemplate( filename: string, snakeName: string, className: string, - format: string, + formatLabel: string, ): string { - const label = format === 'DEFAULT' ? 'STANDARD' : format; - return `{# /** * @file * ${filename}.twig - * Format: ${label} + * Format: ${formatLabel} * * Available variables: * - ${snakeName}__heading - the heading text for this component diff --git a/src/util/project/componentTemplates/webComponentStories.ts b/src/util/project/componentTemplates/webComponentStories.ts index ae8cf99..803a997 100644 --- a/src/util/project/componentTemplates/webComponentStories.ts +++ b/src/util/project/componentTemplates/webComponentStories.ts @@ -7,14 +7,14 @@ * * @param filename Kebab-case component file and folder name. * @param humanName Human-readable component name shown in Storybook. - * @param directory Component structure name used as the Storybook title group. + * @param directoryTitle Display-ready Storybook title group. * @param tagName Valid autonomous custom-element tag name. * @returns JavaScript source content for the generated web component story. */ export function buildWebComponentStoriesTemplate( filename: string, humanName: string, - directory: string, + directoryTitle: string, tagName: string, ): string { return `import { renderWebComponent } from '@emulsify/core/storybook'; @@ -24,7 +24,7 @@ import './${filename}.js'; * Storybook Definition. */ export default { - title: '${directory[0].toUpperCase() + directory.slice(1)}/${humanName}', + title: '${directoryTitle}/${humanName}', render: renderWebComponent('${tagName}'), args: { heading: '${humanName} Component', diff --git a/src/util/project/componentTypes.test.ts b/src/util/project/componentTypes.test.ts index ddc22e7..7cf8fb7 100644 --- a/src/util/project/componentTypes.test.ts +++ b/src/util/project/componentTypes.test.ts @@ -10,6 +10,7 @@ import loadJsonFile from '../fs/loadJsonFile.js'; import { componentTypeFromLegacyFormat, getAvailableComponentTypes, + getComponentFormatLabel, getCompatibleFormatToken, normalizeComponentType, projectDeclaresEmulsifyCore, @@ -74,6 +75,20 @@ describe('component type utilities', () => { expect(getCompatibleFormatToken(type)).toBe(expected); }); + it.each([ + ['twig', 'STANDARD'], + ['twig-sdc', 'SDC'], + ['react', 'REACT'], + ['web-component', 'WEB COMPONENT'], + ] as const)( + 'uses the display-ready format label for %s', + (type, expected) => { + expect.assertions(1); + + expect(getComponentFormatLabel(type)).toBe(expected); + }, + ); + describe('getAvailableComponentTypes', () => { it.each([ ['drupal', true, ['twig', 'twig-sdc', 'react', 'web-component']], diff --git a/src/util/project/componentTypes.ts b/src/util/project/componentTypes.ts index 0aae7fe..c5847f7 100644 --- a/src/util/project/componentTypes.ts +++ b/src/util/project/componentTypes.ts @@ -25,6 +25,13 @@ const LEGACY_FORMAT_TO_TYPE = { type LegacyComponentFormat = keyof typeof LEGACY_FORMAT_TO_TYPE; +const COMPONENT_FORMAT_LABELS: Record = { + twig: 'STANDARD', + 'twig-sdc': 'SDC', + react: 'REACT', + 'web-component': 'WEB COMPONENT', +}; + type ProjectPackage = { dependencies?: Record; devDependencies?: Record; @@ -65,6 +72,11 @@ export function getCompatibleFormatToken(type: ComponentType): string { return type; } +/** Return the display label used in generated component file headers. */ +export function getComponentFormatLabel(type: ComponentType): string { + return COMPONENT_FORMAT_LABELS[type]; +} + /** Return the types the interactive wizard can safely recommend. */ export function getAvailableComponentTypes( platform: Platform, diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index 8462ab6..0e575cc 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -23,6 +23,7 @@ import { runPrompt } from '../prompt/index.js'; import { componentTypeFromLegacyFormat, getAvailableComponentTypes, + getComponentFormatLabel, getCompatibleFormatToken, MISSING_COMPONENT_DIRECTORY_ERROR, MISSING_COMPONENT_TYPE_ERROR, @@ -244,6 +245,9 @@ export default async function generateComponent( ); } + const directoryTitle = `${directory.charAt(0).toUpperCase()}${directory.slice(1)}`; + const formatLabel = getComponentFormatLabel(type); + // Calculate the parent path based on the path to the Emulsify project and the component's structure. const parentPath = safeResolveWithin( projectRoot, @@ -271,7 +275,9 @@ export default async function generateComponent( snakeName, humanName, directory, + directoryTitle, format: getCompatibleFormatToken(type), + formatLabel, type, tagName, }; @@ -284,12 +290,12 @@ export default async function generateComponent( logicalName: 'component.twig', destinationName: `${filename}.twig`, build: () => - buildTwigTemplate(filename, snakeName, className, 'DEFAULT'), + buildTwigTemplate(filename, snakeName, className, formatLabel), }, { logicalName: 'component.scss', destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, 'DEFAULT'), + build: () => buildScssTemplate(className, formatLabel), }, { logicalName: 'component.yml', @@ -300,7 +306,12 @@ export default async function generateComponent( logicalName: 'component.stories.js', destinationName: `${filename}.stories.js`, build: () => - buildStoriesTemplate(camelName, filename, humanName, directory), + buildStoriesTemplate( + camelName, + filename, + humanName, + directoryTitle, + ), }, ]; break; @@ -309,12 +320,13 @@ export default async function generateComponent( { logicalName: 'component.twig', destinationName: `${filename}.twig`, - build: () => buildTwigTemplate(filename, snakeName, className, 'SDC'), + build: () => + buildTwigTemplate(filename, snakeName, className, formatLabel), }, { logicalName: 'component.scss', destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, 'SDC'), + build: () => buildScssTemplate(className, formatLabel), }, { logicalName: 'component.component.yml', @@ -335,7 +347,7 @@ export default async function generateComponent( filename, snakeName, humanName, - directory, + directoryTitle, ), }, ]; @@ -351,7 +363,7 @@ export default async function generateComponent( { logicalName: 'component.scss', destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, 'REACT'), + build: () => buildScssTemplate(className, formatLabel), }, { logicalName: 'component.stories.jsx', @@ -361,7 +373,7 @@ export default async function generateComponent( pascalName, filename, humanName, - directory, + directoryTitle, ), }, ]; @@ -383,7 +395,7 @@ export default async function generateComponent( { logicalName: 'component.scss', destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, 'WEB COMPONENT'), + build: () => buildScssTemplate(className, formatLabel), }, { logicalName: 'component.stories.js', @@ -392,7 +404,7 @@ export default async function generateComponent( buildWebComponentStoriesTemplate( filename, humanName, - directory, + directoryTitle, tagName, ), }, diff --git a/src/util/project/renderTemplate.test.ts b/src/util/project/renderTemplate.test.ts index 6add0e7..a0b9dab 100644 --- a/src/util/project/renderTemplate.test.ts +++ b/src/util/project/renderTemplate.test.ts @@ -16,7 +16,9 @@ const vars: ComponentTemplateVars = { snakeName: 'featured_item', humanName: 'Featured Item', directory: 'base', + directoryTitle: 'Base', format: 'default', + formatLabel: 'STANDARD', type: 'web-component', tagName: 'featured-item', }; @@ -31,11 +33,11 @@ describe('renderTemplate', () => { expect( renderTemplate( - '{{ filename }}|{{ className }}|{{ camelName }}|{{ pascalName }}|{{ snakeName }}|{{ humanName }}|{{ directory }}|{{ format }}|{{ type }}|{{ tagName }}', + '{{ filename }}|{{ className }}|{{ camelName }}|{{ pascalName }}|{{ snakeName }}|{{ humanName }}|{{ directory }}|{{ directoryTitle }}|{{ format }}|{{ formatLabel }}|{{ type }}|{{ tagName }}', vars, ), ).toBe( - 'featured-item|featured-item|featuredItem|FeaturedItem|featured_item|Featured Item|base|default|web-component|featured-item', + 'featured-item|featured-item|featuredItem|FeaturedItem|featured_item|Featured Item|base|Base|default|STANDARD|web-component|featured-item', ); }); diff --git a/src/util/project/renderTemplate.ts b/src/util/project/renderTemplate.ts index b14cbff..5d709f8 100644 --- a/src/util/project/renderTemplate.ts +++ b/src/util/project/renderTemplate.ts @@ -12,7 +12,9 @@ export type ComponentTemplateVars = { snakeName: string; humanName: string; directory: string; + directoryTitle: string; format: string; + formatLabel: string; type: string; tagName: string; }; @@ -24,7 +26,8 @@ const tokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; * * Supported tokens are `{{ filename }}`, `{{ className }}`, `{{ camelName }}`, * `{{ pascalName }}`, `{{ snakeName }}`, `{{ humanName }}`, `{{ directory }}`, - * `{{ format }}`, `{{ type }}`, and `{{ tagName }}`. + * `{{ directoryTitle }}`, `{{ format }}`, `{{ formatLabel }}`, `{{ type }}`, + * and `{{ tagName }}`. * Unknown tokens are left unchanged and logged as warnings. * * @param template raw template file content containing optional double-brace tokens. @@ -41,7 +44,9 @@ const tokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; * snakeName: 'featured_item', * humanName: 'Featured Item', * directory: 'base', + * directoryTitle: 'Base', * format: 'default', + * formatLabel: 'STANDARD', * type: 'twig', * tagName: '', * }); diff --git a/src/util/project/resolveComponentTemplate.test.ts b/src/util/project/resolveComponentTemplate.test.ts index 12e2b75..521e291 100644 --- a/src/util/project/resolveComponentTemplate.test.ts +++ b/src/util/project/resolveComponentTemplate.test.ts @@ -30,7 +30,9 @@ const vars: ComponentTemplateVars = { snakeName: 'featured_item', humanName: 'Featured Item', directory: 'base', + directoryTitle: 'Base', format: 'default', + formatLabel: 'STANDARD', type: 'twig', tagName: '', }; diff --git a/src/util/project/resolveComponentTemplate.ts b/src/util/project/resolveComponentTemplate.ts index dc05e12..976d042 100644 --- a/src/util/project/resolveComponentTemplate.ts +++ b/src/util/project/resolveComponentTemplate.ts @@ -39,7 +39,9 @@ const LEGACY_TEMPLATE_DIRECTORIES: Partial> = { * snakeName: 'featured_item', * humanName: 'Featured Item', * directory: 'base', + * directoryTitle: 'Base', * format: 'default', + * formatLabel: 'STANDARD', * type: 'twig', * tagName: '', * }); diff --git a/src/util/system/buildSystemScaffold.test.ts b/src/util/system/buildSystemScaffold.test.ts index 9e166f0..2c0ad45 100644 --- a/src/util/system/buildSystemScaffold.test.ts +++ b/src/util/system/buildSystemScaffold.test.ts @@ -88,11 +88,11 @@ describe('buildSystemScaffold', () => { 'example-card', 'example_card', 'example-card', - 'DEFAULT', + 'STANDARD', ), ); expect(files['components/example-card/example-card.scss']).toBe( - buildScssTemplate('example-card', 'DEFAULT'), + buildScssTemplate('example-card', 'STANDARD'), ); expect(files['components/example-card/example-card.yml']).toBe( buildYmlTemplate('example_card', 'Example Card'), @@ -102,7 +102,7 @@ describe('buildSystemScaffold', () => { 'exampleCard', 'example-card', 'Example Card', - 'components', + 'Components', ), ); }); diff --git a/src/util/system/buildSystemScaffold.ts b/src/util/system/buildSystemScaffold.ts index ea45c9d..375f4e3 100644 --- a/src/util/system/buildSystemScaffold.ts +++ b/src/util/system/buildSystemScaffold.ts @@ -11,7 +11,8 @@ const EXAMPLE_COMPONENT_NAME = 'example-card'; const EXAMPLE_COMPONENT_CAMEL_NAME = 'exampleCard'; const EXAMPLE_COMPONENT_SNAKE_NAME = 'example_card'; const EXAMPLE_COMPONENT_HUMAN_NAME = 'Example Card'; -const DEFAULT_FORMAT_LABEL = 'DEFAULT'; +const STANDARD_FORMAT_LABEL = 'STANDARD'; +const COMPONENT_STRUCTURE_TITLE = 'Components'; export type BuildSystemScaffoldOptions = { name: string; @@ -130,14 +131,14 @@ export default function buildSystemScaffold( EXAMPLE_COMPONENT_NAME, EXAMPLE_COMPONENT_SNAKE_NAME, EXAMPLE_COMPONENT_NAME, - DEFAULT_FORMAT_LABEL, + STANDARD_FORMAT_LABEL, ), }, { path: `${exampleComponentDirectory}/${EXAMPLE_COMPONENT_NAME}.scss`, contents: buildScssTemplate( EXAMPLE_COMPONENT_NAME, - DEFAULT_FORMAT_LABEL, + STANDARD_FORMAT_LABEL, ), }, { @@ -153,7 +154,7 @@ export default function buildSystemScaffold( EXAMPLE_COMPONENT_CAMEL_NAME, EXAMPLE_COMPONENT_NAME, EXAMPLE_COMPONENT_HUMAN_NAME, - COMPONENT_STRUCTURE_NAME, + COMPONENT_STRUCTURE_TITLE, ), }, ], From 1f30766de0d2a4c16ca1a9a659c3e6011ec2814c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 03:54:37 -0500 Subject: [PATCH 11/33] feat(component): add a command to eject built-in component templates --- README.md | 41 ++- docs/README.md | 2 +- docs/cli-reference.md | 69 +++- docs/component-template-overrides.md | 198 +++++------ docs/components.md | 17 +- src/handlers/componentEjectTemplates.test.ts | 324 ++++++++++++++++++ src/handlers/componentEjectTemplates.ts | 259 ++++++++++++++ src/index.ts | 15 +- src/lib/rootHelp.test.ts | 7 +- src/lib/rootHelp.ts | 17 +- src/types/handlers.d.ts | 7 + .../buildComponentArtifacts.test.ts | 79 +++++ .../buildComponentArtifacts.ts | 200 +++++++++++ src/util/project/componentTemplates/index.ts | 6 + src/util/project/generateComponent.ts | 153 +-------- test/e2e/cli.test.mjs | 61 ++++ test/e2e/root-help.txt | 5 +- 17 files changed, 1164 insertions(+), 296 deletions(-) create mode 100644 src/handlers/componentEjectTemplates.test.ts create mode 100644 src/handlers/componentEjectTemplates.ts create mode 100644 src/util/project/componentTemplates/buildComponentArtifacts.test.ts create mode 100644 src/util/project/componentTemplates/buildComponentArtifacts.ts diff --git a/README.md b/README.md index 1856772..32ca223 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,16 @@ Twig is the only suitable choice, it skips the one-item prompt. Likewise, `emulsify component install` with no name presents the components available in the installed system variant plus an explicit choice to install all components. +To customize component scaffolds, copy the CLI's built-in templates into the +project, then edit the resulting files under `.cli/templates/`: + +```bash +emulsify component eject-templates twig +``` + +Run the command without a type in an interactive terminal to select one or more +component types. Existing overrides are protected unless `--force` is passed. + Prompts only run when standard input is a TTY. In CI, scripts, and commands with piped or redirected input, provide every required positional argument and flag; the CLI exits with an actionable error instead of waiting for input: @@ -92,6 +102,7 @@ emulsify component install card --force # Or install every available component: emulsify component install --all emulsify component create promo-card --directory molecules --type twig --yes +emulsify component eject-templates twig emulsify system detach --yes ``` @@ -103,6 +114,9 @@ creation, provide the positional name plus `--type` and `--directory`, and use from the wizard. The deprecated `--format default` and `--format sdc` forms remain available as aliases for `--type twig` and `--type twig-sdc`, respectively, and print a deprecation warning. +For template ejection, provide the component type outside a TTY; use +`--dry-run` to preview paths and `--force` only when existing customizations +should be replaced. ## Documentation @@ -115,25 +129,26 @@ Detailed documentation lives in [docs](./docs/README.md). | [Systems](./docs/systems.md) | Listing, installing, detaching, or authoring component systems. | | [Components](./docs/components.md) | Listing, installing, dry-running, or creating components. | | [Project Configuration](./docs/configuration.md) | Understanding `project.emulsify.json`, variants, and structure mappings. | -| [Component Template Overrides](./docs/component-template-overrides.md) | Customizing files generated by `emulsify component create`. | +| [Component Template Overrides](./docs/component-template-overrides.md) | Ejecting and customizing files used by `emulsify component create`. | | [Hooks And Cache](./docs/hooks-and-cache.md) | Understanding starter hooks, system hooks, and local repository cache behavior. | | [Development](./docs/development.md) | Setting up this repository and running local checks. | | [Release](./docs/release.md) | Understanding CI, semantic-release, and npm publishing. | ## Command Overview -| Command | Alias | Description | -| ----------------------------------- | ----------------------------- | ----------------------------------------------------------------- | -| `emulsify init [name] [path]` | | Initializes an Emulsify project from a starter. | -| `emulsify audit [...args]` | | Runs the project-installed Emulsify Core audit. | -| `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | -| `emulsify system create [name]` | | Creates a standalone component-system repository. | -| `emulsify system install [name]` | | Installs a system in the current Emulsify project. | -| `emulsify system detach` | | Detaches the system and keeps project components. | -| `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. | -| `emulsify cache clear` | | Clears locally cached system repositories. | +| Command | Alias | Description | +| ------------------------------------------- | ----------------------------- | ----------------------------------------------------------------- | +| `emulsify init [name] [path]` | | Initializes an Emulsify project from a starter. | +| `emulsify audit [...args]` | | Runs the project-installed Emulsify Core audit. | +| `emulsify system list` | `emulsify system ls` | Lists built-in systems available for installation. | +| `emulsify system create [name]` | | Creates a standalone component-system repository. | +| `emulsify system install [name]` | | Installs a system in the current Emulsify project. | +| `emulsify system detach` | | Detaches the system and keeps project components. | +| `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. | +| `emulsify component eject-templates [type]` | | Writes editable built-in templates into the current project. | +| `emulsify cache clear` | | Clears locally cached system repositories. | `emulsify audit` is a convenience façade. The project-installed `@emulsify/core` package remains the owner of the canonical `emulsify-audit` diff --git a/docs/README.md b/docs/README.md index 1228d8a..4f92eb8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ These docs expand on the short project README and are organized by the task a pr | [Systems](./systems.md) | Listing, installing, detaching, or authoring systems, including custom repositories and variant compatibility. | | [Components](./components.md) | Listing installable components, installing components and dependencies, using dry runs, and creating local components. | | [Project Configuration](./configuration.md) | Understanding `project.emulsify.json`, system and variant references, structure mappings, and validation. | -| [Component Template Overrides](./component-template-overrides.md) | Replacing the built-in `component create` templates with project-level templates. | +| [Component Template Overrides](./component-template-overrides.md) | Ejecting built-in `component create` templates and customizing them at the project level. | | [Hooks And Cache](./hooks-and-cache.md) | Understanding starter hooks, system install hooks, script execution, and the `~/.emulsify/cache` repository cache. | | [Development](./development.md) | Setting up this repository, understanding source layout, and running checks. | | [Release](./release.md) | Understanding CI, develop version bumps, semantic-release, and npm publishing. | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f4656c5..9fcf0b1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -12,18 +12,19 @@ The examples below reflect the command definitions in `src/index.ts` and the gen ## Commands -| Command | Alias | Description | -| ----------------------------------- | ----------------------------- | ---------------------------------------------------------------- | -| `emulsify init [name] [path]` | | Initialize an Emulsify project from a starter. | -| `emulsify audit [...args]` | | Run the project-installed Emulsify Core audit. | -| `emulsify system list` | `emulsify system ls` | List built-in systems available for installation. | -| `emulsify system create [name]` | | Create a standalone, distributable component system. | -| `emulsify system install [name]` | | Install a system in the current Emulsify project. | -| `emulsify system detach` | | Detach the system and keep project components. | -| `emulsify component list` | `emulsify component ls` | List components available from the installed system and variant. | -| `emulsify component install [name]` | `emulsify component i [name]` | Install a component from the installed system and variant. | -| `emulsify component create [name]` | `emulsify component c [name]` | Generate a new local component in the current project. | -| `emulsify cache clear` | | Clear locally cached system repositories. | +| Command | Alias | Description | +| ------------------------------------------- | ----------------------------- | ---------------------------------------------------------------- | +| `emulsify init [name] [path]` | | Initialize an Emulsify project from a starter. | +| `emulsify audit [...args]` | | Run the project-installed Emulsify Core audit. | +| `emulsify system list` | `emulsify system ls` | List built-in systems available for installation. | +| `emulsify system create [name]` | | Create a standalone, distributable component system. | +| `emulsify system install [name]` | | Install a system in the current Emulsify project. | +| `emulsify system detach` | | Detach the system and keep project components. | +| `emulsify component list` | `emulsify component ls` | List components available from the installed system and variant. | +| `emulsify component install [name]` | `emulsify component i [name]` | Install a component from the installed system and variant. | +| `emulsify component create [name]` | `emulsify component c [name]` | Generate a new local component in the current project. | +| `emulsify component eject-templates [type]` | | Write editable built-in component templates into the project. | +| `emulsify cache clear` | | Clear locally cached system repositories. | ## `init` @@ -384,6 +385,50 @@ exits with an actionable error instead of opening the picker. If a named component destination already exists, the command also exits unless `--force` is passed to replace it without an overwrite prompt. +## `component eject-templates` + +```bash +emulsify component eject-templates [type] +``` + +Writes the CLI's built-in component templates into +`.cli/templates//` in the current Emulsify project. The files are the +real defaults used by `component create`, expressed with the same supported +template tokens, so they can be edited in place rather than recreated from +scratch. The command reports every destination with its real project path. + +Supported types are `twig`, `twig-sdc`, `react`, and `web-component`. In an +interactive terminal, omitting `[type]` opens a multi-select prompt where one, +several, or all four types can be selected. When standard input is not a TTY, +provide one type; otherwise the command exits immediately with an actionable +message naming the argument. + +Before writing, the command checks every target in the selected set. If any +target already exists, it reports all conflicts and writes nothing. Pass +`--force` only when every conflicting file in the selection may be replaced. + +Options: + +| Option | Description | +| ------------- | ------------------------------------------------------------------------------ | +| `-f, --force` | Replace existing template files after the selection-wide conflict check. | +| `--dry-run` | Report template destinations and conflicts without creating or changing files. | + +Examples: + +```bash +emulsify component eject-templates +emulsify component eject-templates twig +emulsify component eject-templates react --dry-run +emulsify component eject-templates web-component --force +``` + +The command must run inside an Emulsify project. It writes only to canonical +type directories and does not add provenance headers to the templates, so an +unedited ejected template renders byte-for-byte like the corresponding +built-in. Deleting an override restores the usual fallback behavior: a legacy +Twig alias where one exists, then the built-in template. + ## `component create` ```bash diff --git a/docs/component-template-overrides.md b/docs/component-template-overrides.md index 6f0a95e..ba5a498 100644 --- a/docs/component-template-overrides.md +++ b/docs/component-template-overrides.md @@ -1,83 +1,100 @@ # Component Template Overrides -`emulsify component create` uses built-in templates by default. A project can replace any generated artifact with a matching override file under `.cli/templates/`. +Run `emulsify component eject-templates` from an Emulsify project to see and +edit the exact templates used by `component create`: -Overrides replace known generated files one-for-one. They do not add arbitrary extra files and they do not change which artifacts are generated. - -## Directory Layout +```bash +emulsify component eject-templates +``` -Twig overrides: +In an interactive terminal, the command lets you select one or more component +types. To eject one type directly, including from a script or CI job, provide +its canonical type: -```text -.cli/templates/twig/component.twig -.cli/templates/twig/component.scss -.cli/templates/twig/component.yml -.cli/templates/twig/component.stories.js +```bash +emulsify component eject-templates twig +emulsify component eject-templates twig-sdc +emulsify component eject-templates react +emulsify component eject-templates web-component ``` -Twig SDC overrides: +The command writes the selected defaults beneath +`.cli/templates//` and reports each real destination path. It adds no +generated header or other content, so rendering a freshly ejected template is +byte-for-byte identical to using the corresponding built-in template. -```text -.cli/templates/twig-sdc/component.twig -.cli/templates/twig-sdc/component.scss -.cli/templates/twig-sdc/component.component.yml -.cli/templates/twig-sdc/component.js -.cli/templates/twig-sdc/component.stories.js -``` +## Protecting Existing Overrides -React overrides: +Before writing, the CLI checks the complete selected set. If any target already +exists, it lists every conflict and writes nothing. This protects customized +templates from partial replacement. -```text -.cli/templates/react/component.jsx -.cli/templates/react/component.scss -.cli/templates/react/component.stories.jsx +Use `--force` only when all conflicting files in the selected set may be +replaced: + +```bash +emulsify component eject-templates twig --force ``` -Web Component overrides: +Use `--dry-run` to inspect the destinations and conflicts without creating or +changing files: -```text -.cli/templates/web-component/component.js -.cli/templates/web-component/component.scss -.cli/templates/web-component/component.stories.js +```bash +emulsify component eject-templates react --dry-run ``` -The directory name follows the canonical component `--type` value. +When standard input is not a TTY, `[type]` is required. The CLI exits with an +actionable error instead of opening a prompt. + +## How Overrides Are Resolved -### Legacy Twig Directory Aliases +Overrides replace known generated artifacts one-for-one. They do not add +arbitrary files or change the artifact set generated for a component type. -Existing override directories continue to work. For each Twig artifact, the -CLI looks in `.cli/templates/twig/` first and, when that artifact is absent, -looks in `.cli/templates/default/`. For each Twig SDC artifact, it looks in -`.cli/templates/twig-sdc/` first and, when absent, in -`.cli/templates/sdc/`. If neither path contains the artifact, the built-in -template is used. An override file that exists but is empty is ignored in favor -of the built-in template and produces a warning. +The canonical directory names match the `--type` values: `twig`, `twig-sdc`, +`react`, and `web-component`. `eject-templates` always writes to these canonical +directories. -This precedence applies one artifact at a time, so a partial canonical override -does not disable legacy overrides for the remaining files. `default/` and -`sdc/` are compatibility aliases; use `twig/` and `twig-sdc/` for new -customizations. React and Web Component overrides have no legacy aliases. +Existing Twig directory aliases continue to work. For each Twig artifact, the +CLI checks `.cli/templates/twig/` first and then `.cli/templates/default/`. For +each Twig SDC artifact, it checks `.cli/templates/twig-sdc/` and then +`.cli/templates/sdc/`. Alias fallback is resolved one artifact at a time, so a +partial canonical override does not hide legacy overrides for other artifacts. +React and Web Component overrides have no legacy aliases. + +When no override is available, `component create` uses its built-in template. +An override file that exists but is empty is ignored in favor of the built-in +and produces a warning. Deleting an override restores this normal fallback +sequence: a legacy alias where applicable, then the built-in. + +Ejecting a type creates its complete current template set. If only one artifact +needs customization, delete the other ejected files so those artifacts continue +to inherit built-in changes from future CLI releases. ## Supported Tokens Override files can use double-brace tokens. -| Token | Example Value For `featured-item` | -| ------------------ | ---------------------------------------------------------------------------- | -| `{{ filename }}` | `featured-item` | -| `{{ className }}` | `featured-item` | -| `{{ camelName }}` | `featuredItem` | -| `{{ pascalName }}` | `FeaturedItem` | -| `{{ snakeName }}` | `featured_item` | -| `{{ humanName }}` | `Featured Item` | -| `{{ directory }}` | `base` | -| `{{ type }}` | `twig`, `twig-sdc`, `react`, or `web-component` | -| `{{ tagName }}` | `featured-item` for a Web Component; an empty string for every other type | -| `{{ format }}` | `default` for Twig, `sdc` for Twig SDC, otherwise `react` or `web-component` | +| Token | Example Value For `featured-item` | +| ---------------------- | ---------------------------------------------------------------------------- | +| `{{ filename }}` | `featured-item` | +| `{{ className }}` | `featured-item` | +| `{{ camelName }}` | `featuredItem` | +| `{{ pascalName }}` | `FeaturedItem` | +| `{{ snakeName }}` | `featured_item` | +| `{{ humanName }}` | `Featured Item` | +| `{{ directory }}` | `base` | +| `{{ directoryTitle }}` | `Base` | +| `{{ type }}` | `twig`, `twig-sdc`, `react`, or `web-component` | +| `{{ tagName }}` | `featured-item` for a Web Component; an empty string for every other type | +| `{{ format }}` | `default` for Twig, `sdc` for Twig SDC, otherwise `react` or `web-component` | +| `{{ formatLabel }}` | `STANDARD`, `SDC`, `REACT`, or `WEB COMPONENT` | `{{ type }}` is the canonical token for new overrides. `{{ format }}` remains -populated so existing Twig and Twig SDC overrides keep rendering the same -values after migrating from `--format` to `--type`. +populated so existing Twig and Twig SDC overrides keep their previous values +after migrating from `--format` to `--type`. `{{ formatLabel }}` contains the +display label used in generated file headers. `{{ directoryTitle }}` contains +the structure name with its first character capitalized for Storybook titles. Whitespace inside the braces is optional: @@ -86,83 +103,36 @@ Whitespace inside the braces is optional: {{ humanName }} ``` -Unknown tokens are left unchanged and logged as warnings. Empty override files are ignored and the built-in template is used. +Unknown tokens are left unchanged and logged as warnings. -## Example Twig Override +## Customize An Ejected Template -Create `.cli/templates/twig/component.twig`: +First eject the defaults for the component type: -```twig -{% set classes = [ - '{{ className }}', -] %} - -
- {% block content %} - {% endblock %} -
+```bash +emulsify component eject-templates twig ``` -Then generate a component: +Edit `.cli/templates/twig/component.twig`, then generate a component normally: ```bash emulsify component create featured-item --directory base --type twig ``` -The generated file is: +The edited template produces: ```text components/00-base/featured-item/featured-item.twig ``` -## Example SDC Metadata Override - -Create `.cli/templates/twig-sdc/component.component.yml`: - -```yaml -name: {{ humanName }} -status: stable -props: - type: object - properties: - {{ snakeName }}_title: - type: string - title: Title -slots: - content: - title: Content -``` - -Generate the SDC component: - -```bash -emulsify component create featured-item --directory base --type twig-sdc -``` - -The generated file is: - -```text -components/00-base/featured-item/featured-item.component.yml -``` - -## Partial Overrides - -Override only the artifacts you need. For example, a project can override Twig and keep the built-in SCSS, data, and story templates: - -```text -.cli/templates/twig/component.twig -``` +Only that project uses the override. Other projects and component types +continue using their own overrides or the CLI's built-ins. -All missing override files fall through the legacy directory alias, where one -exists, and then to the built-in builders. +## Preview Component Creation -## Dry-Run With Overrides - -Dry runs do not write files, but they still resolve the selected type, -structure, and output paths: +After editing an override, use `component create --dry-run` to confirm the +selected type, structure, and output paths without writing component files: ```bash emulsify component create featured-item --directory base --type twig --dry-run ``` - -Use dry runs to confirm the component destination before replacing or adding override files. diff --git a/docs/components.md b/docs/components.md index 86be364..49eb415 100644 --- a/docs/components.md +++ b/docs/components.md @@ -259,5 +259,18 @@ emulsify component create featured-item --directory base --type twig --yes ## Template Overrides -Projects can override the generated files with `.cli/templates//...` -files. See [Component Template Overrides](./component-template-overrides.md). +Start a project override from the CLI's actual built-in templates: + +```bash +emulsify component eject-templates twig +``` + +In an interactive terminal, omit the type to select one or more types. In a +non-interactive environment, provide one of `twig`, `twig-sdc`, `react`, or +`web-component`. The command protects existing customizations unless `--force` +is passed, and `--dry-run` previews every destination without writing files. + +Edit the resulting `.cli/templates//...` files. Then use `component +create` normally. See +[Component Template Overrides](./component-template-overrides.md) for template +resolution rules and supported tokens. diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts new file mode 100644 index 0000000..496d9dc --- /dev/null +++ b/src/handlers/componentEjectTemplates.test.ts @@ -0,0 +1,324 @@ +jest.mock('@inquirer/prompts'); +jest.mock('../lib/log', () => jest.fn()); +jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); + +import { checkbox } from '@inquirer/prompts'; +import { promises as fs } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { pathExists } from 'fs-extra'; + +import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js'; +import log from '../lib/log.js'; +import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; +import type { ComponentType } from '../util/project/componentTypes.js'; +import { buildEjectableComponentTemplates } from '../util/project/componentTemplates/index.js'; +import componentEjectTemplates, { + buildComponentTemplateEjectionPlan, + MISSING_TEMPLATE_TYPE_ERROR, +} from './componentEjectTemplates.js'; + +const checkboxMock = checkbox as jest.Mock; +const findFileMock = findFileInCurrentPath as jest.Mock; +const logMock = log as jest.Mock; +const mkdirMock = fs.mkdir as jest.Mock; +const pathExistsMock = pathExists as jest.Mock; +const writeFileMock = fs.writeFile as jest.Mock; +const originalStdinIsTTY = process.stdin.isTTY; + +const projectRoot = resolve('/projects/template-project'); +const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); +const templatesRoot = join(projectRoot, '.cli', 'templates'); + +function setStdinIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + }); +} + +function destination(type: ComponentType, logicalName: string): string { + return join(templatesRoot, type, logicalName); +} + +function expectNoWrites(): void { + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); +} + +describe('buildComponentTemplateEjectionPlan', () => { + it('resolves every destination beneath the templates root', () => { + const plan = buildComponentTemplateEjectionPlan(projectRoot, [ + 'twig', + 'react', + ]); + + expect(plan).toHaveLength(7); + expect(plan.map(({ destination: target }) => target)).toEqual([ + destination('twig', 'component.twig'), + destination('twig', 'component.scss'), + destination('twig', 'component.yml'), + destination('twig', 'component.stories.js'), + destination('react', 'component.jsx'), + destination('react', 'component.scss'), + destination('react', 'component.stories.jsx'), + ]); + }); + + it('rejects a type path segment that traverses outside the templates root', () => { + expect(() => + buildComponentTemplateEjectionPlan(projectRoot, [ + '../outside' as ComponentType, + ]), + ).toThrow('outside the expected root'); + }); +}); + +describe('componentEjectTemplates', () => { + beforeEach(() => { + jest.clearAllMocks(); + setStdinIsTTY(false); + findFileMock.mockReturnValue(projectConfigPath); + pathExistsMock.mockResolvedValue(false); + mkdirMock.mockResolvedValue(undefined); + writeFileMock.mockResolvedValue(undefined); + }); + + afterAll(() => { + setStdinIsTTY(originalStdinIsTTY); + }); + + it('writes the exact logical templates for an explicit type', async () => { + await componentEjectTemplates('twig'); + + const artifacts = buildEjectableComponentTemplates('twig'); + expect(checkboxMock).not.toHaveBeenCalled(); + expect(pathExistsMock).toHaveBeenCalledTimes(artifacts.length); + expect(mkdirMock).toHaveBeenCalledTimes(artifacts.length); + expect(writeFileMock).toHaveBeenCalledTimes(artifacts.length); + + for (const artifact of artifacts) { + const target = destination('twig', artifact.logicalName); + expect(mkdirMock).toHaveBeenCalledWith(dirname(target), { + recursive: true, + }); + expect(writeFileMock).toHaveBeenCalledWith(target, artifact.contents, { + encoding: 'utf-8', + flag: 'wx', + }); + } + + expect(logMock).toHaveBeenNthCalledWith( + 1, + 'success', + expect.stringContaining(destination('twig', 'component.twig')), + ); + expect(logMock).toHaveBeenNthCalledWith( + 2, + 'info', + 'Edit these files to customize component create. Delete an override to restore its built-in template.', + ); + }); + + it('prompts interactively for one, several, or all component types', async () => { + setStdinIsTTY(true); + checkboxMock.mockResolvedValueOnce(['web-component', 'twig']); + + await componentEjectTemplates(undefined); + + expect(checkboxMock).toHaveBeenCalledTimes(1); + const prompt = checkboxMock.mock.calls[0][0]; + expect(prompt).toMatchObject({ + message: 'Which component template types should be ejected?', + }); + expect(prompt.choices.map(({ value }: { value: string }) => value)).toEqual( + ['twig', 'twig-sdc', 'react', 'web-component'], + ); + expect(prompt.validate([])).toBe('Select at least one component type.'); + expect(prompt.validate([{ value: 'twig' }])).toBe(true); + + const expectedCount = + buildEjectableComponentTemplates('twig').length + + buildEjectableComponentTemplates('web-component').length; + expect(writeFileMock).toHaveBeenCalledTimes(expectedCount); + expect(writeFileMock.mock.calls[0][0]).toBe( + destination('twig', 'component.twig'), + ); + }); + + it.each([false, undefined])( + 'fails before project lookup or writes without [type] when stdin TTY is %s', + async (stdinIsTTY) => { + setStdinIsTTY(stdinIsTTY); + + await expect(componentEjectTemplates(undefined)).rejects.toMatchObject({ + name: 'CliError', + message: MISSING_TEMPLATE_TYPE_ERROR, + exitCode: 1, + }); + + expect(findFileMock).not.toHaveBeenCalled(); + expect(checkboxMock).not.toHaveBeenCalled(); + expect(pathExistsMock).not.toHaveBeenCalled(); + expectNoWrites(); + }, + ); + + it('checks terminal interactivity again immediately before prompting', async () => { + setStdinIsTTY(true); + findFileMock.mockImplementationOnce(() => { + setStdinIsTTY(false); + return projectConfigPath; + }); + + await expect(componentEjectTemplates(undefined)).rejects.toThrow( + MISSING_TEMPLATE_TYPE_ERROR, + ); + + expect(checkboxMock).not.toHaveBeenCalled(); + expect(pathExistsMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + + it('fails clearly when there is no Emulsify project', async () => { + findFileMock.mockReturnValueOnce(undefined); + + await expect(componentEjectTemplates('twig')).rejects.toMatchObject({ + name: 'CliError', + message: + 'No Emulsify project detected. Run this command within an existing Emulsify project.', + exitCode: 1, + }); + + expect(checkboxMock).not.toHaveBeenCalled(); + expect(pathExistsMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + + it('rejects invalid and traversing explicit type values', async () => { + await expect(componentEjectTemplates('../outside')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringContaining('Invalid component type'), + }); + + expect(pathExistsMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + + it('rejects an empty interactive selection defensively', async () => { + setStdinIsTTY(true); + checkboxMock.mockResolvedValueOnce([]); + + await expect(componentEjectTemplates(undefined)).rejects.toMatchObject({ + name: 'CliError', + message: 'Select at least one component type.', + }); + + expect(pathExistsMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + + it('preflights every conflict and refuses the whole selection without --force', async () => { + const twigConflict = destination('twig', 'component.twig'); + const storiesConflict = destination('twig', 'component.stories.js'); + pathExistsMock.mockImplementation( + async (target: string) => + target === twigConflict || target === storiesConflict, + ); + + await expect(componentEjectTemplates('twig')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching( + new RegExp( + `${twigConflict.replaceAll('/', '\\/')}[\\s\\S]*${storiesConflict.replaceAll('/', '\\/')}[\\s\\S]*Pass --force`, + ), + ), + }); + + expect(pathExistsMock).toHaveBeenCalledTimes(4); + expectNoWrites(); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('replaces every selected known template with --force', async () => { + pathExistsMock.mockResolvedValue(true); + + await componentEjectTemplates('react', { force: true }); + + const artifacts = buildEjectableComponentTemplates('react'); + expect(writeFileMock).toHaveBeenCalledTimes(artifacts.length); + for (const artifact of artifacts) { + expect(writeFileMock).toHaveBeenCalledWith( + destination('react', artifact.logicalName), + artifact.contents, + { encoding: 'utf-8', flag: 'w' }, + ); + } + }); + + it('does not overwrite a template created after the conflict preflight', async () => { + writeFileMock.mockRejectedValueOnce( + Object.assign(new Error('already exists'), { code: 'EEXIST' }), + ); + + await expect(componentEjectTemplates('twig')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching(/appeared.*not replaced.*--force/u), + }); + + expect(writeFileMock).toHaveBeenCalledWith( + destination('twig', 'component.twig'), + expect.any(String), + { encoding: 'utf-8', flag: 'wx' }, + ); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('previews creates and conflicts without writing or failing', async () => { + const conflict = destination('twig', 'component.scss'); + pathExistsMock.mockImplementation( + async (target: string) => target === conflict, + ); + + await componentEjectTemplates('twig', { dryRun: true }); + + expectNoWrites(); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringMatching( + /component\.twig \(would create\)[\s\S]*component\.scss \(conflict; a real run requires --force\)[\s\S]*No files were written or replaced\./, + ), + ); + }); + + it('previews replacements when --force accompanies --dry-run', async () => { + pathExistsMock.mockResolvedValue(true); + + await componentEjectTemplates('web-component', { + dryRun: true, + force: true, + }); + + expectNoWrites(); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringContaining('(would replace)'), + ); + }); + + it.each([ + [new Error('disk full'), 'disk full'], + ['write failed', 'write failed'], + ])( + 'reports a write failure without claiming success', + async (failure, text) => { + writeFileMock.mockRejectedValueOnce(failure); + + await expect(componentEjectTemplates('react')).rejects.toMatchObject({ + name: 'CliError', + message: `Unable to write component template "${destination('react', 'component.jsx')}": ${text}`, + }); + + expect(logMock).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/handlers/componentEjectTemplates.ts b/src/handlers/componentEjectTemplates.ts new file mode 100644 index 0000000..b79ec0d --- /dev/null +++ b/src/handlers/componentEjectTemplates.ts @@ -0,0 +1,259 @@ +import type { EjectComponentTemplatesHandlerOptions } from '@emulsify-cli/handlers'; + +import { checkbox } from '@inquirer/prompts'; +import { promises as fs } from 'fs'; +import { dirname } from 'path'; +import { pathExists } from 'fs-extra'; + +import CliError from '../lib/CliError.js'; +import { + EMULSIFY_PROJECT_CONFIG_FILE, + EMULSIFY_PROJECT_TEMPLATES_FOLDER, +} from '../lib/constants.js'; +import log from '../lib/log.js'; +import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; +import safeResolveWithin from '../util/fs/safeResolveWithin.js'; +import { + COMPONENT_TYPES, + normalizeComponentType, + type ComponentType, +} from '../util/project/componentTypes.js'; +import { buildEjectableComponentTemplates } from '../util/project/componentTemplates/index.js'; +import { requireInteractiveTerminal, runPrompt } from '../util/prompt/index.js'; + +export const MISSING_TEMPLATE_TYPE_ERROR = + 'Component template type is required in non-interactive mode. Pass the [type] positional argument (twig, twig-sdc, react, or web-component).'; + +const TYPE_CHOICES: { + name: string; + value: ComponentType; + description: string; +}[] = [ + { + name: 'Twig', + value: 'twig', + description: 'Twig markup, SCSS, YAML data, and a Storybook story', + }, + { + name: 'Twig SDC', + value: 'twig-sdc', + description: 'Drupal Single Directory Component templates', + }, + { + name: 'React', + value: 'react', + description: 'React JSX, SCSS, and a standard Storybook story', + }, + { + name: 'Web Component', + value: 'web-component', + description: 'Custom element, SCSS, and an Emulsify Core story', + }, +]; + +export type ComponentTemplateEjectionPlanItem = { + type: ComponentType; + logicalName: string; + destination: string; + contents: string; +}; + +type InspectedPlanItem = ComponentTemplateEjectionPlanItem & { + exists: boolean; +}; + +/** Resolve every ejection target within the project's template directory. */ +export function buildComponentTemplateEjectionPlan( + projectRoot: string, + types: readonly ComponentType[], +): ComponentTemplateEjectionPlanItem[] { + const templatesRoot = safeResolveWithin( + projectRoot, + EMULSIFY_PROJECT_TEMPLATES_FOLDER, + 'Component templates directory', + ); + + return types.flatMap((type) => { + // Validate the user-controlled path segment before asking for its artifact + // inventory. Each write destination is guarded again below. + safeResolveWithin(templatesRoot, type, 'Component template type directory'); + + return buildEjectableComponentTemplates(type).map( + ({ logicalName, contents }) => ({ + type, + logicalName, + destination: safeResolveWithin( + templatesRoot, + [type, logicalName], + 'Component template destination', + ), + contents, + }), + ); + }); +} + +function normalizeRequestedType(type: string): ComponentType { + try { + return normalizeComponentType(type); + } catch (error) { + throw new CliError(error instanceof Error ? error.message : String(error)); + } +} + +function canonicalizeSelectedTypes( + selectedTypes: readonly ComponentType[], +): ComponentType[] { + const selected = new Set(selectedTypes); + return COMPONENT_TYPES.filter((type) => selected.has(type)); +} + +async function inspectPlan( + plan: ComponentTemplateEjectionPlanItem[], +): Promise { + return Promise.all( + plan.map(async (item) => ({ + ...item, + exists: await pathExists(item.destination), + })), + ); +} + +function getDryRunAction(exists: boolean, force: boolean): string { + if (!exists) return 'would create'; + if (force) return 'would replace'; + return 'conflict; a real run requires --force'; +} + +function logDryRun( + types: ComponentType[], + plan: InspectedPlanItem[], + force: boolean, +): void { + const destinations = plan + .map( + ({ destination, exists }) => + ` - ${destination} (${getDryRunAction(exists, force)})`, + ) + .join('\n'); + + log( + 'info', + [ + 'Dry run: component eject-templates', + `Types: ${types.join(', ')}`, + 'Template files:', + destinations, + 'No files were written or replaced.', + ].join('\n'), + ); +} + +function formatConflictError(conflicts: InspectedPlanItem[]): string { + const paths = conflicts + .map(({ destination }) => ` - ${destination}`) + .join('\n'); + + return [ + 'Refusing to overwrite existing component template files:', + paths, + 'Pass --force to replace the conflicting templates. No files were written.', + ].join('\n'); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isAlreadyExistsError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'EEXIST' + ); +} + +/** Handler for `emulsify component eject-templates [type]`. */ +export default async function componentEjectTemplates( + type: string | void, + { force = false, dryRun = false }: EjectComponentTemplatesHandlerOptions = {}, +): Promise { + const requestedType = type?.trim(); + + // CI must name the target before project lookup or any other work. + if (!requestedType) { + requireInteractiveTerminal(MISSING_TEMPLATE_TYPE_ERROR); + } + + const projectConfigPath = findFileInCurrentPath(EMULSIFY_PROJECT_CONFIG_FILE); + if (!projectConfigPath) { + throw new CliError( + 'No Emulsify project detected. Run this command within an existing Emulsify project.', + ); + } + const projectRoot = dirname(projectConfigPath); + + const selectedTypes = requestedType + ? [normalizeRequestedType(requestedType)] + : await runPrompt({ + prompt: () => + checkbox({ + message: 'Which component template types should be ejected?', + choices: TYPE_CHOICES, + validate: (values) => + values.length > 0 || 'Select at least one component type.', + }), + nonInteractive: { error: MISSING_TEMPLATE_TYPE_ERROR }, + }); + const canonicalTypes = canonicalizeSelectedTypes(selectedTypes); + if (canonicalTypes.length === 0) { + throw new CliError('Select at least one component type.'); + } + + const inspectedPlan = await inspectPlan( + buildComponentTemplateEjectionPlan(projectRoot, canonicalTypes), + ); + const conflicts = inspectedPlan.filter(({ exists }) => exists); + + if (dryRun) { + logDryRun(canonicalTypes, inspectedPlan, force); + return; + } + + if (conflicts.length > 0 && !force) { + throw new CliError(formatConflictError(conflicts)); + } + + for (const item of inspectedPlan) { + try { + await fs.mkdir(dirname(item.destination), { recursive: true }); + await fs.writeFile(item.destination, item.contents, { + encoding: 'utf-8', + flag: force ? 'w' : 'wx', + }); + } catch (error) { + if (!force && isAlreadyExistsError(error)) { + throw new CliError( + `Component template "${item.destination}" appeared after the overwrite check and was not replaced. Pass --force to replace existing templates.`, + ); + } + + throw new CliError( + `Unable to write component template "${item.destination}": ${getErrorMessage(error)}`, + ); + } + } + + const paths = inspectedPlan + .map(({ destination }) => ` - ${destination}`) + .join('\n'); + log( + 'success', + `Ejected ${inspectedPlan.length} built-in component templates:\n${paths}`, + ); + log( + 'info', + 'Edit these files to customize component create. Delete an override to restore its built-in template.', + ); +} diff --git a/src/index.ts b/src/index.ts index 0b3f27d..8d8b223 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import systemDetach from './handlers/systemDetach.js'; import componentList from './handlers/componentList.js'; import componentInstall from './handlers/componentInstall.js'; import componentCreate from './handlers/componentCreate.js'; +import componentEjectTemplates from './handlers/componentEjectTemplates.js'; import audit from './handlers/audit.js'; import cacheClear from './handlers/cacheClear.js'; import CliError from './lib/CliError.js'; @@ -118,7 +119,7 @@ system // Component sub-commands. const component = program .command('component') - .description('List, install, or create components'); + .description('List, install, create, or customize components'); component .command('list') .description( @@ -177,6 +178,18 @@ component .alias('c') .description('Generate a new local component in the current project') .action(componentCreate); +component + .command('eject-templates [type]') + .description('Write editable copies of the built-in component templates') + .option( + '-f, --force', + 'Replace existing template files in the selected type set.', + ) + .option( + '--dry-run', + 'Preview template destinations and conflicts without writing files.', + ) + .action(componentEjectTemplates); // Cache sub-commands. const cache = program diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index 371891f..7cdfb5d 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -35,7 +35,9 @@ describe('getRootHelp', () => { expect(help).toContain('system create [name]'); expect(help).toContain('MAINTENANCE'); expect(help).toContain('audit [args...]'); - expect(help).toContain('--refresh works on every component command.'); + expect(help).toContain('component eject-templates [type]'); + expect(help).toContain('Write editable built-in templates'); + expect(help).toContain('--refresh works on list, install, and create.'); expect(help).not.toContain('component ls'); expect( Math.max(...visibleLines(help).map((line) => line.length)), @@ -61,6 +63,9 @@ describe('getRootHelp', () => { expect(help).toContain( ' -f, --format \n Deprecated Twig type alias', ); + expect(help).toContain( + ' component eject-templates [type]\n Write editable built-in templates', + ); expect( Math.max(...visibleLines(help).map((line) => line.length)), ).toBeLessThanOrEqual(60); diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index 1b25692..4a4b929 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -122,6 +122,21 @@ const sections: HelpSection[] = [ label: '--dry-run', description: 'Preview without writing files', }, + { + kind: 'command', + label: 'component eject-templates [type]', + description: 'Write editable built-in templates', + }, + { + kind: 'option', + label: '-f, --force', + description: 'Replace existing template files', + }, + { + kind: 'option', + label: '--dry-run', + description: 'Preview paths and conflicts', + }, ], }, { @@ -338,7 +353,7 @@ export default function getRootHelp({ lines.push(''); } - const refresh = '--refresh works on every component command.'; + const refresh = '--refresh works on list, install, and create.'; const switches = '-V, --version -h, --help'; if (width >= NATURAL_WIDTH) { lines.push(colors.dim(` ${refresh} ${switches}`)); diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 0cb7c5d..368c22b 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -74,6 +74,13 @@ declare module '@emulsify-cli/handlers' { refresh?: boolean; }; + export type EjectComponentTemplatesHandlerOptions = { + /** Replace existing selected component template overrides. */ + force?: boolean; + /** Preview component template destinations without writing files. */ + dryRun?: boolean; + }; + export type ClearCacheHandlerOptions = { /** Report cache contents without removing them. */ dryRun?: boolean; diff --git a/src/util/project/componentTemplates/buildComponentArtifacts.test.ts b/src/util/project/componentTemplates/buildComponentArtifacts.test.ts new file mode 100644 index 0000000..d5ef6bc --- /dev/null +++ b/src/util/project/componentTemplates/buildComponentArtifacts.test.ts @@ -0,0 +1,79 @@ +import { + COMPONENT_TYPES, + getCompatibleFormatToken, + getComponentFormatLabel, + type ComponentType, +} from '../componentTypes.js'; +import renderTemplate, { + type ComponentTemplateVars, +} from '../renderTemplate.js'; +import buildComponentArtifacts, { + buildEjectableComponentTemplates, +} from './buildComponentArtifacts.js'; + +const EXPECTED_LOGICAL_NAMES: Record = { + twig: [ + 'component.twig', + 'component.scss', + 'component.yml', + 'component.stories.js', + ], + 'twig-sdc': [ + 'component.twig', + 'component.scss', + 'component.component.yml', + 'component.js', + 'component.stories.js', + ], + react: ['component.jsx', 'component.scss', 'component.stories.jsx'], + 'web-component': ['component.js', 'component.scss', 'component.stories.js'], +}; + +function getConcreteVars(type: ComponentType): ComponentTemplateVars { + return { + filename: 'featured-item', + className: 'featured-item', + camelName: 'featuredItem', + pascalName: 'FeaturedItem', + snakeName: 'featured_item', + humanName: 'Featured Item', + directory: 'base', + directoryTitle: 'Base', + format: getCompatibleFormatToken(type), + formatLabel: getComponentFormatLabel(type), + type, + tagName: type === 'web-component' ? 'featured-item' : '', + }; +} + +describe('buildComponentArtifacts', () => { + it('has exactly 15 built-in logical artifacts across the four types', () => { + const artifacts = COMPONENT_TYPES.flatMap((type) => + buildEjectableComponentTemplates(type), + ); + + expect(artifacts).toHaveLength(15); + }); + + it.each(COMPONENT_TYPES)( + 'renders every ejected %s template byte-for-byte like its built-in', + (type) => { + const vars = getConcreteVars(type); + const builtIns = buildComponentArtifacts(type, vars); + const ejected = buildEjectableComponentTemplates(type); + + expect(ejected.map(({ logicalName }) => logicalName)).toEqual( + EXPECTED_LOGICAL_NAMES[type], + ); + expect(builtIns.map(({ logicalName }) => logicalName)).toEqual( + EXPECTED_LOGICAL_NAMES[type], + ); + + for (const [index, ejectedArtifact] of ejected.entries()) { + expect(renderTemplate(ejectedArtifact.contents, vars)).toBe( + builtIns[index].contents, + ); + } + }, + ); +}); diff --git a/src/util/project/componentTemplates/buildComponentArtifacts.ts b/src/util/project/componentTemplates/buildComponentArtifacts.ts new file mode 100644 index 0000000..5862341 --- /dev/null +++ b/src/util/project/componentTemplates/buildComponentArtifacts.ts @@ -0,0 +1,200 @@ +import type { ComponentType } from '../componentTypes.js'; +import type { ComponentTemplateVars } from '../renderTemplate.js'; + +import { buildReactTemplate } from './react.js'; +import { buildReactStoriesTemplate } from './reactStories.js'; +import { buildScssTemplate } from './scss.js'; +import { buildSdcJsTemplate } from './sdcJs.js'; +import { buildSdcMetadataTemplate } from './sdcMetadata.js'; +import { buildSdcStoriesTemplate } from './sdcStories.js'; +import { buildStoriesTemplate } from './stories.js'; +import { buildTwigTemplate } from './twig.js'; +import { buildWebComponentTemplate } from './webComponent.js'; +import { buildWebComponentStoriesTemplate } from './webComponentStories.js'; +import { buildYmlTemplate } from './yml.js'; + +export type ComponentArtifact = { + logicalName: string; + destinationName: string; + contents: string; +}; + +const token = (name: keyof ComponentTemplateVars): string => `{{ ${name} }}`; + +/** Template variables that preserve every value as an editable override token. */ +export const COMPONENT_TEMPLATE_TOKEN_VARS: ComponentTemplateVars = { + filename: token('filename'), + className: token('className'), + camelName: token('camelName'), + pascalName: token('pascalName'), + snakeName: token('snakeName'), + humanName: token('humanName'), + directory: token('directory'), + directoryTitle: token('directoryTitle'), + format: token('format'), + formatLabel: token('formatLabel'), + type: token('type'), + tagName: token('tagName'), +}; + +/** + * Build the complete artifact set for one component type. + * + * Concrete variables produce generated component files. Token variables produce + * editable override templates with the same logical artifact inventory. + */ +export default function buildComponentArtifacts( + type: ComponentType, + vars: ComponentTemplateVars, +): ComponentArtifact[] { + const { + filename, + className, + camelName, + pascalName, + snakeName, + humanName, + directoryTitle, + formatLabel, + tagName, + } = vars; + + switch (type) { + case 'twig': + return [ + { + logicalName: 'component.twig', + destinationName: `${filename}.twig`, + contents: buildTwigTemplate( + filename, + snakeName, + className, + formatLabel, + ), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + contents: buildScssTemplate(className, formatLabel), + }, + { + logicalName: 'component.yml', + destinationName: `${filename}.yml`, + contents: buildYmlTemplate(snakeName, humanName), + }, + { + logicalName: 'component.stories.js', + destinationName: `${filename}.stories.js`, + contents: buildStoriesTemplate( + camelName, + filename, + humanName, + directoryTitle, + ), + }, + ]; + case 'twig-sdc': + return [ + { + logicalName: 'component.twig', + destinationName: `${filename}.twig`, + contents: buildTwigTemplate( + filename, + snakeName, + className, + formatLabel, + ), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + contents: buildScssTemplate(className, formatLabel), + }, + { + logicalName: 'component.component.yml', + destinationName: `${filename}.component.yml`, + contents: buildSdcMetadataTemplate(snakeName, humanName), + }, + { + logicalName: 'component.js', + destinationName: `${filename}.js`, + contents: buildSdcJsTemplate(camelName, filename, className), + }, + { + logicalName: 'component.stories.js', + destinationName: `${filename}.stories.js`, + contents: buildSdcStoriesTemplate( + camelName, + filename, + snakeName, + humanName, + directoryTitle, + ), + }, + ]; + case 'react': + return [ + { + logicalName: 'component.jsx', + destinationName: `${filename}.jsx`, + contents: buildReactTemplate( + pascalName, + filename, + className, + humanName, + ), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + contents: buildScssTemplate(className, formatLabel), + }, + { + logicalName: 'component.stories.jsx', + destinationName: `${filename}.stories.jsx`, + contents: buildReactStoriesTemplate( + pascalName, + filename, + humanName, + directoryTitle, + ), + }, + ]; + case 'web-component': + return [ + { + logicalName: 'component.js', + destinationName: `${filename}.js`, + contents: buildWebComponentTemplate( + pascalName, + filename, + className, + humanName, + tagName, + ), + }, + { + logicalName: 'component.scss', + destinationName: `${filename}.scss`, + contents: buildScssTemplate(className, formatLabel), + }, + { + logicalName: 'component.stories.js', + destinationName: `${filename}.stories.js`, + contents: buildWebComponentStoriesTemplate( + filename, + humanName, + directoryTitle, + tagName, + ), + }, + ]; + } +} + +/** Build the editable logical templates for one component type. */ +export function buildEjectableComponentTemplates( + type: ComponentType, +): ComponentArtifact[] { + return buildComponentArtifacts(type, COMPONENT_TEMPLATE_TOKEN_VARS); +} diff --git a/src/util/project/componentTemplates/index.ts b/src/util/project/componentTemplates/index.ts index 6b2fbc5..e82994b 100644 --- a/src/util/project/componentTemplates/index.ts +++ b/src/util/project/componentTemplates/index.ts @@ -3,6 +3,12 @@ */ export { buildScssTemplate } from './scss.js'; +export { + default as buildComponentArtifacts, + buildEjectableComponentTemplates, + COMPONENT_TEMPLATE_TOKEN_VARS, + type ComponentArtifact, +} from './buildComponentArtifacts.js'; export { buildReactStoriesTemplate } from './reactStories.js'; export { buildReactTemplate } from './react.js'; export { buildSdcJsTemplate } from './sdcJs.js'; diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index 0e575cc..478f043 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -33,25 +33,7 @@ import { } from './componentTypes.js'; import resolveComponentTemplate from './resolveComponentTemplate.js'; import type { ComponentTemplateVars } from './renderTemplate.js'; -import { - buildReactStoriesTemplate, - buildReactTemplate, - buildScssTemplate, - buildSdcJsTemplate, - buildSdcMetadataTemplate, - buildSdcStoriesTemplate, - buildStoriesTemplate, - buildTwigTemplate, - buildWebComponentStoriesTemplate, - buildWebComponentTemplate, - buildYmlTemplate, -} from './componentTemplates/index.js'; - -type ComponentArtifact = { - logicalName: string; - destinationName: string; - build: () => string; -}; +import { buildComponentArtifacts } from './componentTemplates/index.js'; const TYPE_LABELS: Record = { twig: 'Twig', @@ -281,136 +263,7 @@ export default async function generateComponent( type, tagName, }; - let artifacts: ComponentArtifact[]; - - switch (type) { - case 'twig': - artifacts = [ - { - logicalName: 'component.twig', - destinationName: `${filename}.twig`, - build: () => - buildTwigTemplate(filename, snakeName, className, formatLabel), - }, - { - logicalName: 'component.scss', - destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, formatLabel), - }, - { - logicalName: 'component.yml', - destinationName: `${filename}.yml`, - build: () => buildYmlTemplate(snakeName, humanName), - }, - { - logicalName: 'component.stories.js', - destinationName: `${filename}.stories.js`, - build: () => - buildStoriesTemplate( - camelName, - filename, - humanName, - directoryTitle, - ), - }, - ]; - break; - case 'twig-sdc': - artifacts = [ - { - logicalName: 'component.twig', - destinationName: `${filename}.twig`, - build: () => - buildTwigTemplate(filename, snakeName, className, formatLabel), - }, - { - logicalName: 'component.scss', - destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, formatLabel), - }, - { - logicalName: 'component.component.yml', - destinationName: `${filename}.component.yml`, - build: () => buildSdcMetadataTemplate(snakeName, humanName), - }, - { - logicalName: 'component.js', - destinationName: `${filename}.js`, - build: () => buildSdcJsTemplate(camelName, filename, className), - }, - { - logicalName: 'component.stories.js', - destinationName: `${filename}.stories.js`, - build: () => - buildSdcStoriesTemplate( - camelName, - filename, - snakeName, - humanName, - directoryTitle, - ), - }, - ]; - break; - case 'react': - artifacts = [ - { - logicalName: 'component.jsx', - destinationName: `${filename}.jsx`, - build: () => - buildReactTemplate(pascalName, filename, className, humanName), - }, - { - logicalName: 'component.scss', - destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, formatLabel), - }, - { - logicalName: 'component.stories.jsx', - destinationName: `${filename}.stories.jsx`, - build: () => - buildReactStoriesTemplate( - pascalName, - filename, - humanName, - directoryTitle, - ), - }, - ]; - break; - case 'web-component': - artifacts = [ - { - logicalName: 'component.js', - destinationName: `${filename}.js`, - build: () => - buildWebComponentTemplate( - pascalName, - filename, - className, - humanName, - tagName, - ), - }, - { - logicalName: 'component.scss', - destinationName: `${filename}.scss`, - build: () => buildScssTemplate(className, formatLabel), - }, - { - logicalName: 'component.stories.js', - destinationName: `${filename}.stories.js`, - build: () => - buildWebComponentStoriesTemplate( - filename, - humanName, - directoryTitle, - tagName, - ), - }, - ]; - break; - } + const artifacts = buildComponentArtifacts(type, templateVars); const artifactDestinations = artifacts.map((artifact) => safeResolveWithin( projectRoot, @@ -485,7 +338,7 @@ export default async function generateComponent( type, artifact.logicalName, templateVars, - )) ?? artifact.build(); + )) ?? artifact.contents; const artifactDestination = artifactDestinations[index]; await fs.writeFile(artifactDestination, templateFile); diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 6107f76..47f5979 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -791,6 +791,67 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(existsSync(join(isolatedHome, '.emulsify', 'cache')), true); }); + test('requires an explicit template type outside a TTY without writing files', () => { + const templatesRoot = join(projectRoot, '.cli', 'templates'); + const before = snapshotFiles(templatesRoot); + const result = runCli(projectRoot, ['component', 'eject-templates']); + + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /\[type\] positional argument/u); + assert.deepEqual(snapshotFiles(templatesRoot), before); + }); + + test('uses a customized ejected template during component creation', () => { + const ejectResult = runCli(projectRoot, [ + 'component', + 'eject-templates', + 'twig', + ]); + assert.equal( + ejectResult.status, + 0, + commandFailure('component eject-templates twig', ejectResult), + ); + assert.equal(ejectResult.stderr, ''); + + const templatePath = join( + projectRoot, + '.cli', + 'templates', + 'twig', + 'component.twig', + ); + const template = readFileSync(templatePath, 'utf8'); + writeFileSync( + templatePath, + `${template}\n{# Ejected override for {{ humanName }} #}\n`, + ); + + const createResult = runCli(projectRoot, [ + 'component', + 'create', + 'ejected-card', + '--type', + 'twig', + '--directory', + 'components', + ]); + assert.equal( + createResult.status, + 0, + commandFailure('component create with ejected template', createResult), + ); + assert.equal(createResult.stderr, ''); + assert.match( + readFileSync( + join(projectRoot, 'components', 'ejected-card', 'ejected-card.twig'), + 'utf8', + ), + /Ejected override for Ejected Card/u, + ); + }); + test('scaffolds the exact artifact set for every component type', () => { const componentCases = [ { diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 449d9eb..40704fa 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -27,6 +27,9 @@ COMPONENTS -t, --type twig | twig-sdc | react | web-component -f, --format Deprecated Twig type alias --dry-run Preview without writing files + component eject-templates [type] Write editable built-in templates + -f, --force Replace existing template files + --dry-run Preview paths and conflicts SYSTEMS system list Show built-in systems @@ -43,5 +46,5 @@ MAINTENANCE cache clear Remove cached system repositories --dry-run Preview cache removal - --refresh works on every component command. -V, --version -h, --help + --refresh works on list, install, and create. -V, --version -h, --help emulsify --help for the full option list. From 010546ac91d34afad4c79a8f7979dc05a7d91f61 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 04:08:44 -0500 Subject: [PATCH 12/33] test(ci): fix cross-platform release checks --- scripts/smoke-pack.mjs | 6 +++++- src/handlers/componentEjectTemplates.test.ts | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/smoke-pack.mjs b/scripts/smoke-pack.mjs index ab49189..4cd0e90 100644 --- a/scripts/smoke-pack.mjs +++ b/scripts/smoke-pack.mjs @@ -163,7 +163,11 @@ function smokeTest(tempRoot) { `Emulsify CLI ${packageManifest.version}`, 'emulsify --help', ); - assertCommandOutput(helpResult, 'Usage:', 'emulsify --help'); + assertCommandOutput( + helpResult, + 'New here? Run these in order:', + 'emulsify --help', + ); const versionResult = runCommand(localBin, ['--version'], commandOptions); assertCommandOutput( diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index 496d9dc..16eb183 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -225,14 +225,17 @@ describe('componentEjectTemplates', () => { target === twigConflict || target === storiesConflict, ); - await expect(componentEjectTemplates('twig')).rejects.toMatchObject({ - name: 'CliError', - message: expect.stringMatching( - new RegExp( - `${twigConflict.replaceAll('/', '\\/')}[\\s\\S]*${storiesConflict.replaceAll('/', '\\/')}[\\s\\S]*Pass --force`, - ), - ), - }); + const error = await componentEjectTemplates('twig').catch( + (reason: unknown) => reason, + ); + expect(error).toMatchObject({ name: 'CliError' }); + const message = (error as Error).message; + expect(message).toContain(twigConflict); + expect(message).toContain(storiesConflict); + expect(message.indexOf(twigConflict)).toBeLessThan( + message.indexOf(storiesConflict), + ); + expect(message).toContain('Pass --force'); expect(pathExistsMock).toHaveBeenCalledTimes(4); expectNoWrites(); From 914cd3d240ef8a716954c70b32aaf94f3d221ed4 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 08:51:42 -0500 Subject: [PATCH 13/33] fix(component): prevent template tokens from rewriting user twig variables --- docs/component-template-overrides.md | 71 ++++++----- docs/emulsify-info-cli-updates.md | 39 ++++--- .../buildComponentArtifacts.ts | 31 ++--- src/util/project/generateComponent.test.ts | 22 ++++ src/util/project/renderTemplate.test.ts | 110 ++++++++++++++++-- src/util/project/renderTemplate.ts | 70 +++++++++-- .../project/resolveComponentTemplate.test.ts | 10 +- src/util/project/resolveComponentTemplate.ts | 9 +- test/e2e/cli.test.mjs | 13 +-- 9 files changed, 285 insertions(+), 90 deletions(-) diff --git a/docs/component-template-overrides.md b/docs/component-template-overrides.md index ba5a498..2f178e1 100644 --- a/docs/component-template-overrides.md +++ b/docs/component-template-overrides.md @@ -62,6 +62,12 @@ each Twig SDC artifact, it checks `.cli/templates/twig-sdc/` and then partial canonical override does not hide legacy overrides for other artifacts. React and Web Component overrides have no legacy aliases. +Canonical directories use the collision-free token syntax documented below. +The `default/` and `sdc/` aliases retain the v2.3 double-brace token syntax for +backward compatibility. Move an older override into its canonical directory +and update its tokens when convenient; this prevents CLI placeholders from +overlapping with ordinary Twig variables. + When no override is available, `component create` uses its built-in template. An override file that exists but is empty is ignored in favor of the built-in and produces a warning. Deleting an override restores this normal fallback @@ -73,37 +79,48 @@ to inherit built-in changes from future CLI releases. ## Supported Tokens -Override files can use double-brace tokens. - -| Token | Example Value For `featured-item` | -| ---------------------- | ---------------------------------------------------------------------------- | -| `{{ filename }}` | `featured-item` | -| `{{ className }}` | `featured-item` | -| `{{ camelName }}` | `featuredItem` | -| `{{ pascalName }}` | `FeaturedItem` | -| `{{ snakeName }}` | `featured_item` | -| `{{ humanName }}` | `Featured Item` | -| `{{ directory }}` | `base` | -| `{{ directoryTitle }}` | `Base` | -| `{{ type }}` | `twig`, `twig-sdc`, `react`, or `web-component` | -| `{{ tagName }}` | `featured-item` for a Web Component; an empty string for every other type | -| `{{ format }}` | `default` for Twig, `sdc` for Twig SDC, otherwise `react` or `web-component` | -| `{{ formatLabel }}` | `STANDARD`, `SDC`, `REACT`, or `WEB COMPONENT` | - -`{{ type }}` is the canonical token for new overrides. `{{ format }}` remains -populated so existing Twig and Twig SDC overrides keep their previous values -after migrating from `--format` to `--type`. `{{ formatLabel }}` contains the -display label used in generated file headers. `{{ directoryTitle }}` contains -the structure name with its first character capitalized for Storybook titles. - -Whitespace inside the braces is optional: +Canonical override files use namespaced placeholders. Their delimiter is +intentionally different from Twig's `{{ variable }}` syntax, so ordinary Twig +variables are never rewritten by the CLI. + +| Token | Example Value For `featured-item` | +| ----------------------------- | ---------------------------------------------------------------------------- | +| `__EMULSIFY_filename__` | `featured-item` | +| `__EMULSIFY_className__` | `featured-item` | +| `__EMULSIFY_camelName__` | `featuredItem` | +| `__EMULSIFY_pascalName__` | `FeaturedItem` | +| `__EMULSIFY_snakeName__` | `featured_item` | +| `__EMULSIFY_humanName__` | `Featured Item` | +| `__EMULSIFY_directory__` | `base` | +| `__EMULSIFY_directoryTitle__` | `Base` | +| `__EMULSIFY_type__` | `twig`, `twig-sdc`, `react`, or `web-component` | +| `__EMULSIFY_tagName__` | `featured-item` for a Web Component; an empty string for every other type | +| `__EMULSIFY_format__` | `default` for Twig, `sdc` for Twig SDC, otherwise `react` or `web-component` | +| `__EMULSIFY_formatLabel__` | `STANDARD`, `SDC`, `REACT`, or `WEB COMPONENT` | + +`__EMULSIFY_type__` is the canonical type. `__EMULSIFY_format__` remains +available for compatibility with the deprecated `--format` terminology. +`__EMULSIFY_formatLabel__` contains the display label used in generated file +headers. `__EMULSIFY_directoryTitle__` contains the structure name with its +first character capitalized for Storybook titles. + +For example, an override can combine a scaffold-time placeholder with a Twig +variable. Only the namespaced placeholder is replaced: ```twig -{{humanName}} -{{ humanName }} +
+ {{ type }} +
``` -Unknown tokens are left unchanged and logged as warnings. +Unknown namespaced placeholders are left unchanged and logged as warnings. +Ordinary Twig expressions are ignored by the CLI renderer. + +Legacy overrides in `default/` and `sdc/` continue to recognize the v2.3 +double-brace tokens: `filename`, `className`, `camelName`, `snakeName`, +`humanName`, `directory`, and `format`. New 2.4 tokens are not enabled in those +directories, preventing new collisions from being introduced into legacy +files. ## Customize An Ejected Template diff --git a/docs/emulsify-info-cli-updates.md b/docs/emulsify-info-cli-updates.md index ac7b026..dc346a6 100644 --- a/docs/emulsify-info-cli-updates.md +++ b/docs/emulsify-info-cli-updates.md @@ -206,23 +206,28 @@ legacy `sdc/` alias under the same rule. The fallback is resolved per artifact, so partial legacy override sets continue working. If neither path contains the artifact, the built-in template is used. -Override files can use double-brace tokens: - -- `{{ filename }}` -- `{{ className }}` -- `{{ camelName }}` -- `{{ pascalName }}` -- `{{ snakeName }}` -- `{{ humanName }}` -- `{{ directory }}` -- `{{ type }}` -- `{{ tagName }}` -- `{{ format }}` - -`{{ type }}` contains the canonical type. `{{ tagName }}` contains the -validated Web Component tag and is empty for the other types. For compatibility, -`{{ format }}` remains `default` for Twig and `sdc` for Twig SDC; it contains -`react` or `web-component` for the new types. +Canonical override files use namespaced tokens so ordinary Twig variables are +not rewritten by the CLI: + +- `__EMULSIFY_filename__` +- `__EMULSIFY_className__` +- `__EMULSIFY_camelName__` +- `__EMULSIFY_pascalName__` +- `__EMULSIFY_snakeName__` +- `__EMULSIFY_humanName__` +- `__EMULSIFY_directory__` +- `__EMULSIFY_directoryTitle__` +- `__EMULSIFY_type__` +- `__EMULSIFY_tagName__` +- `__EMULSIFY_format__` +- `__EMULSIFY_formatLabel__` + +`__EMULSIFY_type__` contains the canonical type. +`__EMULSIFY_tagName__` contains the validated Web Component tag and is empty +for the other types. For compatibility, `__EMULSIFY_format__` remains +`default` for Twig and `sdc` for Twig SDC; it contains `react` or +`web-component` for the new types. The legacy `default/` and `sdc/` aliases +continue to render the seven double-brace tokens supported in v2.3. If an override is unavailable, the built-in template is used. If an override exists but is empty, it is ignored and a warning is logged. Unknown tokens are diff --git a/src/util/project/componentTemplates/buildComponentArtifacts.ts b/src/util/project/componentTemplates/buildComponentArtifacts.ts index 5862341..36da489 100644 --- a/src/util/project/componentTemplates/buildComponentArtifacts.ts +++ b/src/util/project/componentTemplates/buildComponentArtifacts.ts @@ -1,5 +1,8 @@ import type { ComponentType } from '../componentTypes.js'; -import type { ComponentTemplateVars } from '../renderTemplate.js'; +import { + componentTemplateToken, + type ComponentTemplateVars, +} from '../renderTemplate.js'; import { buildReactTemplate } from './react.js'; import { buildReactStoriesTemplate } from './reactStories.js'; @@ -19,22 +22,20 @@ export type ComponentArtifact = { contents: string; }; -const token = (name: keyof ComponentTemplateVars): string => `{{ ${name} }}`; - /** Template variables that preserve every value as an editable override token. */ export const COMPONENT_TEMPLATE_TOKEN_VARS: ComponentTemplateVars = { - filename: token('filename'), - className: token('className'), - camelName: token('camelName'), - pascalName: token('pascalName'), - snakeName: token('snakeName'), - humanName: token('humanName'), - directory: token('directory'), - directoryTitle: token('directoryTitle'), - format: token('format'), - formatLabel: token('formatLabel'), - type: token('type'), - tagName: token('tagName'), + filename: componentTemplateToken('filename'), + className: componentTemplateToken('className'), + camelName: componentTemplateToken('camelName'), + pascalName: componentTemplateToken('pascalName'), + snakeName: componentTemplateToken('snakeName'), + humanName: componentTemplateToken('humanName'), + directory: componentTemplateToken('directory'), + directoryTitle: componentTemplateToken('directoryTitle'), + format: componentTemplateToken('format'), + formatLabel: componentTemplateToken('formatLabel'), + type: componentTemplateToken('type'), + tagName: componentTemplateToken('tagName'), }; /** diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index 78a4def..9c10778 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -779,6 +779,28 @@ describe('generateComponent', () => { ); }); + it('keeps ordinary Twig variables in a canonical override', async () => { + expect.assertions(2); + mockTemplateOverrides({ + 'twig/component.twig': + "{% set type = 'promo' %}{{ type }}

__EMULSIFY_humanName__

", + }); + + await generateComponent(variant, projectConfig, 'featuredItem', { + directory: 'base', + type: 'twig', + }); + + expect(readFileMock).toHaveBeenCalledWith( + projectTemplatePath('twig', 'component.twig'), + 'utf8', + ); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('featured-item', 'featured-item.twig'), + "{% set type = 'promo' %}{{ type }}

Featured Item

", + ); + }); + it('allows partial project template overrides per artifact', async () => { expect.assertions(2); mockTemplateOverrides({ diff --git a/src/util/project/renderTemplate.test.ts b/src/util/project/renderTemplate.test.ts index a0b9dab..263d319 100644 --- a/src/util/project/renderTemplate.test.ts +++ b/src/util/project/renderTemplate.test.ts @@ -5,7 +5,7 @@ jest.mock('../../lib/log.js'); import log from '../../lib/log.js'; -import renderTemplate from './renderTemplate.js'; +import renderTemplate, { renderLegacyTemplate } from './renderTemplate.js'; import type { ComponentTemplateVars } from './renderTemplate.js'; const vars: ComponentTemplateVars = { @@ -23,17 +23,21 @@ const vars: ComponentTemplateVars = { tagName: 'featured-item', }; +const twigVariableNames = Object.keys(vars) as Array< + keyof ComponentTemplateVars +>; + describe('renderTemplate', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders all supported component template tokens', () => { + it('renders all 12 canonical component template tokens', () => { expect.assertions(1); expect( renderTemplate( - '{{ filename }}|{{ className }}|{{ camelName }}|{{ pascalName }}|{{ snakeName }}|{{ humanName }}|{{ directory }}|{{ directoryTitle }}|{{ format }}|{{ formatLabel }}|{{ type }}|{{ tagName }}', + '__EMULSIFY_filename__|__EMULSIFY_className__|__EMULSIFY_camelName__|__EMULSIFY_pascalName__|__EMULSIFY_snakeName__|__EMULSIFY_humanName__|__EMULSIFY_directory__|__EMULSIFY_directoryTitle__|__EMULSIFY_format__|__EMULSIFY_formatLabel__|__EMULSIFY_type__|__EMULSIFY_tagName__', vars, ), ).toBe( @@ -41,16 +45,108 @@ describe('renderTemplate', () => { ); }); - it('leaves unknown tokens untouched and logs one warning per token', () => { + it.each(twigVariableNames)( + 'leaves the ordinary Twig variable %s untouched without warning', + (variableName) => { + expect.assertions(2); + const template = `{% if ${variableName} %}{{ ${variableName} }}{% endif %}`; + + expect(renderTemplate(template, vars)).toBe(template); + expect(log).not.toHaveBeenCalled(); + }, + ); + + it('does not rewrite the reported Twig type variable collision', () => { + expect.assertions(2); + const template = + "{% if type == 'promo' %}{{ type }}{% endif %}"; + + expect(renderTemplate(template, vars)).toBe(template); + expect(log).not.toHaveBeenCalled(); + }); + + it('does not rewrite the reported Twig directory and filename variable collisions', () => { + expect.assertions(2); + const template = '{{ filename }}'; + + expect(renderTemplate(template, vars)).toBe(template); + expect(log).not.toHaveBeenCalled(); + }); + + it('leaves an unknown canonical token untouched and logs one warning', () => { expect.assertions(3); + const template = '__EMULSIFY_unknown__ __EMULSIFY_unknown__'; + + expect(renderTemplate(template, vars)).toBe(template); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + 'warn', + 'Unknown component template token "__EMULSIFY_unknown__" left unchanged.', + ); + }); + + it.each(['human_name', 'constructor'])( + 'leaves the unknown canonical token %s untouched and warns', + (token) => { + expect.assertions(3); + const template = `__EMULSIFY_${token}__`; + + expect(renderTemplate(template, vars)).toBe(template); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith( + 'warn', + `Unknown component template token "${template}" left unchanged.`, + ); + }, + ); + + it('renders a canonical token nested inside a Twig print expression without consuming the outer braces', () => { + expect.assertions(2); expect( - renderTemplate('{{ humanName }} {{ unknown }} {{ unknown }}', vars), - ).toBe('Featured Item {{ unknown }} {{ unknown }}'); + renderTemplate('{{ __EMULSIFY_snakeName____base_class }}', vars), + ).toBe('{{ featured_item__base_class }}'); + expect(log).not.toHaveBeenCalled(); + }); +}); + +describe('renderLegacyTemplate', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the seven component template tokens supported in v2.3', () => { + expect.assertions(1); + + expect( + renderLegacyTemplate( + '{{ filename }}|{{ className }}|{{ camelName }}|{{ snakeName }}|{{ humanName }}|{{ directory }}|{{ format }}', + vars, + ), + ).toBe( + 'featured-item|featured-item|featuredItem|featured_item|Featured Item|base|default', + ); + }); + + it('leaves a token introduced after v2.3 unknown in a legacy override', () => { + expect.assertions(3); + + expect(renderLegacyTemplate('{{ type }} {{ type }}', vars)).toBe( + '{{ type }} {{ type }}', + ); expect(log).toHaveBeenCalledTimes(1); expect(log).toHaveBeenCalledWith( 'warn', - 'Unknown component template token "{{ unknown }}" left unchanged.', + 'Unknown component template token "{{ type }}" left unchanged.', ); }); + + it('renders a legacy token nested inside a Twig print expression without consuming the outer braces', () => { + expect.assertions(2); + + expect( + renderLegacyTemplate('{{ {{ snakeName }}__base_class }}', vars), + ).toBe('{{ featured_item__base_class }}'); + expect(log).not.toHaveBeenCalled(); + }); }); diff --git a/src/util/project/renderTemplate.ts b/src/util/project/renderTemplate.ts index 5d709f8..d866397 100644 --- a/src/util/project/renderTemplate.ts +++ b/src/util/project/renderTemplate.ts @@ -19,24 +19,52 @@ export type ComponentTemplateVars = { tagName: string; }; -const tokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; +// Match canonical names lazily so plausible unknown names containing `_` still +// warn without consuming the adjacent Twig suffix in an ejected expression +// such as `{{ __EMULSIFY_snakeName____base_class }}`. The first `__` must +// remain the closing delimiter so the outer Twig expression survives intact. +const tokenPattern = /__EMULSIFY_([A-Za-z][A-Za-z0-9_]*?)__/g; + +// Keep this legacy pattern deliberately narrow. A v2.3 override can nest a +// token inside a Twig print expression (`{{ {{ snakeName }}__base_class }}`). +// Matching only the inner identifier preserves the outer Twig braces; allowing +// `_` or `.` here could consume part of that expression and corrupt the output. +const legacyTokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; +const legacyTokenNames = new Set([ + 'filename', + 'className', + 'camelName', + 'snakeName', + 'humanName', + 'directory', + 'format', +]); + +/** Return the collision-free placeholder for a component template value. */ +export function componentTemplateToken( + name: keyof ComponentTemplateVars, +): string { + return `__EMULSIFY_${name}__`; +} /** - * Renders double-brace tokens in a component template override. + * Renders namespaced tokens in a component template override. * - * Supported tokens are `{{ filename }}`, `{{ className }}`, `{{ camelName }}`, - * `{{ pascalName }}`, `{{ snakeName }}`, `{{ humanName }}`, `{{ directory }}`, - * `{{ directoryTitle }}`, `{{ format }}`, `{{ formatLabel }}`, `{{ type }}`, - * and `{{ tagName }}`. + * Supported tokens are `__EMULSIFY_filename__`, `__EMULSIFY_className__`, + * `__EMULSIFY_camelName__`, `__EMULSIFY_pascalName__`, + * `__EMULSIFY_snakeName__`, `__EMULSIFY_humanName__`, + * `__EMULSIFY_directory__`, `__EMULSIFY_directoryTitle__`, + * `__EMULSIFY_format__`, `__EMULSIFY_formatLabel__`, `__EMULSIFY_type__`, + * and `__EMULSIFY_tagName__`. * Unknown tokens are left unchanged and logged as warnings. * - * @param template raw template file content containing optional double-brace tokens. + * @param template raw template file content containing optional namespaced tokens. * @param vars component template values available for token replacement. * @returns rendered template content with supported tokens replaced. * @throws {Error} if token rendering fails unexpectedly. * * @example - * renderTemplate('

{{ humanName }}

', { + * renderTemplate('

__EMULSIFY_humanName__

', { * filename: 'featured-item', * className: 'featured-item', * camelName: 'featuredItem', @@ -55,11 +83,31 @@ const tokenPattern = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g; export default function renderTemplate( template: string, vars: ComponentTemplateVars, +): string { + return renderTokens(template, vars, tokenPattern); +} + +/** Render the double-brace token syntax used by v2.3 alias directories. */ +export function renderLegacyTemplate( + template: string, + vars: ComponentTemplateVars, +): string { + return renderTokens(template, vars, legacyTokenPattern, legacyTokenNames); +} + +function renderTokens( + template: string, + vars: ComponentTemplateVars, + pattern: RegExp, + supportedTokens?: ReadonlySet, ): string { const warnedTokens = new Set(); - return template.replace(tokenPattern, (match, token: string) => { - if (token in vars) { + return template.replace(pattern, (match, token: string) => { + if ( + (!supportedTokens || supportedTokens.has(token)) && + Object.hasOwn(vars, token) + ) { return vars[token as keyof ComponentTemplateVars]; } @@ -67,7 +115,7 @@ export default function renderTemplate( warnedTokens.add(token); log( 'warn', - `Unknown component template token "{{ ${token} }}" left unchanged.`, + `Unknown component template token "${match}" left unchanged.`, ); } diff --git a/src/util/project/resolveComponentTemplate.test.ts b/src/util/project/resolveComponentTemplate.test.ts index 521e291..2a0c2c2 100644 --- a/src/util/project/resolveComponentTemplate.test.ts +++ b/src/util/project/resolveComponentTemplate.test.ts @@ -60,11 +60,13 @@ describe('resolveComponentTemplate', () => { it('uses the canonical override without consulting the legacy alias', async () => { expect.assertions(4); pathExistsMock.mockResolvedValueOnce(true); - readFileMock.mockResolvedValueOnce('

{{ humanName }}

'); + readFileMock.mockResolvedValueOnce( + '

__EMULSIFY_humanName__ {{ type }}

', + ); await expect( resolveComponentTemplate(projectRoot, 'twig', 'component.twig', vars), - ).resolves.toBe('

Featured Item

'); + ).resolves.toBe('

Featured Item {{ type }}

'); expect(pathExistsMock).toHaveBeenCalledTimes(1); expect(pathExistsMock).toHaveBeenCalledWith(twigTemplatePath); @@ -88,7 +90,7 @@ describe('resolveComponentTemplate', () => { it('falls back from twig-sdc to the legacy sdc directory', async () => { expect.assertions(4); pathExistsMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - readFileMock.mockResolvedValueOnce('{{ type }}: {{ pascalName }}'); + readFileMock.mockResolvedValueOnce('{{ format }}: {{ humanName }}'); await expect( resolveComponentTemplate(projectRoot, 'twig-sdc', 'component.twig', { @@ -96,7 +98,7 @@ describe('resolveComponentTemplate', () => { format: 'sdc', type: 'twig-sdc', }), - ).resolves.toBe('twig-sdc: FeaturedItem'); + ).resolves.toBe('sdc: Featured Item'); expect(pathExistsMock).toHaveBeenCalledTimes(2); expect(pathExistsMock).toHaveBeenNthCalledWith(1, twigSdcTemplatePath); diff --git a/src/util/project/resolveComponentTemplate.ts b/src/util/project/resolveComponentTemplate.ts index 976d042..2ba80ef 100644 --- a/src/util/project/resolveComponentTemplate.ts +++ b/src/util/project/resolveComponentTemplate.ts @@ -8,7 +8,10 @@ import { pathExists } from 'fs-extra'; import log from '../../lib/log.js'; import { EMULSIFY_PROJECT_TEMPLATES_FOLDER } from '../../lib/constants.js'; -import renderTemplate, { ComponentTemplateVars } from './renderTemplate.js'; +import renderTemplate, { + renderLegacyTemplate, + type ComponentTemplateVars, +} from './renderTemplate.js'; import type { ComponentType } from './componentTypes.js'; // Component overrides intentionally mirror built-in artifacts one-for-one: @@ -79,7 +82,9 @@ export default async function resolveComponentTemplate( return null; } - return renderTemplate(template, vars); + return directory === legacyDirectory + ? renderLegacyTemplate(template, vars) + : renderTemplate(template, vars); } return null; diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 47f5979..af42ee0 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -825,7 +825,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { const template = readFileSync(templatePath, 'utf8'); writeFileSync( templatePath, - `${template}\n{# Ejected override for {{ humanName }} #}\n`, + `${template}\n{# Ejected override for __EMULSIFY_humanName__ #}\n{% set type = 'promo' %}{{ type }}\n`, ); const createResult = runCli(projectRoot, [ @@ -843,13 +843,12 @@ describe('built Emulsify CLI', { concurrency: false }, () => { commandFailure('component create with ejected template', createResult), ); assert.equal(createResult.stderr, ''); - assert.match( - readFileSync( - join(projectRoot, 'components', 'ejected-card', 'ejected-card.twig'), - 'utf8', - ), - /Ejected override for Ejected Card/u, + const generatedTemplate = readFileSync( + join(projectRoot, 'components', 'ejected-card', 'ejected-card.twig'), + 'utf8', ); + assert.match(generatedTemplate, /Ejected override for Ejected Card/u); + assert.ok(generatedTemplate.includes('{{ type }}')); }); test('scaffolds the exact artifact set for every component type', () => { From be0f488e29c589cf65e6a47cb529e3a895b44259 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 09:16:11 -0500 Subject: [PATCH 14/33] fix(cli): align component overwrite flags --- README.md | 5 +++-- docs/cli-reference.md | 10 ++++++---- docs/components.md | 6 ++++-- docs/emulsify-info-cli-updates.md | 5 +++-- src/index.ts | 6 +++++- src/lib/rootHelp.test.ts | 3 +++ src/lib/rootHelp.ts | 5 +++++ src/types/handlers.d.ts | 4 +++- src/util/project/generateComponent.test.ts | 18 ++++++++++++++++++ src/util/project/generateComponent.ts | 8 +++++--- test/e2e/cli.test.mjs | 3 +++ test/e2e/root-help.txt | 1 + 12 files changed, 59 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 32ca223..ed277f8 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ emulsify system install compound emulsify component install card --force # Or install every available component: emulsify component install --all -emulsify component create promo-card --directory molecules --type twig --yes +emulsify component create promo-card --directory molecules --type twig --force emulsify component eject-templates twig emulsify system detach --yes ``` @@ -109,7 +109,8 @@ emulsify system detach --yes For component installation, provide either a component name or `--all`, and use `--force` when an existing destination should be replaced. For component creation, provide the positional name plus `--type` and `--directory`, and use -`--yes` when an existing generated component should be replaced. Explicit +`--force` when an existing generated component should be replaced. The existing +`-y, --yes` form remains available as a compatibility alias. Explicit `--type` values are honored even when project detection would hide that choice from the wizard. The deprecated `--format default` and `--format sdc` forms remain available as aliases for `--type twig` and `--type twig-sdc`, diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9fcf0b1..2c2d2d0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -456,7 +456,8 @@ Options: | `-d, --directory ` | Variant structure name where the component should be created. | | `-t, --type ` | Component renderer and packaging type. | | `-f, --format ` | Deprecated alias: `default` maps to `twig`; `sdc` maps to `twig-sdc`. Prints a deprecation warning. | -| `-y, --yes` | Replace an existing generated component without prompting. | +| `--force` | Replace an existing generated component without prompting. | +| `-y, --yes` | Compatibility alias for `--force`. | | `--dry-run` | Preview destination and generated files without writing, removing, or creating files. | | `--refresh` | Check the system's remote ref before reusing its local cache entry. | @@ -480,6 +481,7 @@ override; non-interactive creation derives and validates it silently. When standard input is not a TTY, provide the positional `[name]` plus both `--directory` and `--type`; otherwise the command exits with an actionable error instead of waiting for prompts that cannot be answered. If the generated -component already exists, also pass `--yes` to replace it without an overwrite -prompt. Existing scripts may continue using `--format default` or -`--format sdc`; both aliases work and print a deprecation warning. +component already exists, also pass `--force` to replace it without an +overwrite prompt. Existing scripts may continue using the `-y, --yes` +compatibility alias. The deprecated `--format default` and `--format sdc` +forms also remain available and print a deprecation warning. diff --git a/docs/components.md b/docs/components.md index 49eb415..0243c76 100644 --- a/docs/components.md +++ b/docs/components.md @@ -251,10 +251,12 @@ For compatibility with existing scripts, deprecated `--format default` maps to `--type twig` and `--format sdc` maps to `--type twig-sdc`. Both legacy forms print a deprecation warning. -Use `--yes` when the command should replace an existing generated component without asking: +Use `--force` when the command should replace an existing generated component +without asking. The existing `-y, --yes` form remains available as a +compatibility alias. ```bash -emulsify component create featured-item --directory base --type twig --yes +emulsify component create featured-item --directory base --type twig --force ``` ## Template Overrides diff --git a/docs/emulsify-info-cli-updates.md b/docs/emulsify-info-cli-updates.md index dc346a6..57d735e 100644 --- a/docs/emulsify-info-cli-updates.md +++ b/docs/emulsify-info-cli-updates.md @@ -134,7 +134,8 @@ Options: - `--directory `: Sets the variant structure where the component is created. - `--type `: Sets the component type. Supported values are `twig`, `twig-sdc`, `react`, and `web-component`. - `--format `: Deprecated compatibility alias. `default` maps to `twig`; `sdc` maps to `twig-sdc`, and both print a warning. -- `--yes`: Replaces an existing component without an overwrite confirmation prompt. +- `--force`: Replaces an existing component without an overwrite confirmation prompt. +- `--yes`: Compatibility alias for `--force`. - `--dry-run`: Previews the destination and generated files without writing, removing, or creating files. In the interactive wizard, Twig is always available, Twig SDC is shown only @@ -150,7 +151,7 @@ Examples: ```bash emulsify component create promo-card --directory molecules --type twig -emulsify component create teaser --directory molecules --type twig-sdc --yes +emulsify component create teaser --directory molecules --type twig-sdc --force emulsify component create promo-card --directory molecules --type react emulsify component create promo-card --directory molecules --type web-component emulsify component create promo-card --directory molecules --type twig --dry-run diff --git a/src/index.ts b/src/index.ts index 8d8b223..027eecc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,9 +163,13 @@ component '-f, --format ', 'Deprecated alias: default maps to twig and sdc maps to twig-sdc.', ) + .option( + '--force', + 'Replace an existing generated component without prompting.', + ) .option( '-y, --yes', - 'Skip overwrite confirmation prompts and replace existing components.', + 'Compatibility alias for --force when replacing an existing component.', ) .option( '--dry-run', diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index 7cdfb5d..78cd592 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -37,6 +37,9 @@ describe('getRootHelp', () => { expect(help).toContain('audit [args...]'); expect(help).toContain('component eject-templates [type]'); expect(help).toContain('Write editable built-in templates'); + expect(help).toContain( + ' --force Replace an existing generated component', + ); expect(help).toContain('--refresh works on list, install, and create.'); expect(help).not.toContain('component ls'); expect( diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index 4a4b929..07fb92c 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -117,6 +117,11 @@ const sections: HelpSection[] = [ label: '-f, --format ', description: 'Deprecated Twig type alias', }, + { + kind: 'option', + label: '--force', + description: 'Replace an existing generated component', + }, { kind: 'option', label: '--dry-run', diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 368c22b..bed7352 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -66,7 +66,9 @@ declare module '@emulsify-cli/handlers' { type?: string; /** Deprecated component format alias. "default" maps to "twig" and "sdc" maps to "twig-sdc". */ format?: string; - /** Skip overwrite confirmation prompts and replace existing components. */ + /** Replace an existing component without prompting. */ + force?: boolean; + /** Compatibility alias for force. */ 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 9c10778..d10a582 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -591,6 +591,24 @@ describe('generateComponent', () => { ); }); + it('skips the overwrite confirm and replaces the component when force is set', async () => { + expect.assertions(3); + setStdinIsTTY(false); + + await generateComponent(variant, projectConfig, 'link', { + directory: 'base', + type: 'twig', + force: true, + }); + + expect(confirm).not.toHaveBeenCalled(); + expect(removeMock).toHaveBeenCalledWith(componentPath('link')); + expect(log).toHaveBeenCalledWith( + 'success', + expect.stringContaining('Success!'), + ); + }); + it('should continue creation if user confirms overwrite', async () => { expect.assertions(2); confirmMock.mockResolvedValueOnce(true); diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index 478f043..cb4dc08 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -126,7 +126,8 @@ function validateCustomElementTagName(value: string): true | string { * @param options.directory string name of the directory where the component should be created. * @param options.type canonical component type to generate. * @param options.format deprecated component format alias. "default" maps to "twig" and "sdc" maps to "twig-sdc". - * @param options.yes whether to skip overwrite confirmation prompts and replace existing components. + * @param options.force whether to replace existing components without prompting. + * @param options.yes compatibility alias for options.force. * @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. @@ -141,6 +142,7 @@ export default async function generateComponent( const { filename, className, camelName, pascalName, snakeName, humanName } = deriveComponentNames(componentName); const providedType = resolveProvidedComponentType(options); + const force = options.force === true || options.yes === true; let directory = options.directory || ''; // Gather information about the current Emulsify project. If none exists, @@ -274,7 +276,7 @@ export default async function generateComponent( if (options.dryRun) { const realRunAction = componentExists - ? options.yes + ? force ? 'replace the existing component directory' : 'prompt before replacing the existing component directory' : 'create the component directory'; @@ -315,7 +317,7 @@ export default async function generateComponent( default: false, }), nonInteractive: { value: false }, - accept: { when: options.yes === true, value: true }, + accept: { when: force, value: true }, }); if (!shouldReplace) { diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index af42ee0..67b4732 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -322,6 +322,9 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); assert.match(result.stdout, /--format /u); assert.match(result.stdout, /Deprecated alias/u); + assert.match(result.stdout, /--force/u); + assert.match(result.stdout, /-y, --yes/u); + assert.match(result.stdout, /Compatibility alias for --force/u); }); test('prints the package version', () => { diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 40704fa..b8ee447 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -26,6 +26,7 @@ COMPONENTS -d, --directory Variant structure to create it in -t, --type twig | twig-sdc | react | web-component -f, --format Deprecated Twig type alias + --force Replace an existing generated component --dry-run Preview without writing files component eject-templates [type] Write editable built-in templates -f, --force Replace existing template files From 6ce2182bc23a3a936e20315cb95b10b42dfd6c89 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 09:21:44 -0500 Subject: [PATCH 15/33] feat(component): add all template ejection option --- README.md | 7 +-- docs/cli-reference.md | 7 ++- docs/component-template-overrides.md | 7 +-- docs/components.md | 5 +- src/handlers/componentEjectTemplates.test.ts | 34 +++++++++++++ src/handlers/componentEjectTemplates.ts | 45 ++++++++++++------ src/index.ts | 1 + src/lib/rootHelp.test.ts | 1 + src/lib/rootHelp.ts | 5 ++ src/types/handlers.d.ts | 2 + test/e2e/cli.test.mjs | 50 ++++++++++++++++++++ test/e2e/root-help.txt | 1 + 12 files changed, 140 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ed277f8..9b18b1e 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ emulsify component eject-templates twig ``` Run the command without a type in an interactive terminal to select one or more -component types. Existing overrides are protected unless `--force` is passed. +component types. Use `--all` to eject every type non-interactively. Existing +overrides are protected unless `--force` is passed. Prompts only run when standard input is a TTY. In CI, scripts, and commands with piped or redirected input, provide every required positional argument and flag; @@ -102,7 +103,7 @@ emulsify component install card --force # Or install every available component: emulsify component install --all emulsify component create promo-card --directory molecules --type twig --force -emulsify component eject-templates twig +emulsify component eject-templates --all emulsify system detach --yes ``` @@ -115,7 +116,7 @@ creation, provide the positional name plus `--type` and `--directory`, and use from the wizard. The deprecated `--format default` and `--format sdc` forms remain available as aliases for `--type twig` and `--type twig-sdc`, respectively, and print a deprecation warning. -For template ejection, provide the component type outside a TTY; use +For template ejection, provide the component type or `--all` outside a TTY; use `--dry-run` to preview paths and `--force` only when existing customizations should be replaced. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2c2d2d0..2b9d5c6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -400,8 +400,9 @@ scratch. The command reports every destination with its real project path. Supported types are `twig`, `twig-sdc`, `react`, and `web-component`. In an interactive terminal, omitting `[type]` opens a multi-select prompt where one, several, or all four types can be selected. When standard input is not a TTY, -provide one type; otherwise the command exits immediately with an actionable -message naming the argument. +provide one type or pass `--all`; otherwise the command exits immediately with +an actionable message naming both choices. `[type]` and `--all` are mutually +exclusive. Before writing, the command checks every target in the selected set. If any target already exists, it reports all conflicts and writes nothing. Pass @@ -411,6 +412,7 @@ Options: | Option | Description | | ------------- | ------------------------------------------------------------------------------ | +| `-a, --all` | Eject templates for every supported component type. | | `-f, --force` | Replace existing template files after the selection-wide conflict check. | | `--dry-run` | Report template destinations and conflicts without creating or changing files. | @@ -419,6 +421,7 @@ Examples: ```bash emulsify component eject-templates emulsify component eject-templates twig +emulsify component eject-templates --all emulsify component eject-templates react --dry-run emulsify component eject-templates web-component --force ``` diff --git a/docs/component-template-overrides.md b/docs/component-template-overrides.md index 2f178e1..88c716d 100644 --- a/docs/component-template-overrides.md +++ b/docs/component-template-overrides.md @@ -9,13 +9,14 @@ emulsify component eject-templates In an interactive terminal, the command lets you select one or more component types. To eject one type directly, including from a script or CI job, provide -its canonical type: +its canonical type. Use `--all` when all four types are wanted: ```bash emulsify component eject-templates twig emulsify component eject-templates twig-sdc emulsify component eject-templates react emulsify component eject-templates web-component +emulsify component eject-templates --all ``` The command writes the selected defaults beneath @@ -43,8 +44,8 @@ changing files: emulsify component eject-templates react --dry-run ``` -When standard input is not a TTY, `[type]` is required. The CLI exits with an -actionable error instead of opening a prompt. +When standard input is not a TTY, `[type]` or `--all` is required. The CLI exits +with an actionable error instead of opening a prompt. Do not combine them. ## How Overrides Are Resolved diff --git a/docs/components.md b/docs/components.md index 0243c76..f728ea7 100644 --- a/docs/components.md +++ b/docs/components.md @@ -269,8 +269,9 @@ emulsify component eject-templates twig In an interactive terminal, omit the type to select one or more types. In a non-interactive environment, provide one of `twig`, `twig-sdc`, `react`, or -`web-component`. The command protects existing customizations unless `--force` -is passed, and `--dry-run` previews every destination without writing files. +`web-component`, or pass `--all` to eject every type. Do not combine a type +with `--all`. The command protects existing customizations unless `--force` is +passed, and `--dry-run` previews every destination without writing files. Edit the resulting `.cli/templates//...` files. Then use `component create` normally. See diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index 16eb183..bf84ed0 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -14,6 +14,7 @@ import type { ComponentType } from '../util/project/componentTypes.js'; import { buildEjectableComponentTemplates } from '../util/project/componentTemplates/index.js'; import componentEjectTemplates, { buildComponentTemplateEjectionPlan, + CONFLICTING_TEMPLATE_TYPE_ERROR, MISSING_TEMPLATE_TYPE_ERROR, } from './componentEjectTemplates.js'; @@ -119,6 +120,39 @@ describe('componentEjectTemplates', () => { ); }); + it('writes all 15 templates without prompting when --all is passed', async () => { + await componentEjectTemplates(undefined, { all: true }); + + expect(checkboxMock).not.toHaveBeenCalled(); + expect(pathExistsMock).toHaveBeenCalledTimes(15); + expect(mkdirMock).toHaveBeenCalledTimes(15); + expect(writeFileMock).toHaveBeenCalledTimes(15); + expect(writeFileMock).toHaveBeenCalledWith( + destination('twig', 'component.twig'), + expect.any(String), + { encoding: 'utf-8', flag: 'wx' }, + ); + expect(writeFileMock).toHaveBeenCalledWith( + destination('web-component', 'component.stories.js'), + expect.any(String), + { encoding: 'utf-8', flag: 'wx' }, + ); + }); + + it('rejects combining an explicit type with --all before project lookup', async () => { + await expect( + componentEjectTemplates('twig', { all: true }), + ).rejects.toMatchObject({ + name: 'CliError', + message: CONFLICTING_TEMPLATE_TYPE_ERROR, + exitCode: 1, + }); + + expect(findFileMock).not.toHaveBeenCalled(); + expect(pathExistsMock).not.toHaveBeenCalled(); + expectNoWrites(); + }); + it('prompts interactively for one, several, or all component types', async () => { setStdinIsTTY(true); checkboxMock.mockResolvedValueOnce(['web-component', 'twig']); diff --git a/src/handlers/componentEjectTemplates.ts b/src/handlers/componentEjectTemplates.ts index b79ec0d..d4c7b4a 100644 --- a/src/handlers/componentEjectTemplates.ts +++ b/src/handlers/componentEjectTemplates.ts @@ -22,7 +22,9 @@ import { buildEjectableComponentTemplates } from '../util/project/componentTempl import { requireInteractiveTerminal, runPrompt } from '../util/prompt/index.js'; export const MISSING_TEMPLATE_TYPE_ERROR = - 'Component template type is required in non-interactive mode. Pass the [type] positional argument (twig, twig-sdc, react, or web-component).'; + 'Component template selection is required in non-interactive mode. Pass the [type] positional argument (twig, twig-sdc, react, or web-component), or pass --all.'; +export const CONFLICTING_TEMPLATE_TYPE_ERROR = + 'Pass either the [type] positional argument or --all, not both.'; const TYPE_CHOICES: { name: string; @@ -177,12 +179,20 @@ function isAlreadyExistsError(error: unknown): boolean { /** Handler for `emulsify component eject-templates [type]`. */ export default async function componentEjectTemplates( type: string | void, - { force = false, dryRun = false }: EjectComponentTemplatesHandlerOptions = {}, + { + all = false, + force = false, + dryRun = false, + }: EjectComponentTemplatesHandlerOptions = {}, ): Promise { const requestedType = type?.trim(); + if (requestedType && all) { + throw new CliError(CONFLICTING_TEMPLATE_TYPE_ERROR); + } + // CI must name the target before project lookup or any other work. - if (!requestedType) { + if (!requestedType && !all) { requireInteractiveTerminal(MISSING_TEMPLATE_TYPE_ERROR); } @@ -194,18 +204,23 @@ export default async function componentEjectTemplates( } const projectRoot = dirname(projectConfigPath); - const selectedTypes = requestedType - ? [normalizeRequestedType(requestedType)] - : await runPrompt({ - prompt: () => - checkbox({ - message: 'Which component template types should be ejected?', - choices: TYPE_CHOICES, - validate: (values) => - values.length > 0 || 'Select at least one component type.', - }), - nonInteractive: { error: MISSING_TEMPLATE_TYPE_ERROR }, - }); + let selectedTypes: readonly ComponentType[]; + if (all) { + selectedTypes = COMPONENT_TYPES; + } else if (requestedType) { + selectedTypes = [normalizeRequestedType(requestedType)]; + } else { + selectedTypes = await runPrompt({ + prompt: () => + checkbox({ + message: 'Which component template types should be ejected?', + choices: TYPE_CHOICES, + validate: (values) => + values.length > 0 || 'Select at least one component type.', + }), + nonInteractive: { error: MISSING_TEMPLATE_TYPE_ERROR }, + }); + } const canonicalTypes = canonicalizeSelectedTypes(selectedTypes); if (canonicalTypes.length === 0) { throw new CliError('Select at least one component type.'); diff --git a/src/index.ts b/src/index.ts index 027eecc..f461139 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,6 +185,7 @@ component component .command('eject-templates [type]') .description('Write editable copies of the built-in component templates') + .option('-a, --all', 'Eject templates for every supported component type.') .option( '-f, --force', 'Replace existing template files in the selected type set.', diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index 78cd592..46f2ee6 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -37,6 +37,7 @@ describe('getRootHelp', () => { expect(help).toContain('audit [args...]'); expect(help).toContain('component eject-templates [type]'); expect(help).toContain('Write editable built-in templates'); + expect(help).toContain('Eject every component template type'); expect(help).toContain( ' --force Replace an existing generated component', ); diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index 07fb92c..59bf675 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -132,6 +132,11 @@ const sections: HelpSection[] = [ label: 'component eject-templates [type]', description: 'Write editable built-in templates', }, + { + kind: 'option', + label: '-a, --all', + description: 'Eject every component template type', + }, { kind: 'option', label: '-f, --force', diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index bed7352..4f189a8 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -77,6 +77,8 @@ declare module '@emulsify-cli/handlers' { }; export type EjectComponentTemplatesHandlerOptions = { + /** Eject templates for every supported component type. */ + all?: boolean; /** Replace existing selected component template overrides. */ force?: boolean; /** Preview component template destinations without writing files. */ diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 67b4732..8392a48 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -327,6 +327,18 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.match(result.stdout, /Compatibility alias for --force/u); }); + test('advertises all-types template ejection in detailed help', () => { + const result = runCli(tempRoot, ['component', 'eject-templates', '--help']); + + assert.equal( + result.status, + 0, + commandFailure('component eject-templates --help', result), + ); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /-a, --all/u); + }); + test('prints the package version', () => { const result = runCli(tempRoot, ['--version']); @@ -802,14 +814,52 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.notEqual(result.status, 0); assert.equal(result.stdout, ''); assert.match(result.stderr, /\[type\] positional argument/u); + assert.match(result.stderr, /--all/u); assert.deepEqual(snapshotFiles(templatesRoot), before); }); + test('ejects every component template type with --all', () => { + const templatesRoot = join(projectRoot, '.cli', 'templates'); + const result = runCli(projectRoot, [ + 'component', + 'eject-templates', + '--all', + ]); + + assert.equal( + result.status, + 0, + commandFailure('component eject-templates --all', result), + ); + assert.equal(result.stderr, ''); + assert.deepEqual( + Object.keys(snapshotFiles(templatesRoot)).sort(), + [ + join('react', 'component.jsx'), + join('react', 'component.scss'), + join('react', 'component.stories.jsx'), + join('twig', 'component.scss'), + join('twig', 'component.stories.js'), + join('twig', 'component.twig'), + join('twig', 'component.yml'), + join('twig-sdc', 'component.component.yml'), + join('twig-sdc', 'component.js'), + join('twig-sdc', 'component.scss'), + join('twig-sdc', 'component.stories.js'), + join('twig-sdc', 'component.twig'), + join('web-component', 'component.js'), + join('web-component', 'component.scss'), + join('web-component', 'component.stories.js'), + ].sort(), + ); + }); + test('uses a customized ejected template during component creation', () => { const ejectResult = runCli(projectRoot, [ 'component', 'eject-templates', 'twig', + '--force', ]); assert.equal( ejectResult.status, diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index b8ee447..a7c7de5 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -29,6 +29,7 @@ COMPONENTS --force Replace an existing generated component --dry-run Preview without writing files component eject-templates [type] Write editable built-in templates + -a, --all Eject every component template type -f, --force Replace existing template files --dry-run Preview paths and conflicts From 02dce9139450ab04b985d67ac0f723bd192823e3 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 09:24:20 -0500 Subject: [PATCH 16/33] fix(system): use explicit scaffold URL placeholders --- README.md | 3 +++ docs/cli-reference.md | 12 +++++++----- docs/systems.md | 6 +++--- src/handlers/systemCreate.test.ts | 29 +++++++++++++++++++++++++---- src/handlers/systemCreate.ts | 5 ++--- src/index.ts | 10 ++++++++-- src/lib/rootHelp.test.ts | 2 ++ src/lib/rootHelp.ts | 10 ++++++++++ test/e2e/cli.test.mjs | 8 ++++++++ test/e2e/root-help.txt | 2 ++ 10 files changed, 70 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9b18b1e..5d277f9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ emulsify system create "My System" --directory ./systems --platform "drupal || w This creates `./systems/my-system` with valid system and variant configuration, an installable `example-card` component, repository documentation, a `.gitignore`, and a license placeholder to replace before distribution. +Unless overridden, its required URL metadata uses obvious, schema-valid +`https://TODO.invalid/...` placeholders that must also be replaced before +publishing. When components installed from another system have evolved into the basis of your own, detach the configured system before authoring a replacement: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2b9d5c6..1aebcf3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -135,8 +135,8 @@ Options: | `-p, --platform ` | Variant target: `none`, a concrete platform, or a compound compatibility expression. | | `--git` | Initialize a Git repository in the generated system. | | `--no-git` | Generate the system without initializing Git. | -| `--homepage ` | Override the homepage metadata written to `system.emulsify.json`. | -| `--repository ` | Override the repository metadata written to `system.emulsify.json`. | +| `--homepage ` | Replace the generated `TODO.invalid` homepage metadata. | +| `--repository ` | Replace the generated `TODO.invalid` repository metadata. | | `-y, --yes` | Accept defaults for every missing prompt value. | In an interactive terminal, missing name, parent directory, platform expression, @@ -151,9 +151,11 @@ With `--yes`, missing values default to: - Platform expression: `none` - Git initialization: enabled -The generated homepage and repository metadata use placeholder example URLs -derived from the normalized name unless `--homepage` or `--repository` is -provided. Replace placeholders before publishing. +The generated homepage and repository metadata use the schema-valid reserved +placeholders `https://TODO.invalid/` and +`https://TODO.invalid/.git` unless `--homepage` or `--repository` is +provided. The `TODO.invalid` host is deliberately non-resolving and must be +replaced before publishing. The command refuses to overwrite an existing target. A successful scaffold contains: diff --git a/docs/systems.md b/docs/systems.md index b85e319..9d1a6ee 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -242,7 +242,7 @@ emulsify system create my-system \ --repository https://github.com/acme/my-system.git ``` -Without those overrides, the metadata defaults to `https://example.com/` and `https://github.com/example/.git`. Replace these placeholders before publishing. The generated `LICENSE` is also a placeholder; choose a license appropriate for the system before distribution. +Without those overrides, the metadata defaults to `https://TODO.invalid/` and `https://TODO.invalid/.git`. The reserved, non-resolving host keeps the scaffold schema-valid while making the unfinished metadata obvious. Replace these placeholders before publishing. The generated `LICENSE` is also a placeholder; choose a license appropriate for the system before distribution. Use `--no-git` instead of `--git` when another tool will initialize the repository. In non-interactive environments, supply the positional name, `--directory`, `--platform`, and either `--git` or `--no-git`, or use `--yes`. `--yes` supplies these defaults for anything omitted: @@ -278,8 +278,8 @@ When Git initialization is enabled, `.git/` is also created with `main` as the i ```json { "name": "my-system", - "homepage": "https://example.com/my-system", - "repository": "https://github.com/example/my-system.git", + "homepage": "https://TODO.invalid/my-system", + "repository": "https://TODO.invalid/my-system.git", "structure": [ { "name": "components", diff --git a/src/handlers/systemCreate.test.ts b/src/handlers/systemCreate.test.ts index 97bbe6d..ecc7a99 100644 --- a/src/handlers/systemCreate.test.ts +++ b/src/handlers/systemCreate.test.ts @@ -243,8 +243,8 @@ describe('systemCreate', () => { const target = join(resolve('/interactive-systems'), 'fancy-system'); const scaffold = expectedScaffold({ name: 'fancy-system', - homepage: 'https://example.com/fancy-system', - repository: 'https://github.com/example/fancy-system.git', + homepage: 'https://TODO.invalid/fancy-system', + repository: 'https://TODO.invalid/fancy-system.git', }); expect(validateSystemConfigMock).toHaveBeenCalledWith( scaffold.systemConfig, @@ -262,8 +262,8 @@ describe('systemCreate', () => { const scaffold = expectedScaffold({ name: 'custom-system', platform: 'none', - homepage: 'https://example.com/custom-system', - repository: 'https://github.com/example/custom-system.git', + homepage: 'https://TODO.invalid/custom-system', + repository: 'https://TODO.invalid/custom-system.git', }); await systemCreate(undefined, { yes: true }); @@ -282,6 +282,27 @@ describe('systemCreate', () => { }); }); + it('uses non-resolving TODO metadata when URL options are omitted', async () => { + const scaffold = expectedScaffold({ + homepage: 'https://TODO.invalid/acme-system', + repository: 'https://TODO.invalid/acme-system.git', + }); + + await systemCreate('acme-system', { + directory: parentDirectory, + platform: 'drupal || wordpress', + git: false, + }); + + expect(validateSystemConfigMock).toHaveBeenCalledWith( + scaffold.systemConfig, + ); + expect(writeToJsonFileMock).toHaveBeenCalledWith( + join(parentDirectory, 'acme-system', EMULSIFY_SYSTEM_CONFIG_FILE), + scaffold.systemConfig, + ); + }); + it.each<{ label: string; name: string | undefined; diff --git a/src/handlers/systemCreate.ts b/src/handlers/systemCreate.ts index cf0ad6b..455b518 100644 --- a/src/handlers/systemCreate.ts +++ b/src/handlers/systemCreate.ts @@ -175,9 +175,8 @@ export default async function systemCreate( const scaffold = buildSystemScaffold({ name: systemName, platform, - homepage: options.homepage || `https://example.com/${systemName}`, - repository: - options.repository || `https://github.com/example/${systemName}.git`, + homepage: options.homepage || `https://TODO.invalid/${systemName}`, + repository: options.repository || `https://TODO.invalid/${systemName}.git`, }); const validation = await validateSystemConfig(scaffold.systemConfig); if (!validation.valid) { diff --git a/src/index.ts b/src/index.ts index f461139..a250870 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,8 +79,14 @@ system ) .option('--git', 'Initialize a Git repository on branch main.') .option('--no-git', 'Do not initialize a Git repository.') - .option('--homepage ', 'Homepage URI for system.emulsify.json.') - .option('--repository ', 'Repository URI for system.emulsify.json.') + .option( + '--homepage ', + 'Homepage URI; overrides the generated TODO.invalid placeholder.', + ) + .option( + '--repository ', + 'Repository URI; overrides the generated TODO.invalid placeholder.', + ) .option( '-y, --yes', 'Accept defaults for all missing system scaffold values without prompting.', diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index 46f2ee6..a000d60 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -33,6 +33,8 @@ describe('getRootHelp', () => { expect(help).toContain('system detach'); expect(help).toContain('Detach the system and keep project components'); expect(help).toContain('system create [name]'); + expect(help).toContain('Replace TODO.invalid homepage metadata'); + expect(help).toContain('Replace TODO.invalid repository metadata'); expect(help).toContain('MAINTENANCE'); expect(help).toContain('audit [args...]'); expect(help).toContain('component eject-templates [type]'); diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index 59bf675..1ff058b 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -192,6 +192,16 @@ const sections: HelpSection[] = [ label: 'system create [name]', description: 'Scaffold a system others can install from', }, + { + kind: 'option', + label: '--homepage ', + description: 'Replace TODO.invalid homepage metadata', + }, + { + kind: 'option', + label: '--repository ', + description: 'Replace TODO.invalid repository metadata', + }, ], }, { diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 8392a48..c133348 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -466,6 +466,14 @@ describe('built Emulsify CLI', { concurrency: false }, () => { readFileSync(join(generatedSystemRoot, 'system.emulsify.json'), 'utf8'), ); assert.equal(systemConfig.name, systemName); + assert.equal( + systemConfig.homepage, + 'https://TODO.invalid/round-trip-system', + ); + assert.equal( + systemConfig.repository, + 'https://TODO.invalid/round-trip-system.git', + ); assert.equal(systemConfig.variants[0].platform, 'drupal || wordpress'); assert.deepEqual(systemConfig.variants[0].components, [ { diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index a7c7de5..a0f5877 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -42,6 +42,8 @@ SYSTEMS system detach Detach the system and keep project components -y, --yes Skip the confirmation prompt system create [name] Scaffold a system others can install from + --homepage Replace TODO.invalid homepage metadata + --repository Replace TODO.invalid repository metadata MAINTENANCE audit [args...] Run the Emulsify Core audit From b517868ab9feb3f10f84ec7db32a4891c4db8b8c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 10:39:32 -0500 Subject: [PATCH 17/33] fix(fs): write project config atomically --- src/util/fs/writeToJsonFile.test.ts | 93 +++++++++++++++++++++++------ src/util/fs/writeToJsonFile.ts | 35 +++++++++-- 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/util/fs/writeToJsonFile.test.ts b/src/util/fs/writeToJsonFile.test.ts index 79b2265..1fc98ff 100644 --- a/src/util/fs/writeToJsonFile.test.ts +++ b/src/util/fs/writeToJsonFile.test.ts @@ -1,31 +1,90 @@ import { promises as fs } from 'fs'; +import { basename, dirname } from 'path'; import writeToJsonFile from './writeToJsonFile.js'; -const writeFileMock = (fs.writeFile as jest.Mock).mockResolvedValue(true); +const writeFileMock = fs.writeFile as jest.Mock; +const renameMock = fs.rename as jest.Mock; +const rmMock = fs.rm as jest.Mock; describe('writeToJsonFile', () => { - it('can write given data to a json file', async () => { - expect.assertions(2); - await expect( - writeToJsonFile('path.json', { - key: 'value', - }), - ).resolves.toBe(undefined); + beforeEach(() => { + jest.clearAllMocks(); + writeFileMock.mockResolvedValue(undefined); + renameMock.mockResolvedValue(undefined); + rmMock.mockResolvedValue(undefined); + }); + + it('writes to a unique sibling temp file before renaming it over the target', async () => { + const path = '/project/project.emulsify.json'; + const json = { key: 'value' }; + + await expect(writeToJsonFile(path, json)).resolves.toBe(undefined); + + const temporaryPath = writeFileMock.mock.calls[0][0] as string; + + expect(dirname(temporaryPath)).toBe(dirname(path)); + expect(basename(temporaryPath)).toMatch( + /^\.project\.emulsify\.json\.\d+\.[0-9a-f-]+\.tmp$/, + ); expect(writeFileMock).toHaveBeenCalledWith( - 'path.json', - expect.any(String), - { encoding: 'utf-8' }, + temporaryPath, + JSON.stringify(json, null, 2), + { encoding: 'utf-8', flag: 'wx', flush: true }, + ); + expect(renameMock).toHaveBeenCalledWith(temporaryPath, path); + expect(writeFileMock.mock.invocationCallOrder[0]).toBeLessThan( + renameMock.mock.invocationCallOrder[0], ); + expect(rmMock).not.toHaveBeenCalled(); + }); + + it('leaves the original untouched and removes the temp after a failed write', async () => { + const path = '/project/project.emulsify.json'; + const json = { secret: 'must not appear in the error' }; + const writeError = new Error('ENOSPC: no space left on device'); + writeFileMock.mockRejectedValueOnce(writeError); + + await expect(writeToJsonFile(path, json)).rejects.toMatchObject({ + message: `Unable to write JSON file at ${path}: Error: ENOSPC: no space left on device.`, + cause: writeError, + }); + + const temporaryPath = writeFileMock.mock.calls[0][0] as string; + + expect(temporaryPath).not.toBe(path); + expect(writeFileMock).toHaveBeenCalledTimes(1); + expect(renameMock).not.toHaveBeenCalled(); + expect(rmMock).toHaveBeenCalledWith(temporaryPath, { force: true }); + }); + + it('removes the temp after a failed rename', async () => { + const path = '/project/project.emulsify.json'; + const renameError = new Error('EPERM: rename failed'); + renameMock.mockRejectedValueOnce(renameError); + + await expect(writeToJsonFile(path, {})).rejects.toMatchObject({ + message: `Unable to write JSON file at ${path}: Error: EPERM: rename failed.`, + cause: renameError, + }); + + const temporaryPath = writeFileMock.mock.calls[0][0] as string; + + expect(rmMock).toHaveBeenCalledWith(temporaryPath, { force: true }); }); - it('throws an error if the file cannot be written', async () => { - expect.assertions(1); - writeFileMock.mockImplementationOnce(() => { - throw new Error('Unable to write to a file that does not exist'); + it('reports a cleanup failure without exposing the serialized JSON', async () => { + const path = '/project/project.emulsify.json'; + const writeError = new Error('EIO: write failed'); + writeFileMock.mockRejectedValueOnce(writeError); + rmMock.mockRejectedValueOnce(new Error('EACCES: cleanup failed')); + + const result = writeToJsonFile(path, { + secret: 'must not appear in the error', }); - await expect(writeToJsonFile('path.json', {})).rejects.toEqual( - Error('Unable to write to path.json with the given JSON: {}'), + await expect(result).rejects.toThrow( + `Unable to write JSON file at ${path}: Error: EIO: write failed. Temporary-file cleanup also failed: Error: EACCES: cleanup failed`, ); + await expect(result).rejects.not.toThrow('must not appear in the error'); }); }); diff --git a/src/util/fs/writeToJsonFile.ts b/src/util/fs/writeToJsonFile.ts index fb42513..921395d 100644 --- a/src/util/fs/writeToJsonFile.ts +++ b/src/util/fs/writeToJsonFile.ts @@ -1,4 +1,13 @@ import { promises as fs } from 'fs'; +import { randomUUID } from 'crypto'; +import { basename, dirname, join } from 'path'; + +function createTemporaryPath(path: string): string { + return join( + dirname(path), + `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`, + ); +} /** * Takes an object and writes it to the specified file path as JSON. @@ -8,16 +17,32 @@ import { promises as fs } from 'fs'; * * @returns void, or throws an error if the write operation failed. */ -export default async function loadJsonFile( +export default async function writeToJsonFile( path: string, json: Json, ): Promise { - const data = JSON.stringify(json, null, 2); + const temporaryPath = createTemporaryPath(path); + try { - await fs.writeFile(path, data, { + await fs.writeFile(temporaryPath, JSON.stringify(json, null, 2), { encoding: 'utf-8', + flag: 'wx', + flush: true, }); - } catch (e) { - throw new Error(`Unable to write to ${path} with the given JSON: ${data}`); + await fs.rename(temporaryPath, path); + } catch (error) { + let cleanupMessage = ''; + + try { + await fs.rm(temporaryPath, { force: true }); + } catch (caughtCleanupError) { + const cleanupCause = String(caughtCleanupError); + cleanupMessage = ` Temporary-file cleanup also failed: ${cleanupCause}`; + } + + throw new Error( + `Unable to write JSON file at ${path}: ${String(error)}.${cleanupMessage}`, + { cause: error }, + ); } } From a7ba189b338e87637ebe1ffdaf553f130f8319db Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 10:42:26 -0500 Subject: [PATCH 18/33] fix(component): report or roll back partial template ejects --- jest.setup.cjs | 1 + src/handlers/componentEjectTemplates.test.ts | 194 ++++++++++++++-- src/handlers/componentEjectTemplates.ts | 229 +++++++++++++++++-- 3 files changed, 386 insertions(+), 38 deletions(-) diff --git a/jest.setup.cjs b/jest.setup.cjs index eda354d..98cecde 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -35,6 +35,7 @@ jest.mock('fs', () => ({ rm: jest.fn(), mkdir: jest.fn(), mkdtemp: jest.fn(), + link: jest.fn(), rename: jest.fn(), stat: jest.fn(), copyFile: jest.fn(), diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index bf84ed0..e2c5a8f 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -4,7 +4,7 @@ jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); import { checkbox } from '@inquirer/prompts'; import { promises as fs } from 'fs'; -import { dirname, join, resolve } from 'path'; +import { basename, dirname, join, resolve } from 'path'; import { pathExists } from 'fs-extra'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js'; @@ -21,8 +21,11 @@ import componentEjectTemplates, { const checkboxMock = checkbox as jest.Mock; const findFileMock = findFileInCurrentPath as jest.Mock; const logMock = log as jest.Mock; +const linkMock = fs.link as jest.Mock; const mkdirMock = fs.mkdir as jest.Mock; const pathExistsMock = pathExists as jest.Mock; +const renameMock = fs.rename as jest.Mock; +const rmMock = fs.rm as jest.Mock; const writeFileMock = fs.writeFile as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; @@ -41,9 +44,19 @@ function destination(type: ComponentType, logicalName: string): string { return join(templatesRoot, type, logicalName); } +function transactionPathPrefix( + target: string, + kind: 'temporary' | 'backup', +): string { + return join(dirname(target), `.${basename(target)}.emulsify-${kind}-`); +} + function expectNoWrites(): void { expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); + expect(linkMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalled(); } describe('buildComponentTemplateEjectionPlan', () => { @@ -81,6 +94,9 @@ describe('componentEjectTemplates', () => { findFileMock.mockReturnValue(projectConfigPath); pathExistsMock.mockResolvedValue(false); mkdirMock.mockResolvedValue(undefined); + linkMock.mockResolvedValue(undefined); + renameMock.mockResolvedValue(undefined); + rmMock.mockResolvedValue(undefined); writeFileMock.mockResolvedValue(undefined); }); @@ -102,11 +118,27 @@ describe('componentEjectTemplates', () => { expect(mkdirMock).toHaveBeenCalledWith(dirname(target), { recursive: true, }); - expect(writeFileMock).toHaveBeenCalledWith(target, artifact.contents, { - encoding: 'utf-8', - flag: 'wx', - }); + expect(writeFileMock).toHaveBeenCalledWith( + expect.stringContaining(transactionPathPrefix(target, 'temporary')), + artifact.contents, + { + encoding: 'utf-8', + flag: 'wx', + flush: true, + }, + ); + expect(linkMock).toHaveBeenCalledWith( + expect.stringContaining(transactionPathPrefix(target, 'temporary')), + target, + ); + expect(rmMock).toHaveBeenCalledWith( + expect.stringContaining(transactionPathPrefix(target, 'temporary')), + { force: true }, + ); } + expect(Math.max(...writeFileMock.mock.invocationCallOrder)).toBeLessThan( + Math.min(...linkMock.mock.invocationCallOrder), + ); expect(logMock).toHaveBeenNthCalledWith( 1, @@ -128,15 +160,26 @@ describe('componentEjectTemplates', () => { expect(mkdirMock).toHaveBeenCalledTimes(15); expect(writeFileMock).toHaveBeenCalledTimes(15); expect(writeFileMock).toHaveBeenCalledWith( - destination('twig', 'component.twig'), + expect.stringContaining( + transactionPathPrefix( + destination('twig', 'component.twig'), + 'temporary', + ), + ), expect.any(String), - { encoding: 'utf-8', flag: 'wx' }, + { encoding: 'utf-8', flag: 'wx', flush: true }, ); expect(writeFileMock).toHaveBeenCalledWith( - destination('web-component', 'component.stories.js'), + expect.stringContaining( + transactionPathPrefix( + destination('web-component', 'component.stories.js'), + 'temporary', + ), + ), expect.any(String), - { encoding: 'utf-8', flag: 'wx' }, + { encoding: 'utf-8', flag: 'wx', flush: true }, ); + expect(linkMock).toHaveBeenCalledTimes(15); }); it('rejects combining an explicit type with --all before project lookup', async () => { @@ -174,8 +217,8 @@ describe('componentEjectTemplates', () => { buildEjectableComponentTemplates('twig').length + buildEjectableComponentTemplates('web-component').length; expect(writeFileMock).toHaveBeenCalledTimes(expectedCount); - expect(writeFileMock.mock.calls[0][0]).toBe( - destination('twig', 'component.twig'), + expect(writeFileMock.mock.calls[0][0]).toContain( + transactionPathPrefix(destination('twig', 'component.twig'), 'temporary'), ); }); @@ -284,16 +327,29 @@ describe('componentEjectTemplates', () => { const artifacts = buildEjectableComponentTemplates('react'); expect(writeFileMock).toHaveBeenCalledTimes(artifacts.length); for (const artifact of artifacts) { + const target = destination('react', artifact.logicalName); expect(writeFileMock).toHaveBeenCalledWith( - destination('react', artifact.logicalName), + expect.stringContaining(transactionPathPrefix(target, 'temporary')), artifact.contents, - { encoding: 'utf-8', flag: 'w' }, + { encoding: 'utf-8', flag: 'wx', flush: true }, + ); + expect(linkMock).toHaveBeenCalledWith( + target, + expect.stringContaining(transactionPathPrefix(target, 'backup')), + ); + expect(renameMock).toHaveBeenCalledWith( + expect.stringContaining(transactionPathPrefix(target, 'temporary')), + target, + ); + expect(rmMock).toHaveBeenCalledWith( + expect.stringContaining(transactionPathPrefix(target, 'backup')), + { force: true }, ); } }); it('does not overwrite a template created after the conflict preflight', async () => { - writeFileMock.mockRejectedValueOnce( + linkMock.mockRejectedValueOnce( Object.assign(new Error('already exists'), { code: 'EEXIST' }), ); @@ -302,14 +358,112 @@ describe('componentEjectTemplates', () => { message: expect.stringMatching(/appeared.*not replaced.*--force/u), }); - expect(writeFileMock).toHaveBeenCalledWith( + expect(linkMock).toHaveBeenCalledWith( + expect.stringContaining( + transactionPathPrefix( + destination('twig', 'component.twig'), + 'temporary', + ), + ), destination('twig', 'component.twig'), - expect.any(String), - { encoding: 'utf-8', flag: 'wx' }, ); expect(logMock).not.toHaveBeenCalled(); }); + it('changes no destinations when staging fails after earlier templates were staged', async () => { + writeFileMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('disk full')); + + await expect(componentEjectTemplates('react')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching( + /Unable to stage.*component\.scss.*disk full.*No destination files were changed\./u, + ), + }); + + expect(writeFileMock).toHaveBeenCalledTimes(2); + expect(linkMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); + expect(rmMock).toHaveBeenCalledWith(writeFileMock.mock.calls[0][0], { + force: true, + }); + expect( + rmMock.mock.calls.every(([target]) => + String(target).includes('.emulsify-temporary-'), + ), + ).toBe(true); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('restores replaced files and removes new files after a mid-finalization failure', async () => { + const replacedTarget = destination('react', 'component.jsx'); + const newTarget = destination('react', 'component.scss'); + const failedTarget = destination('react', 'component.stories.jsx'); + linkMock.mockImplementation(async (source: string) => { + if (source === replacedTarget) return undefined; + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + }); + renameMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('rename failed')) + .mockResolvedValue(undefined); + + await expect( + componentEjectTemplates('react', { force: true }), + ).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching( + /Unable to install[\s\S]*component\.stories\.jsx[\s\S]*rename failed[\s\S]*All destination changes were rolled back\./u, + ), + }); + + const replacedBackup = linkMock.mock.calls.find( + ([source]) => source === replacedTarget, + )?.[1]; + expect(replacedBackup).toContain( + transactionPathPrefix(replacedTarget, 'backup'), + ); + expect(renameMock).toHaveBeenCalledWith(replacedBackup, replacedTarget); + expect(rmMock).toHaveBeenCalledWith(newTarget, { force: true }); + expect(renameMock).toHaveBeenCalledWith( + expect.stringContaining(transactionPathPrefix(failedTarget, 'temporary')), + failedTarget, + ); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('preserves and reports a backup when rollback cannot restore it', async () => { + const firstTarget = destination('react', 'component.jsx'); + const failedTarget = destination('react', 'component.scss'); + linkMock.mockResolvedValue(undefined); + renameMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('install failed')) + .mockRejectedValueOnce(new Error('restore failed')) + .mockResolvedValue(undefined); + + const error = await componentEjectTemplates('react', { + force: true, + }).catch((reason: unknown) => reason); + + const failedBackup = linkMock.mock.calls.find( + ([source]) => source === failedTarget, + )?.[1]; + expect(error).toMatchObject({ name: 'CliError' }); + expect((error as Error).message).toContain('Rollback was incomplete:'); + expect((error as Error).message).toContain( + `Could not restore "${failedTarget}"; its previous contents remain at "${failedBackup}": restore failed`, + ); + expect(renameMock).toHaveBeenCalledWith( + linkMock.mock.calls.find(([source]) => source === firstTarget)?.[1], + firstTarget, + ); + expect(rmMock).not.toHaveBeenCalledWith(failedBackup, { force: true }); + expect(logMock).not.toHaveBeenCalled(); + }); + it('previews creates and conflicts without writing or failing', async () => { const conflict = destination('twig', 'component.scss'); pathExistsMock.mockImplementation( @@ -352,9 +506,13 @@ describe('componentEjectTemplates', () => { await expect(componentEjectTemplates('react')).rejects.toMatchObject({ name: 'CliError', - message: `Unable to write component template "${destination('react', 'component.jsx')}": ${text}`, + message: expect.stringContaining( + `Unable to stage component template "${destination('react', 'component.jsx')}": ${text}. No destination files were changed.`, + ), }); + expect(linkMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); expect(logMock).not.toHaveBeenCalled(); }, ); diff --git a/src/handlers/componentEjectTemplates.ts b/src/handlers/componentEjectTemplates.ts index d4c7b4a..cc018b1 100644 --- a/src/handlers/componentEjectTemplates.ts +++ b/src/handlers/componentEjectTemplates.ts @@ -1,8 +1,9 @@ import type { EjectComponentTemplatesHandlerOptions } from '@emulsify-cli/handlers'; import { checkbox } from '@inquirer/prompts'; +import { randomUUID } from 'crypto'; import { promises as fs } from 'fs'; -import { dirname } from 'path'; +import { basename, dirname, join } from 'path'; import { pathExists } from 'fs-extra'; import CliError from '../lib/CliError.js'; @@ -64,6 +65,13 @@ type InspectedPlanItem = ComponentTemplateEjectionPlanItem & { exists: boolean; }; +type TransactionPlanItem = InspectedPlanItem & { + temporaryPath: string; + backupPath: string; + hasBackup: boolean; + installed: boolean; +}; + /** Resolve every ejection target within the project's template directory. */ export function buildComponentTemplateEjectionPlan( projectRoot: string, @@ -176,6 +184,205 @@ function isAlreadyExistsError(error: unknown): boolean { ); } +function isMissingError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ); +} + +function getTransactionPath( + destination: string, + kind: 'temporary' | 'backup', +): string { + return join( + dirname(destination), + `.${basename(destination)}.emulsify-${kind}-${randomUUID()}`, + ); +} + +function buildTransactionPlan( + plan: InspectedPlanItem[], +): TransactionPlanItem[] { + return plan.map((item) => ({ + ...item, + temporaryPath: getTransactionPath(item.destination, 'temporary'), + backupPath: getTransactionPath(item.destination, 'backup'), + hasBackup: false, + installed: false, + })); +} + +async function removePaths(paths: readonly string[]): Promise { + const failures: string[] = []; + + for (const path of paths) { + try { + await fs.rm(path, { force: true }); + } catch (error) { + failures.push(` - ${path}: ${getErrorMessage(error)}`); + } + } + + return failures; +} + +function appendCleanupFailures(message: string, failures: string[]): string { + if (failures.length === 0) return message; + + return [ + message, + 'Temporary transaction files could not be removed:', + ...failures, + ].join('\n'); +} + +async function stageTransaction(plan: TransactionPlanItem[]): Promise { + for (const item of plan) { + try { + await fs.mkdir(dirname(item.destination), { recursive: true }); + await fs.writeFile(item.temporaryPath, item.contents, { + encoding: 'utf-8', + flag: 'wx', + flush: true, + }); + } catch (error) { + const cleanupFailures = await removePaths( + plan.map(({ temporaryPath }) => temporaryPath), + ); + throw new CliError( + appendCleanupFailures( + `Unable to stage component template "${item.destination}": ${getErrorMessage(error)}. No destination files were changed.`, + cleanupFailures, + ), + ); + } + } +} + +async function installTransactionItem( + item: TransactionPlanItem, + force: boolean, +): Promise { + if (!force) { + // link() publishes the fully-written staged file atomically and refuses an + // existing destination, preserving the post-preflight EEXIST backstop. + await fs.link(item.temporaryPath, item.destination); + item.installed = true; + return; + } + + try { + // A hard-linked backup retains the old contents without creating a window + // where the destination is absent before the atomic replacement rename. + await fs.link(item.destination, item.backupPath); + item.hasBackup = true; + } catch (error) { + if (!isMissingError(error)) throw error; + } + + await fs.rename(item.temporaryPath, item.destination); + item.installed = true; +} + +async function rollbackTransaction( + plan: TransactionPlanItem[], +): Promise { + const failures: string[] = []; + + for (const item of [...plan].reverse()) { + if (item.hasBackup) { + try { + await fs.rename(item.backupPath, item.destination); + item.hasBackup = false; + item.installed = false; + } catch (error) { + failures.push( + ` - Could not restore "${item.destination}"; its previous contents remain at "${item.backupPath}": ${getErrorMessage(error)}`, + ); + } + } else if (item.installed) { + try { + await fs.rm(item.destination, { force: true }); + item.installed = false; + } catch (error) { + failures.push( + ` - Could not remove newly installed "${item.destination}": ${getErrorMessage(error)}`, + ); + } + } + } + + return failures; +} + +function formatInstallError( + item: TransactionPlanItem, + error: unknown, + force: boolean, + rollbackFailures: string[], + cleanupFailures: string[], +): string { + const failure = + !force && isAlreadyExistsError(error) + ? `Component template "${item.destination}" appeared after the overwrite check and was not replaced. Pass --force to replace existing templates.` + : `Unable to install component template "${item.destination}": ${getErrorMessage(error)}.`; + const rollback = + rollbackFailures.length === 0 + ? 'All destination changes were rolled back.' + : ['Rollback was incomplete:', ...rollbackFailures].join('\n'); + + return appendCleanupFailures([failure, rollback].join('\n'), cleanupFailures); +} + +async function executeTransaction( + plan: InspectedPlanItem[], + force: boolean, +): Promise { + const transactionPlan = buildTransactionPlan(plan); + await stageTransaction(transactionPlan); + + for (const item of transactionPlan) { + try { + await installTransactionItem(item, force); + } catch (error) { + const rollbackFailures = await rollbackTransaction(transactionPlan); + // Preserve any backup whose restoration failed: it is the only remaining + // copy of the user's prior override and its path is reported above. + const cleanupFailures = await removePaths( + transactionPlan.flatMap(({ temporaryPath, backupPath, hasBackup }) => + hasBackup ? [temporaryPath] : [temporaryPath, backupPath], + ), + ); + throw new CliError( + formatInstallError( + item, + error, + force, + rollbackFailures, + cleanupFailures, + ), + ); + } + } + + const cleanupFailures = await removePaths( + transactionPlan.flatMap(({ temporaryPath, backupPath, hasBackup }) => + hasBackup ? [temporaryPath, backupPath] : [temporaryPath], + ), + ); + if (cleanupFailures.length > 0) { + throw new CliError( + [ + 'Component templates were installed, but transaction cleanup was incomplete:', + ...cleanupFailures, + ].join('\n'), + ); + } +} + /** Handler for `emulsify component eject-templates [type]`. */ export default async function componentEjectTemplates( type: string | void, @@ -240,25 +447,7 @@ export default async function componentEjectTemplates( throw new CliError(formatConflictError(conflicts)); } - for (const item of inspectedPlan) { - try { - await fs.mkdir(dirname(item.destination), { recursive: true }); - await fs.writeFile(item.destination, item.contents, { - encoding: 'utf-8', - flag: force ? 'w' : 'wx', - }); - } catch (error) { - if (!force && isAlreadyExistsError(error)) { - throw new CliError( - `Component template "${item.destination}" appeared after the overwrite check and was not replaced. Pass --force to replace existing templates.`, - ); - } - - throw new CliError( - `Unable to write component template "${item.destination}": ${getErrorMessage(error)}`, - ); - } - } + await executeTransaction(inspectedPlan, force); const paths = inspectedPlan .map(({ destination }) => ` - ${destination}`) From 9b38b79b6cd241754c73e124b1c3dbc636308ebd Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 10:44:37 -0500 Subject: [PATCH 19/33] feat(system): add dry-run and exclusive-create guard to system create --- README.md | 4 + docs/cli-reference.md | 7 ++ docs/systems.md | 5 + src/handlers/systemCreate.test.ts | 149 +++++++++++++++++++++++++++++- src/handlers/systemCreate.ts | 138 +++++++++++++++++++++++---- src/index.ts | 4 + src/lib/rootHelp.test.ts | 1 + src/lib/rootHelp.ts | 5 + src/types/handlers.d.ts | 2 + test/e2e/cli.test.mjs | 55 +++++++++++ test/e2e/root-help.txt | 1 + 11 files changed, 351 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5d277f9..0bf1af1 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ Unless overridden, its required URL metadata uses obvious, schema-valid `https://TODO.invalid/...` placeholders that must also be replaced before publishing. +Add `--dry-run` to preview the normalized target, every generated file, and +whether Git would be initialized without changing the filesystem. + When components installed from another system have evolved into the basis of your own, detach the configured system before authoring a replacement: @@ -101,6 +104,7 @@ the CLI exits with an actionable error instead of waiting for input: ```bash emulsify init "My Theme" ./web/themes/custom --platform drupal --yes emulsify system create my-system --directory ./systems --platform none --git +emulsify system create my-system --directory ./systems --platform none --git --dry-run emulsify system install compound emulsify component install card --force # Or install every available component: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1aebcf3..0d938ff 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -138,6 +138,7 @@ Options: | `--homepage ` | Replace the generated `TODO.invalid` homepage metadata. | | `--repository ` | Replace the generated `TODO.invalid` repository metadata. | | `-y, --yes` | Accept defaults for every missing prompt value. | +| `--dry-run` | Preview the target, generated files, and Git action without changing the filesystem. | In an interactive terminal, missing name, parent directory, platform expression, and Git choice are prompted. In a non-interactive environment, provide them as @@ -178,6 +179,11 @@ The generated `example-card` is marked as required, so installing the system also proves that its component source layout is usable. `--git` additionally creates the `.git/` metadata directory with `main` as the initial branch. +Use `--dry-run` to inspect the normalized target, every generated file, and +whether Git would be initialized. If the target already exists, the preview +reports that a real run would refuse it; no directories, files, or Git metadata +are created. + Examples: ```bash @@ -185,6 +191,7 @@ emulsify system create emulsify system create "My System" --directory ./systems --platform none --git emulsify system create shared-system --directory ./systems --platform "drupal || wordpress" --no-git emulsify system create my-system --yes +emulsify system create my-system --directory ./systems --platform none --git --dry-run emulsify system create my-system --directory ./systems --platform drupal --git \ --homepage https://design.example.com/my-system \ --repository https://github.com/example/my-system.git diff --git a/docs/systems.md b/docs/systems.md index 9d1a6ee..bab5558 100644 --- a/docs/systems.md +++ b/docs/systems.md @@ -255,6 +255,10 @@ Use `--no-git` instead of `--git` when another tool will initialize the reposito The command never merges into or overwrites an existing target. If the normalized target directory already exists, choose another name or parent directory. +Pass `--dry-run` to preview the normalized target, generated files, and Git +initialization without creating directories or files. An occupied target is +reported as a condition that a real run would refuse. + ### Generated Repository Anatomy The scaffold has a valid `system.emulsify.json`, repository guidance, and one real component that can be installed immediately: @@ -320,6 +324,7 @@ Use `none` for a platform-neutral system, a concrete target such as `drupal` or emulsify system create generic-system --directory ./systems --platform none --git emulsify system create drupal-system --directory ./systems --platform drupal --git emulsify system create shared-system --directory ./systems --platform "drupal || wordpress" --git +emulsify system create preview-system --directory ./systems --platform none --git --dry-run ``` Quote expressions containing `||` so the shell passes the whole value to the CLI. The generated variant stores the normalized expression in `system.emulsify.json`; installation uses it when selecting a variant for the project's concrete platform. diff --git a/src/handlers/systemCreate.test.ts b/src/handlers/systemCreate.test.ts index ecc7a99..83e38fe 100644 --- a/src/handlers/systemCreate.test.ts +++ b/src/handlers/systemCreate.test.ts @@ -129,10 +129,13 @@ describe('systemCreate', () => { expect(validateSystemConfigMock).toHaveBeenCalledWith( scaffold.systemConfig, ); - expect(mkdirMock).toHaveBeenCalledTimes(scaffold.files.length + 1); - expect(mkdirMock).toHaveBeenNthCalledWith(1, target, { + expect(mkdirMock).toHaveBeenCalledTimes(scaffold.files.length + 2); + expect(mkdirMock).toHaveBeenNthCalledWith(1, dirname(target), { recursive: true, }); + expect(mkdirMock).toHaveBeenNthCalledWith(2, target, { + recursive: false, + }); expect(writeToJsonFileMock).toHaveBeenCalledTimes(1); expect(writeToJsonFileMock).toHaveBeenCalledWith( join(target, EMULSIFY_SYSTEM_CONFIG_FILE), @@ -147,6 +150,8 @@ describe('systemCreate', () => { }); expect(writeFileMock).toHaveBeenCalledWith(destination, contents, { encoding: 'utf-8', + flag: 'wx', + flush: true, }); } @@ -407,6 +412,105 @@ describe('systemCreate', () => { expectNoWrites(); }); + it.each([ + { + targetExists: false, + git: true, + targetState: 'would be created', + gitAction: 'would initialize a repository on branch main', + realRunAction: 'create the system scaffold', + }, + { + targetExists: true, + git: false, + targetState: 'exists', + gitAction: 'would not initialize a repository', + realRunAction: 'refuse because the target is already occupied', + }, + ])( + 'reports the complete plan without writes when target existence is $targetExists', + async ({ targetExists, git, targetState, gitAction, realRunAction }) => { + const target = join(parentDirectory, 'acme-system'); + const scaffold = expectedScaffold(); + existsSyncMock.mockReturnValueOnce(targetExists); + + await systemCreate('acme-system', { + ...explicitOptions, + git, + dryRun: true, + }); + + expect(validateSystemConfigMock).toHaveBeenCalledWith( + scaffold.systemConfig, + ); + expectNoWrites(); + expect(logMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + 'info', + expect.stringMatching( + new RegExp( + [ + 'Dry run: system create', + `Target: ${target} \\(${targetState}\\)`, + 'Platform: drupal \\|\\| wordpress', + `Git: ${gitAction}`, + `Real run would: ${realRunAction}`, + `Generated files:[\\s\\S]*${EMULSIFY_SYSTEM_CONFIG_FILE}`, + '[\\s\\S]*README\\.md', + 'No directories or files were written, and Git was not initialized\\.', + ].join('[\\s\\S]*'), + ), + ), + ); + }, + ); + + it('refuses a target that appears after the initial existence check', async () => { + const target = join(parentDirectory, 'acme-system'); + mkdirMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce( + Object.assign(new Error('exists'), { code: 'EEXIST' }), + ); + + await expect( + systemCreate('acme-system', explicitOptions), + ).rejects.toMatchObject({ + name: 'CliError', + message: `The system target became occupied during creation: ${target}. No existing files were replaced.`, + exitCode: 1, + }); + + expect(mkdirMock).toHaveBeenNthCalledWith(1, dirname(target), { + recursive: true, + }); + expect(mkdirMock).toHaveBeenNthCalledWith(2, target, { + recursive: false, + }); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(simpleGitMock).not.toHaveBeenCalled(); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('wraps a non-collision target reservation failure', async () => { + const target = join(parentDirectory, 'acme-system'); + mkdirMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('directory is read-only')); + + await expect( + systemCreate('acme-system', explicitOptions), + ).rejects.toMatchObject({ + name: 'CliError', + message: `Unable to create the system in ${target}: directory is read-only`, + exitCode: 1, + }); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(simpleGitMock).not.toHaveBeenCalled(); + expect(logMock).not.toHaveBeenCalled(); + }); + it.each([ { errors: [ @@ -443,7 +547,13 @@ describe('systemCreate', () => { it('wraps an artifact write failure and does not initialize Git or log success', async () => { const target = join(parentDirectory, 'acme-system'); - writeFileMock.mockRejectedValueOnce(new Error('disk full')); + writeFileMock.mockImplementation( + async (destination: string): Promise => { + if (destination.endsWith('README.md')) { + throw new Error('disk full'); + } + }, + ); await expect( systemCreate('acme-system', { ...explicitOptions, git: true }), @@ -453,7 +563,6 @@ describe('systemCreate', () => { exitCode: 1, }); - expect(writeToJsonFileMock).toHaveBeenCalledTimes(1); expect(writeFileMock).toHaveBeenCalled(); expect(simpleGitMock).not.toHaveBeenCalled(); expect(logMock).not.toHaveBeenCalled(); @@ -477,6 +586,38 @@ describe('systemCreate', () => { expect(logMock).not.toHaveBeenCalled(); }); + it('does not replace a scaffold file that appears during creation', async () => { + const target = join(parentDirectory, 'acme-system'); + const artifactPath = join(target, 'README.md'); + writeFileMock.mockImplementation( + async (destination: string): Promise => { + if (destination === artifactPath) { + throw Object.assign(new Error('already exists'), { code: 'EEXIST' }); + } + }, + ); + + await expect( + systemCreate('acme-system', explicitOptions), + ).rejects.toMatchObject({ + name: 'CliError', + message: `System scaffold file "${artifactPath}" appeared during creation and was not replaced. The target may contain a partial scaffold; remove it before retrying.`, + exitCode: 1, + }); + + expect(writeFileMock).toHaveBeenCalledWith( + artifactPath, + expect.any(String), + { + encoding: 'utf-8', + flag: 'wx', + flush: true, + }, + ); + expect(simpleGitMock).not.toHaveBeenCalled(); + expect(logMock).not.toHaveBeenCalled(); + }); + it('skips Git and only logs creation when --no-git is selected', async () => { const target = join(parentDirectory, 'acme-system'); diff --git a/src/handlers/systemCreate.ts b/src/handlers/systemCreate.ts index 455b518..e898cd2 100644 --- a/src/handlers/systemCreate.ts +++ b/src/handlers/systemCreate.ts @@ -83,6 +83,78 @@ function formatValidationErrors( ); } +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isAlreadyExistsError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'EEXIST' + ); +} + +type PlannedSystemFile = { + destination: string; + contents: string; +}; + +function logDryRun( + target: string, + targetExists: boolean, + platform: PlatformExpression, + initializeGit: boolean, + files: readonly string[], +): void { + const plannedFiles = files + .map((destination) => ` - ${destination}`) + .join('\n'); + const realRunAction = targetExists + ? 'refuse because the target is already occupied' + : 'create the system scaffold'; + const gitAction = initializeGit + ? 'would initialize a repository on branch main' + : 'would not initialize a repository'; + + log( + 'info', + [ + 'Dry run: system create', + `Target: ${target} (${targetExists ? 'exists' : 'would be created'})`, + `Platform: ${platform}`, + `Git: ${gitAction}`, + `Real run would: ${realRunAction}`, + 'Generated files:', + plannedFiles, + 'No directories or files were written, and Git was not initialized.', + ].join('\n'), + ); +} + +async function writeExclusiveSystemFile({ + destination, + contents, +}: PlannedSystemFile): Promise { + try { + await fs.mkdir(dirname(destination), { recursive: true }); + await fs.writeFile(destination, contents, { + encoding: 'utf-8', + flag: 'wx', + flush: true, + }); + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new CliError( + `System scaffold file "${destination}" appeared during creation and was not replaced. The target may contain a partial scaffold; remove it before retrying.`, + ); + } + + throw error; + } +} + /** * Handler for `emulsify system create [name]`. */ @@ -91,6 +163,7 @@ export default async function systemCreate( options: CreateSystemHandlerOptions = {}, ): Promise { const acceptDefaults = options.yes === true; + const dryRun = options.dryRun === true; let requestedName = name?.trim(); if (!requestedName) { @@ -166,7 +239,8 @@ export default async function systemCreate( } const target = join(resolve(targetParent), systemName); - if (existsSync(target)) { + const targetExists = existsSync(target); + if (targetExists && !dryRun) { throw new CliError( `The system target is already occupied: ${target}. Choose another parent directory with --directory.`, ); @@ -185,30 +259,62 @@ export default async function systemCreate( ); } + const systemConfigDestination = safeResolveWithin( + target, + EMULSIFY_SYSTEM_CONFIG_FILE, + 'System scaffold file', + ); + const plannedArtifactFiles = scaffold.files.map(({ path, contents }) => ({ + destination: safeResolveWithin(target, path, 'System scaffold file'), + contents, + })); + const plannedDestinations = [ + systemConfigDestination, + ...plannedArtifactFiles.map(({ destination }) => destination), + ]; + + if (dryRun) { + logDryRun( + target, + targetExists, + platform, + initializeGit, + plannedDestinations, + ); + return; + } + try { - await fs.mkdir(target, { recursive: true }); - await Promise.all([ - writeToJsonFile( - join(target, EMULSIFY_SYSTEM_CONFIG_FILE), - scaffold.systemConfig, - ), - ...scaffold.files.map(async ({ path, contents }) => { - const destination = safeResolveWithin( - target, - path, - 'System scaffold file', + await fs.mkdir(dirname(target), { recursive: true }); + try { + // A non-recursive mkdir fails with EEXIST if the target appears after + // the initial check, closing the check-then-create race. + await fs.mkdir(target, { recursive: false }); + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new CliError( + `The system target became occupied during creation: ${target}. No existing files were replaced.`, ); - await fs.mkdir(dirname(destination), { recursive: true }); - await fs.writeFile(destination, contents, { encoding: 'utf-8' }); - }), + } + + throw error; + } + + await Promise.all([ + writeToJsonFile(systemConfigDestination, scaffold.systemConfig), + ...plannedArtifactFiles.map(writeExclusiveSystemFile), ]); if (initializeGit) { await simpleGit(target).init(false, { '--initial-branch': 'main' }); } } catch (error) { + if (error instanceof CliError) { + throw error; + } + throw new CliError( - `Unable to create the system in ${target}: ${error instanceof Error ? error.message : String(error)}`, + `Unable to create the system in ${target}: ${getErrorMessage(error)}`, ); } diff --git a/src/index.ts b/src/index.ts index a250870..c9ce27e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,10 @@ system '-y, --yes', 'Accept defaults for all missing system scaffold values without prompting.', ) + .option( + '--dry-run', + 'Preview the system scaffold without creating files or initializing Git.', + ) .action(systemCreate); system .command('install [name]') diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index a000d60..aaa96e7 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -35,6 +35,7 @@ describe('getRootHelp', () => { expect(help).toContain('system create [name]'); expect(help).toContain('Replace TODO.invalid homepage metadata'); expect(help).toContain('Replace TODO.invalid repository metadata'); + expect(help).toContain('Preview files and Git setup'); expect(help).toContain('MAINTENANCE'); expect(help).toContain('audit [args...]'); expect(help).toContain('component eject-templates [type]'); diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index 1ff058b..9a9fea8 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -202,6 +202,11 @@ const sections: HelpSection[] = [ label: '--repository ', description: 'Replace TODO.invalid repository metadata', }, + { + kind: 'option', + label: '--dry-run', + description: 'Preview files and Git setup', + }, ], }, { diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 4f189a8..04028b5 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -39,6 +39,8 @@ declare module '@emulsify-cli/handlers' { repository?: string | void; /** Accept defaults for all missing system scaffold values. */ yes?: boolean; + /** Preview the system scaffold without creating files or initializing Git. */ + dryRun?: boolean; }; export type DetachSystemHandlerOptions = { diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index c133348..83fda9e 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -339,6 +339,22 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.match(result.stdout, /-a, --all/u); }); + test('advertises system scaffold dry runs in detailed help', () => { + const result = runCli(tempRoot, ['system', 'create', '--help']); + + assert.equal( + result.status, + 0, + commandFailure('system create --help', result), + ); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /--dry-run/u); + assert.match( + result.stdout, + /Preview the system scaffold without\s+creating files or initializing Git/u, + ); + }); + test('prints the package version', () => { const result = runCli(tempRoot, ['--version']); @@ -416,6 +432,45 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(existsSync(join(tempRoot, 'custom-system')), false); }); + test('previews a complete system scaffold without writing files or Git metadata', () => { + const systemName = 'dry-run-system'; + const target = join(tempRoot, systemName); + const result = runCli(tempRoot, [ + 'system', + 'create', + systemName, + '--directory', + tempRoot, + '--platform', + 'none', + '--git', + '--dry-run', + ]); + + assert.equal( + result.status, + 0, + commandFailure('system create --dry-run', result), + ); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /Dry run: system create/u); + assert.match(result.stdout, /dry-run-system \(would be created\)/u); + assert.match( + result.stdout, + /Git: would initialize a repository on branch main/u, + ); + assert.match(result.stdout, /system\.emulsify\.json/u); + assert.match( + result.stdout, + /components[/\\]example-card[/\\]example-card\.twig/u, + ); + assert.match( + result.stdout, + /No directories or files were written, and Git was not initialized\./u, + ); + assert.equal(existsSync(target), false); + }); + test('fails fast without changing the project when system install has no source outside a TTY', () => { const projectConfigPath = join( nonInteractiveInstallProjectRoot, diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index a0f5877..6feaad9 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -44,6 +44,7 @@ SYSTEMS system create [name] Scaffold a system others can install from --homepage Replace TODO.invalid homepage metadata --repository Replace TODO.invalid repository metadata + --dry-run Preview files and Git setup MAINTENANCE audit [args...] Run the Emulsify Core audit From 9711c5f4c8734ee545ea4c6ab0a3f2f781364851 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 11:09:41 -0500 Subject: [PATCH 20/33] fix(system): bound the remote tag lookup --- src/util/cache/cloneIntoCache.test.ts | 10 +-- src/util/cache/cloneIntoCache.ts | 6 +- .../getNonInteractiveGitEnvironment.test.ts | 23 ++++++ src/util/getNonInteractiveGitEnvironment.ts | 9 +++ src/util/getRepositoryLatestTag.test.ts | 71 +++++++++++++++++-- src/util/getRepositoryLatestTag.ts | 28 +++++++- 6 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 src/util/getNonInteractiveGitEnvironment.test.ts create mode 100644 src/util/getNonInteractiveGitEnvironment.ts diff --git a/src/util/cache/cloneIntoCache.test.ts b/src/util/cache/cloneIntoCache.test.ts index 653f134..d425591 100644 --- a/src/util/cache/cloneIntoCache.test.ts +++ b/src/util/cache/cloneIntoCache.test.ts @@ -307,7 +307,7 @@ describe('cloneIntoCache', () => { expect(rmMock).not.toHaveBeenCalled(); }); - it('bounds remote lookups and disables terminal prompts', async () => { + it('bounds remote lookups and disables Git and SSH prompts', async () => { existsSyncMock.mockReturnValueOnce(true); readFileMock.mockResolvedValueOnce(metadata()); listRemoteMock.mockResolvedValueOnce( @@ -325,9 +325,11 @@ describe('cloneIntoCache', () => { stdErr: false, }, }); - expect(envMock).toHaveBeenCalledWith( - expect.objectContaining({ GIT_TERMINAL_PROMPT: '0' }), - ); + expect(envMock).toHaveBeenCalledWith({ + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes', + }); }); it('reuses a valid default-branch entry when remote HEAD matches', async () => { diff --git a/src/util/cache/cloneIntoCache.ts b/src/util/cache/cloneIntoCache.ts index b97b1c3..e81b9b0 100644 --- a/src/util/cache/cloneIntoCache.ts +++ b/src/util/cache/cloneIntoCache.ts @@ -8,6 +8,7 @@ import { EMULSIFY_CACHE_METADATA_FILE } from '../../lib/constants.js'; import getCachedItemPath from './getCachedItemPath.js'; import normalizeRepositoryUrl from './normalizeRepositoryUrl.js'; +import getNonInteractiveGitEnvironment from '../getNonInteractiveGitEnvironment.js'; type CacheMetadata = { repository: string; @@ -161,10 +162,7 @@ async function getRemoteResolvedRef( stdOut: false, stdErr: false, }, - }).env({ - ...process.env, - GIT_TERMINAL_PROMPT: '0', - }); + }).env(getNonInteractiveGitEnvironment()); const output = await git.listRemote([repository, ...remoteRefs]); const resolvedRefs = new Map(); diff --git a/src/util/getNonInteractiveGitEnvironment.test.ts b/src/util/getNonInteractiveGitEnvironment.test.ts new file mode 100644 index 0000000..77af229 --- /dev/null +++ b/src/util/getNonInteractiveGitEnvironment.test.ts @@ -0,0 +1,23 @@ +import getNonInteractiveGitEnvironment from './getNonInteractiveGitEnvironment.js'; + +describe('getNonInteractiveGitEnvironment', () => { + it('disables Git and SSH prompts by default', () => { + expect(getNonInteractiveGitEnvironment({ EXISTING: 'value' })).toEqual({ + EXISTING: 'value', + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: 'ssh -oBatchMode=yes', + }); + }); + + it('preserves an explicitly configured SSH command', () => { + expect( + getNonInteractiveGitEnvironment({ + GIT_TERMINAL_PROMPT: '1', + GIT_SSH_COMMAND: 'custom-ssh --identity custom-key', + }), + ).toEqual({ + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: 'custom-ssh --identity custom-key', + }); + }); +}); diff --git a/src/util/getNonInteractiveGitEnvironment.ts b/src/util/getNonInteractiveGitEnvironment.ts new file mode 100644 index 0000000..40587a3 --- /dev/null +++ b/src/util/getNonInteractiveGitEnvironment.ts @@ -0,0 +1,9 @@ +export default function getNonInteractiveGitEnvironment( + environment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { + ...environment, + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: environment.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes', + }; +} diff --git a/src/util/getRepositoryLatestTag.test.ts b/src/util/getRepositoryLatestTag.test.ts index 62c7337..e7322da 100644 --- a/src/util/getRepositoryLatestTag.test.ts +++ b/src/util/getRepositoryLatestTag.test.ts @@ -28,7 +28,7 @@ function mockLsRemoteSuccess(output: string): void { ( _command: string, _args: string[], - _options: { encoding: string }, + _options: object, callback: ExecFileCallback, ) => { callback(null, output, ''); @@ -41,7 +41,7 @@ function mockLsRemoteFailure(stderr: string): void { ( _command: string, _args: string[], - _options: { encoding: string }, + _options: object, callback: ExecFileCallback, ) => { callback(new Error('Command failed'), '', stderr); @@ -49,6 +49,26 @@ function mockLsRemoteFailure(stderr: string): void { ); } +function mockLsRemoteTimeout(): void { + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: object, + callback: ExecFileCallback, + ) => { + callback( + Object.assign(new Error('Command failed'), { + killed: true, + signal: 'SIGTERM', + }), + '', + '', + ); + }, + ); +} + describe('getRepositoryLatestTag', () => { beforeEach(() => { jest.clearAllMocks(); @@ -63,8 +83,16 @@ describe('getRepositoryLatestTag', () => { expect(latest).toBe('v1.5.0'); expect(execFileMock).toHaveBeenCalledWith( 'git', - ['ls-remote', '--tags', '--refs', repoUrl], - { encoding: 'utf8' }, + ['ls-remote', '--tags', '--refs', '--', repoUrl], + { + encoding: 'utf8', + timeout: 10_000, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes', + }, + }, expect.any(Function), ); }); @@ -230,6 +258,41 @@ describe('getRepositoryLatestTag', () => { ); }); + it('throws a bounded timeout error with an explicit checkout escape hatch', async () => { + expect.assertions(1); + + mockLsRemoteTimeout(); + + await expect(getRepositoryLatestTag(repoUrl)).rejects.toThrow( + `Unable to read tags from repository ${repoUrl}: The lookup timed out after 10 seconds. Retry with --repository "${repoUrl}" --checkout to skip automatic tag lookup.`, + ); + }); + + it('does not misclassify another child-process signal as a timeout', async () => { + expect.assertions(1); + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: object, + callback: ExecFileCallback, + ) => { + callback( + Object.assign(new Error('Command was killed'), { + killed: true, + signal: 'SIGKILL', + }), + '', + '', + ); + }, + ); + + await expect(getRepositoryLatestTag(repoUrl)).rejects.toThrow( + `Unable to read tags from repository ${repoUrl}: Command was killed`, + ); + }); + it('does not use simple-git current-directory mutations', async () => { expect.assertions(6); const gitMock = { diff --git a/src/util/getRepositoryLatestTag.ts b/src/util/getRepositoryLatestTag.ts index 0e81dc7..f99220b 100644 --- a/src/util/getRepositoryLatestTag.ts +++ b/src/util/getRepositoryLatestTag.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process'; +import getNonInteractiveGitEnvironment from './getNonInteractiveGitEnvironment.js'; type ParsedTag = { tag: string; @@ -11,14 +12,31 @@ type ParsedTag = { const tagPattern = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; +const REMOTE_TAG_LOOKUP_TIMEOUT_MS = 10_000; + +class RemoteTagLookupTimeoutError extends Error {} + function getRemoteTagRefs(repoUrl: string): Promise { return new Promise((resolve, reject) => { execFile( 'git', - ['ls-remote', '--tags', '--refs', repoUrl], - { encoding: 'utf8' }, + ['ls-remote', '--tags', '--refs', '--', repoUrl], + { + encoding: 'utf8', + timeout: REMOTE_TAG_LOOKUP_TIMEOUT_MS, + env: getNonInteractiveGitEnvironment(), + }, (error, stdout, stderr) => { if (error) { + if (error.killed && error.signal === 'SIGTERM') { + reject( + new RemoteTagLookupTimeoutError( + `The lookup timed out after ${REMOTE_TAG_LOOKUP_TIMEOUT_MS / 1_000} seconds.`, + ), + ); + return; + } + reject(new Error(stderr.trim() || error.message)); return; } @@ -122,8 +140,12 @@ const getRepositoryLatestTag = async (repoUrl: string): Promise => { refs = await getRemoteTagRefs(repoUrl); } catch (error) { const message = error instanceof Error ? error.message : String(error); + const checkoutSuggestion = + error instanceof RemoteTagLookupTimeoutError + ? ` Retry with --repository "${repoUrl}" --checkout to skip automatic tag lookup.` + : ''; throw new Error( - `Unable to read tags from repository ${repoUrl}: ${message}`, + `Unable to read tags from repository ${repoUrl}: ${message}${checkoutSuggestion}`, ); } From 9eb9c783f495e4d25ef5e54fe0b5e448b2cebbbf Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 11:10:25 -0500 Subject: [PATCH 21/33] feat(component): allow an explicit custom element tag name --- README.md | 5 +- docs/cli-reference.md | 8 ++- docs/components.md | 15 +++-- src/handlers/componentCreate.test.ts | 3 +- src/index.ts | 4 ++ src/lib/rootHelp.test.ts | 4 ++ src/lib/rootHelp.ts | 5 ++ src/types/handlers.d.ts | 2 + src/util/project/generateComponent.test.ts | 70 +++++++++++++++++++++- src/util/project/generateComponent.ts | 54 +++++++++++------ test/e2e/cli.test.mjs | 35 +++++++++++ test/e2e/root-help.txt | 1 + 12 files changed, 181 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0bf1af1..60b37e8 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ emulsify component install card --force # Or install every available component: emulsify component install --all emulsify component create promo-card --directory molecules --type twig --force +emulsify component create card --directory molecules --type web-component --tag-name acme-card emulsify component eject-templates --all emulsify system detach --yes ``` @@ -122,7 +123,9 @@ creation, provide the positional name plus `--type` and `--directory`, and use `--type` values are honored even when project detection would hide that choice from the wizard. The deprecated `--format default` and `--format sdc` forms remain available as aliases for `--type twig` and `--type twig-sdc`, -respectively, and print a deprecation warning. +respectively, and print a deprecation warning. Web Components derive their tag +name from the component and project names; pass `--tag-name` to override it, +including when the derived value would be invalid in a non-interactive run. For template ejection, provide the component type or `--all` outside a TTY; use `--dry-run` to preview paths and `--force` only when existing customizations should be replaced. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0d938ff..418a19c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -467,6 +467,7 @@ Options: | --------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `-d, --directory ` | Variant structure name where the component should be created. | | `-t, --type ` | Component renderer and packaging type. | +| `--tag-name ` | Explicit custom element tag for `--type web-component`. | | `-f, --format ` | Deprecated alias: `default` maps to `twig`; `sdc` maps to `twig-sdc`. Prints a deprecation warning. | | `--force` | Replace an existing generated component without prompting. | | `-y, --yes` | Compatibility alias for `--force`. | @@ -480,6 +481,7 @@ emulsify component create promo-card --directory molecules --type twig emulsify component create teaser --directory molecules --type twig-sdc emulsify component create promo-card --directory molecules --type react emulsify component create promo-card --directory molecules --type web-component +emulsify component create card --directory molecules --type web-component --tag-name acme-card emulsify component create promo-card --directory molecules --type twig --dry-run ``` @@ -488,7 +490,11 @@ use Emulsify Core's `renderWebComponent` helper. For Web Components, a hyphenated component filename is also the custom element tag. A single-word filename is prefixed with the project's machine name, so `card` in `acme-theme` becomes ``. The wizard confirms this tag and allows an -override; non-interactive creation derives and validates it silently. +override. Pass `--tag-name ` to set it explicitly, including when a +project machine name cannot produce a valid derived tag in a non-interactive +environment. Explicit and derived values follow the same browser-compatible +validation rules. `--tag-name` is rejected for types other than +`web-component`. When standard input is not a TTY, provide the positional `[name]` plus both `--directory` and `--type`; otherwise the command exits with an actionable diff --git a/docs/components.md b/docs/components.md index f728ea7..d6d7c62 100644 --- a/docs/components.md +++ b/docs/components.md @@ -219,10 +219,17 @@ filename already contains one, that filename becomes the tag name. For example, For a single-word component, the CLI prefixes the filename with the project's machine name. In a project whose machine name is `acme-theme`, `card` generates ``. The interactive wizard confirms the derived tag name and -lets you override it. In non-interactive mode, the CLI derives the value -silently and validates it before writing files; an invalid tag fails with an -actionable error rather than generating a custom element the browser would -reject. +lets you override it. Pass `--tag-name ` to set the tag explicitly: + +```bash +emulsify component create card --directory base --type web-component --tag-name acme-card +``` + +In non-interactive mode, the CLI otherwise derives the value silently and +validates it before writing files. Use `--tag-name` when a project machine name +cannot produce a valid derived tag. Explicit and derived tags follow the same +browser-compatible validation rules, and `--tag-name` is rejected for types +other than `web-component`. ## Create Dry Runs diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index 1ca02e7..a81487b 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -301,7 +301,8 @@ describe('componentCreate', () => { it('forwards project configuration and all generator options using the new signature', async () => { const options = { directory: 'base', - type: 'react', + type: 'web-component', + tagName: 'custom-button', yes: true, dryRun: true, refresh: true, diff --git a/src/index.ts b/src/index.ts index c9ce27e..a66d481 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,6 +169,10 @@ component '-t, --type ', 'Component implementation type to generate.', ) + .option( + '--tag-name ', + 'Custom element tag name for a Web Component.', + ) .option( '-f, --format ', 'Deprecated alias: default maps to twig and sdc maps to twig-sdc.', diff --git a/src/lib/rootHelp.test.ts b/src/lib/rootHelp.test.ts index aaa96e7..fd10412 100644 --- a/src/lib/rootHelp.test.ts +++ b/src/lib/rootHelp.test.ts @@ -44,6 +44,7 @@ describe('getRootHelp', () => { expect(help).toContain( ' --force Replace an existing generated component', ); + expect(help).toContain('Set the Web Component custom element tag'); expect(help).toContain('--refresh works on list, install, and create.'); expect(help).not.toContain('component ls'); expect( @@ -70,6 +71,9 @@ describe('getRootHelp', () => { expect(help).toContain( ' -f, --format \n Deprecated Twig type alias', ); + expect(help).toContain( + ' --tag-name \n Set the Web Component custom element tag', + ); expect(help).toContain( ' component eject-templates [type]\n Write editable built-in templates', ); diff --git a/src/lib/rootHelp.ts b/src/lib/rootHelp.ts index 9a9fea8..e9287a6 100644 --- a/src/lib/rootHelp.ts +++ b/src/lib/rootHelp.ts @@ -112,6 +112,11 @@ const sections: HelpSection[] = [ label: '-t, --type ', description: 'twig | twig-sdc | react | web-component', }, + { + kind: 'option', + label: '--tag-name ', + description: 'Set the Web Component custom element tag', + }, { kind: 'option', label: '-f, --format ', diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index 04028b5..daa7169 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -66,6 +66,8 @@ declare module '@emulsify-cli/handlers' { directory?: string; /** Component implementation type to generate. */ type?: string; + /** Explicit custom element tag name for a Web Component. */ + tagName?: string; /** Deprecated component format alias. "default" maps to "twig" and "sdc" maps to "twig-sdc". */ format?: string; /** Replace an existing component without prompting. */ diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index d10a582..cbe5788 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -746,6 +746,74 @@ describe('generateComponent', () => { ); }); + it('uses an explicit valid tag outside a TTY when the derived tag would be invalid', async () => { + expect.assertions(3); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(false); + const invalidMachineNameConfig: EmulsifyProjectConfiguration = { + ...projectConfig, + project: { ...projectConfig.project, machineName: '123theme' }, + }; + + await generateComponent(variant, invalidMachineNameConfig, 'button', { + directory: 'base', + type: 'web-component', + tagName: ' valid-button ', + }); + + expect(inputMock).not.toHaveBeenCalled(); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('button', 'button.js'), + expect.stringContaining( + "customElements.define('valid-button', ButtonElement);", + ), + ); + expect(writeFileMock).toHaveBeenCalledWith( + componentPath('button', 'button.stories.js'), + expect.stringContaining("render: renderWebComponent('valid-button')"), + ); + }); + + it('rejects an invalid explicit custom element tag without prompting or writing', async () => { + expect.assertions(3); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(false); + + await expect( + generateComponent(variant, projectConfig, 'button', { + directory: 'base', + type: 'web-component', + tagName: 'button', + }), + ).rejects.toThrow( + 'Invalid custom element tag name "button". Names must start with an ASCII lowercase letter', + ); + + expect(inputMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it.each(['twig', 'twig-sdc', 'react'] as const)( + 'rejects --tag-name for the %s component type', + async (type) => { + expect.assertions(2); + setStdinIsTTY(false); + pathExistsMock.mockResolvedValue(false); + + await expect( + generateComponent(variant, projectConfig, 'button', { + directory: 'base', + type, + tagName: 'custom-button', + }), + ).rejects.toThrow( + 'The --tag-name option can only be used with --type web-component.', + ); + + expect(writeFileMock).not.toHaveBeenCalled(); + }, + ); + it('rejects an invalid derived tag outside a TTY without writing files', async () => { expect.assertions(3); setStdinIsTTY(false); @@ -760,7 +828,7 @@ describe('generateComponent', () => { type: 'web-component', }), ).rejects.toThrow( - 'Invalid custom element tag name "123theme-button". Names must start with an ASCII lowercase letter', + 'Invalid custom element tag name "123theme-button". Names must start with an ASCII lowercase letter, contain a hyphen, use browser-supported custom-element name characters, and must not be a reserved name. Pass --tag-name to provide a valid custom element name.', ); expect(inputMock).not.toHaveBeenCalled(); diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index cb4dc08..f9450a9 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -19,7 +19,7 @@ import { assertValidCustomElementTagName, deriveCustomElementTagName, } from '../deriveCustomElementTagName.js'; -import { runPrompt } from '../prompt/index.js'; +import { isInteractiveTerminal, runPrompt } from '../prompt/index.js'; import { componentTypeFromLegacyFormat, getAvailableComponentTypes, @@ -125,6 +125,7 @@ function validateCustomElementTagName(value: string): true | string { * @param options commander options object. * @param options.directory string name of the directory where the component should be created. * @param options.type canonical component type to generate. + * @param options.tagName explicit custom element tag name for a Web Component. * @param options.format deprecated component format alias. "default" maps to "twig" and "sdc" maps to "twig-sdc". * @param options.force whether to replace existing components without prompting. * @param options.yes compatibility alias for options.force. @@ -170,6 +171,12 @@ export default async function generateComponent( nonInteractive: { error: MISSING_COMPONENT_TYPE_ERROR }, }); + if (options.tagName !== undefined && type !== 'web-component') { + throw new Error( + 'The --tag-name option can only be used with --type web-component.', + ); + } + if ( providedType && (type === 'react' || type === 'web-component') && @@ -183,22 +190,35 @@ export default async function generateComponent( let tagName = ''; if (type === 'web-component') { - const derivedTagName = deriveCustomElementTagName( - filename, - projectConfig.project.machineName, - ); - tagName = ( - await runPrompt({ - prompt: () => - input({ - message: cyan('Custom element tag name:'), - default: derivedTagName, - validate: validateCustomElementTagName, - }), - nonInteractive: { value: derivedTagName }, - }) - ).trim(); - assertValidCustomElementTagName(tagName); + if (options.tagName !== undefined) { + tagName = options.tagName.trim(); + } else { + const derivedTagName = deriveCustomElementTagName( + filename, + projectConfig.project.machineName, + ); + tagName = ( + await runPrompt({ + prompt: () => + input({ + message: cyan('Custom element tag name:'), + default: derivedTagName, + validate: validateCustomElementTagName, + }), + nonInteractive: { value: derivedTagName }, + }) + ).trim(); + } + try { + assertValidCustomElementTagName(tagName); + } catch (error) { + if (options.tagName === undefined && !isInteractiveTerminal()) { + throw new SyntaxError( + `${(error as Error).message} Pass --tag-name to provide a valid custom element name.`, + ); + } + throw error; + } } // Choose the component's parent structure within the given variant configuration. diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 83fda9e..ce5de53 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -325,6 +325,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.match(result.stdout, /--force/u); assert.match(result.stdout, /-y, --yes/u); assert.match(result.stdout, /Compatibility alias for --force/u); + assert.match(result.stdout, /--tag-name /u); }); test('advertises all-types template ejection in detailed help', () => { @@ -1034,6 +1035,40 @@ describe('built Emulsify CLI', { concurrency: false }, () => { } }); + test('uses an explicit custom element tag name non-interactively', () => { + const result = runCli(projectRoot, [ + 'component', + 'create', + 'tag-override-example', + '--type', + 'web-component', + '--tag-name', + 'custom-tag-override', + '--directory', + 'components', + ]); + + assert.equal( + result.status, + 0, + commandFailure('component create --tag-name', result), + ); + assert.match(result.stderr, /@emulsify\/core was not detected/u); + const componentSource = readFileSync( + join( + projectRoot, + 'components', + 'tag-override-example', + 'tag-override-example.js', + ), + 'utf8', + ); + assert.match( + componentSource, + /customElements\.define\('custom-tag-override'/u, + ); + }); + test('executes the project system-install hook', () => { assert.equal( existsSync(systemHookSentinel), diff --git a/test/e2e/root-help.txt b/test/e2e/root-help.txt index 6feaad9..63282b4 100644 --- a/test/e2e/root-help.txt +++ b/test/e2e/root-help.txt @@ -25,6 +25,7 @@ COMPONENTS component create [name] Generate a new local component -d, --directory Variant structure to create it in -t, --type twig | twig-sdc | react | web-component + --tag-name Set the Web Component custom element tag -f, --format Deprecated Twig type alias --force Replace an existing generated component --dry-run Preview without writing files From 936d08744cb0792818d234d105bee4cf44dc82f3 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 11:20:09 -0500 Subject: [PATCH 22/33] test(ci): make coverage patterns and fixtures platform-independent --- .gitattributes | 1 + jest.config.cjs | 4 ++-- src/util/cache/getCachedItemPath.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..26b1b53 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +test/e2e/root-help.txt text eol=lf diff --git a/jest.config.cjs b/jest.config.cjs index a68932d..e9c516d 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -29,8 +29,8 @@ module.exports = { "^(\\.\\.?\\/.+)\\.js$": "$1", }, coveragePathIgnorePatterns: [ - '/node_modules/', - '/src/index.ts', + '[\\\\/]node_modules[\\\\/]', + '[\\\\/]src[\\\\/]index\\.ts$', ], setupFilesAfterEnv: ['/jest.setup.cjs'], }; diff --git a/src/util/cache/getCachedItemPath.test.ts b/src/util/cache/getCachedItemPath.test.ts index 5f85a44..41f79dd 100644 --- a/src/util/cache/getCachedItemPath.test.ts +++ b/src/util/cache/getCachedItemPath.test.ts @@ -36,6 +36,29 @@ describe('getCachedItemPath', () => { findFileMock.mockReturnValue(projectPath); }); + it('preserves the cache-key identity contract', () => { + findFileMock.mockReturnValueOnce('project'); + + expect( + getCachedItemPath({ + bucket: 'systems', + itemPath: ['compound', 'system.emulsify.json'], + repository: 'git@example.test:compound.git', + checkout: 'stable', + }), + ).toBe( + join( + cacheDirectory, + 'systems', + // Keep this digest literal: recomputing it with the implementation's + // formula would not detect a cache-key change that orphans user caches. + 'd5539f54e7ab378a6da292d1fd1ecfb2', + 'compound', + 'system.emulsify.json', + ), + ); + }); + it('produces the path to a cached item from its complete identity', () => { expect(getCachedItemPath(baseOptions)).toBe( join( From 8bf5072ad8c5bd5a208cd2e728c7fa1e0f2b6dfd Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 12:07:27 -0500 Subject: [PATCH 23/33] test(system): assert dry run output without regex path interpolation --- src/handlers/componentEjectTemplates.test.ts | 12 ++++---- src/handlers/systemCreate.test.ts | 32 +++++++++----------- src/testUtils/expectToContainInOrder.ts | 14 +++++++++ tsconfig.dist.json | 6 +++- 4 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 src/testUtils/expectToContainInOrder.ts diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index e2c5a8f..e477d4a 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -9,6 +9,7 @@ import { pathExists } from 'fs-extra'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js'; import log from '../lib/log.js'; +import expectToContainInOrder from '../testUtils/expectToContainInOrder.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import type { ComponentType } from '../util/project/componentTypes.js'; import { buildEjectableComponentTemplates } from '../util/project/componentTemplates/index.js'; @@ -307,12 +308,11 @@ describe('componentEjectTemplates', () => { ); expect(error).toMatchObject({ name: 'CliError' }); const message = (error as Error).message; - expect(message).toContain(twigConflict); - expect(message).toContain(storiesConflict); - expect(message.indexOf(twigConflict)).toBeLessThan( - message.indexOf(storiesConflict), - ); - expect(message).toContain('Pass --force'); + expectToContainInOrder(message, [ + twigConflict, + storiesConflict, + 'Pass --force', + ]); expect(pathExistsMock).toHaveBeenCalledTimes(4); expectNoWrites(); diff --git a/src/handlers/systemCreate.test.ts b/src/handlers/systemCreate.test.ts index 83e38fe..4c3b1ff 100644 --- a/src/handlers/systemCreate.test.ts +++ b/src/handlers/systemCreate.test.ts @@ -16,6 +16,7 @@ import { simpleGit } from 'simple-git'; import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; import log from '../lib/log.js'; +import expectToContainInOrder from '../testUtils/expectToContainInOrder.js'; import writeToJsonFile from '../util/fs/writeToJsonFile.js'; import buildSystemScaffold, { type BuildSystemScaffoldOptions, @@ -445,23 +446,20 @@ describe('systemCreate', () => { ); expectNoWrites(); expect(logMock).toHaveBeenCalledTimes(1); - expect(logMock).toHaveBeenCalledWith( - 'info', - expect.stringMatching( - new RegExp( - [ - 'Dry run: system create', - `Target: ${target} \\(${targetState}\\)`, - 'Platform: drupal \\|\\| wordpress', - `Git: ${gitAction}`, - `Real run would: ${realRunAction}`, - `Generated files:[\\s\\S]*${EMULSIFY_SYSTEM_CONFIG_FILE}`, - '[\\s\\S]*README\\.md', - 'No directories or files were written, and Git was not initialized\\.', - ].join('[\\s\\S]*'), - ), - ), - ); + expect(logMock).toHaveBeenCalledWith('info', expect.any(String)); + + const message = logMock.mock.calls[0][1] as string; + expectToContainInOrder(message, [ + 'Dry run: system create', + `Target: ${target} (${targetState})`, + 'Platform: drupal || wordpress', + `Git: ${gitAction}`, + `Real run would: ${realRunAction}`, + 'Generated files:', + EMULSIFY_SYSTEM_CONFIG_FILE, + 'README.md', + 'No directories or files were written, and Git was not initialized.', + ]); }, ); diff --git a/src/testUtils/expectToContainInOrder.ts b/src/testUtils/expectToContainInOrder.ts new file mode 100644 index 0000000..7e06bc5 --- /dev/null +++ b/src/testUtils/expectToContainInOrder.ts @@ -0,0 +1,14 @@ +export default function expectToContainInOrder( + received: string, + substrings: readonly string[], +): void { + let previousIndex = -1; + + for (const substring of substrings) { + expect(received).toContain(substring); + + const index = received.indexOf(substring, previousIndex + 1); + expect(previousIndex).toBeLessThan(index); + previousIndex = index; + } +} diff --git a/tsconfig.dist.json b/tsconfig.dist.json index 41ec6bd..e90563c 100644 --- a/tsconfig.dist.json +++ b/tsconfig.dist.json @@ -1,4 +1,8 @@ { "extends": "./tsconfig.json", - "exclude": ["src/**/*.test.ts", "src/scripts/**/*.ts"] + "exclude": [ + "src/**/*.test.ts", + "src/scripts/**/*.ts", + "src/testUtils/**/*.ts" + ] } From 219f985c59037d5771064a832d5ef185232425f8 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 12:07:45 -0500 Subject: [PATCH 24/33] test(fs): use platform-shaped paths in temp file assertions --- src/util/fs/writeToJsonFile.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/util/fs/writeToJsonFile.test.ts b/src/util/fs/writeToJsonFile.test.ts index 1fc98ff..c928ffa 100644 --- a/src/util/fs/writeToJsonFile.test.ts +++ b/src/util/fs/writeToJsonFile.test.ts @@ -1,5 +1,5 @@ import { promises as fs } from 'fs'; -import { basename, dirname } from 'path'; +import { basename, dirname, resolve } from 'path'; import writeToJsonFile from './writeToJsonFile.js'; const writeFileMock = fs.writeFile as jest.Mock; @@ -15,7 +15,7 @@ describe('writeToJsonFile', () => { }); it('writes to a unique sibling temp file before renaming it over the target', async () => { - const path = '/project/project.emulsify.json'; + const path = resolve('/project/project.emulsify.json'); const json = { key: 'value' }; await expect(writeToJsonFile(path, json)).resolves.toBe(undefined); @@ -39,7 +39,7 @@ describe('writeToJsonFile', () => { }); it('leaves the original untouched and removes the temp after a failed write', async () => { - const path = '/project/project.emulsify.json'; + const path = resolve('/project/project.emulsify.json'); const json = { secret: 'must not appear in the error' }; const writeError = new Error('ENOSPC: no space left on device'); writeFileMock.mockRejectedValueOnce(writeError); @@ -58,7 +58,7 @@ describe('writeToJsonFile', () => { }); it('removes the temp after a failed rename', async () => { - const path = '/project/project.emulsify.json'; + const path = resolve('/project/project.emulsify.json'); const renameError = new Error('EPERM: rename failed'); renameMock.mockRejectedValueOnce(renameError); @@ -73,7 +73,7 @@ describe('writeToJsonFile', () => { }); it('reports a cleanup failure without exposing the serialized JSON', async () => { - const path = '/project/project.emulsify.json'; + const path = resolve('/project/project.emulsify.json'); const writeError = new Error('EIO: write failed'); writeFileMock.mockRejectedValueOnce(writeError); rmMock.mockRejectedValueOnce(new Error('EACCES: cleanup failed')); From 49da06eb291d0733d9e1a246f75a8bd96ce562af Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 12:20:49 -0500 Subject: [PATCH 25/33] fix(component): fall back when the filesystem does not support hard links --- jest.setup.cjs | 1 + src/handlers/componentEjectTemplates.test.ts | 216 ++++++++++++++++++- src/handlers/componentEjectTemplates.ts | 51 ++++- test/e2e/cli.test.mjs | 100 ++++++++- test/e2e/unsupported-hard-links.cjs | 8 + 5 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 test/e2e/unsupported-hard-links.cjs diff --git a/jest.setup.cjs b/jest.setup.cjs index 98cecde..68c8c0c 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -26,6 +26,7 @@ jest.mock('simple-git', () => { }); jest.mock('fs', () => ({ + constants: jest.requireActual('fs').constants, existsSync: jest.fn(), writeFileSync: jest.fn(), promises: { diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index e477d4a..e875b82 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -3,7 +3,7 @@ jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); import { checkbox } from '@inquirer/prompts'; -import { promises as fs } from 'fs'; +import { constants as fsConstants, promises as fs } from 'fs'; import { basename, dirname, join, resolve } from 'path'; import { pathExists } from 'fs-extra'; @@ -20,6 +20,7 @@ import componentEjectTemplates, { } from './componentEjectTemplates.js'; const checkboxMock = checkbox as jest.Mock; +const copyFileMock = fs.copyFile as jest.Mock; const findFileMock = findFileInCurrentPath as jest.Mock; const logMock = log as jest.Mock; const linkMock = fs.link as jest.Mock; @@ -53,6 +54,7 @@ function transactionPathPrefix( } function expectNoWrites(): void { + expect(copyFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); expect(linkMock).not.toHaveBeenCalled(); @@ -60,6 +62,55 @@ function expectNoWrites(): void { expect(rmMock).not.toHaveBeenCalled(); } +function filesystemError(code: string, message = code): NodeJS.ErrnoException { + return Object.assign(new Error(message), { code }); +} + +function mockInMemoryFilesystem( + initialFiles: Readonly> = {}, +) { + const files = new Map(Object.entries(initialFiles)); + + const writeFile = async ( + target: string, + contents: string, + options?: { flag?: string }, + ): Promise => { + if (options?.flag === 'wx' && files.has(target)) { + throw filesystemError('EEXIST'); + } + files.set(target, String(contents)); + }; + const link = async (source: string, target: string): Promise => { + if (!files.has(source)) throw filesystemError('ENOENT'); + if (files.has(target)) throw filesystemError('EEXIST'); + files.set(target, files.get(source)!); + }; + const copyFile = async (source: string, target: string): Promise => { + if (!files.has(source)) throw filesystemError('ENOENT'); + if (files.has(target)) throw filesystemError('EEXIST'); + files.set(target, files.get(source)!); + }; + const rename = async (source: string, target: string): Promise => { + if (!files.has(source)) throw filesystemError('ENOENT'); + files.set(target, files.get(source)!); + files.delete(source); + }; + + pathExistsMock.mockImplementation(async (target: string) => + files.has(target), + ); + writeFileMock.mockImplementation(writeFile); + linkMock.mockImplementation(link); + copyFileMock.mockImplementation(copyFile); + renameMock.mockImplementation(rename); + rmMock.mockImplementation(async (target: string) => { + files.delete(target); + }); + + return { files, rename, writeFile }; +} + describe('buildComponentTemplateEjectionPlan', () => { it('resolves every destination beneath the templates root', () => { const plan = buildComponentTemplateEjectionPlan(projectRoot, [ @@ -92,6 +143,7 @@ describe('componentEjectTemplates', () => { beforeEach(() => { jest.clearAllMocks(); setStdinIsTTY(false); + copyFileMock.mockResolvedValue(undefined); findFileMock.mockReturnValue(projectConfigPath); pathExistsMock.mockResolvedValue(false); mkdirMock.mockResolvedValue(undefined); @@ -153,6 +205,35 @@ describe('componentEjectTemplates', () => { ); }); + it.each(['EPERM', 'ENOTSUP'])( + 'publishes exclusive destination files when hard links fail with %s', + async (code) => { + const { files } = mockInMemoryFilesystem(); + linkMock.mockRejectedValue(filesystemError(code, 'links unsupported')); + + await componentEjectTemplates('react'); + + const artifacts = buildEjectableComponentTemplates('react'); + expect(Object.fromEntries(files)).toEqual( + Object.fromEntries( + artifacts.map(({ logicalName, contents }) => [ + destination('react', logicalName), + contents, + ]), + ), + ); + for (const artifact of artifacts) { + expect(writeFileMock).toHaveBeenCalledWith( + destination('react', artifact.logicalName), + artifact.contents, + { encoding: 'utf-8', flag: 'wx', flush: true }, + ); + } + expect(copyFileMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); + }, + ); + it('writes all 15 templates without prompting when --all is passed', async () => { await componentEjectTemplates(undefined, { all: true }); @@ -348,6 +429,104 @@ describe('componentEjectTemplates', () => { } }); + it.each(['EPERM', 'ENOTSUP'])( + 'copies restore points before replacement when hard links fail with %s', + async (code) => { + const artifacts = buildEjectableComponentTemplates('react'); + const initialFiles = Object.fromEntries( + artifacts.map(({ logicalName }) => [ + destination('react', logicalName), + `custom ${logicalName}`, + ]), + ); + const { files } = mockInMemoryFilesystem(initialFiles); + linkMock.mockRejectedValue(filesystemError(code, 'links unsupported')); + + await componentEjectTemplates('react', { force: true }); + + expect(Object.fromEntries(files)).toEqual( + Object.fromEntries( + artifacts.map(({ logicalName, contents }) => [ + destination('react', logicalName), + contents, + ]), + ), + ); + for (const artifact of artifacts) { + const target = destination('react', artifact.logicalName); + expect(copyFileMock).toHaveBeenCalledWith( + target, + expect.stringContaining(transactionPathPrefix(target, 'backup')), + fsConstants.COPYFILE_EXCL, + ); + } + }, + ); + + it('installs new files with --force when fallback backup copying finds no source', async () => { + const { files } = mockInMemoryFilesystem(); + linkMock.mockRejectedValue(filesystemError('ENOTSUP')); + + await componentEjectTemplates('react', { force: true }); + + const artifacts = buildEjectableComponentTemplates('react'); + expect(Object.fromEntries(files)).toEqual( + Object.fromEntries( + artifacts.map(({ logicalName, contents }) => [ + destination('react', logicalName), + contents, + ]), + ), + ); + expect(copyFileMock).toHaveBeenCalledTimes(artifacts.length); + }); + + it('rolls back successful and partial exclusive writes after publication fails', async () => { + const firstTarget = destination('react', 'component.jsx'); + const failedTarget = destination('react', 'component.scss'); + const { files, writeFile } = mockInMemoryFilesystem(); + linkMock.mockRejectedValue(filesystemError('EPERM')); + writeFileMock.mockImplementation( + async (target: string, contents: string, options?: { flag?: string }) => { + await writeFile(target, contents, options); + if (target === failedTarget) throw new Error('write interrupted'); + }, + ); + + await expect(componentEjectTemplates('react')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching( + /write interrupted[\s\S]*All destination changes were rolled back\./u, + ), + }); + + expect(Object.fromEntries(files)).toEqual({}); + expect(rmMock).toHaveBeenCalledWith(firstTarget, { force: true }); + expect(rmMock).toHaveBeenCalledWith(failedTarget, { force: true }); + }); + + it('preserves a destination that appears before the exclusive-write fallback', async () => { + const firstTarget = destination('react', 'component.jsx'); + const { files, writeFile } = mockInMemoryFilesystem(); + linkMock.mockRejectedValue(filesystemError('EPERM')); + writeFileMock.mockImplementation( + async (target: string, contents: string, options?: { flag?: string }) => { + if (target === firstTarget) files.set(target, 'concurrent contents'); + await writeFile(target, contents, options); + }, + ); + + await expect(componentEjectTemplates('react')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching(/appeared.*not replaced.*--force/u), + }); + + expect(Object.fromEntries(files)).toEqual({ + [firstTarget]: 'concurrent contents', + }); + expect(rmMock).not.toHaveBeenCalledWith(firstTarget, { force: true }); + }); + it('does not overwrite a template created after the conflict preflight', async () => { linkMock.mockRejectedValueOnce( Object.assign(new Error('already exists'), { code: 'EEXIST' }), @@ -434,6 +613,41 @@ describe('componentEjectTemplates', () => { expect(logMock).not.toHaveBeenCalled(); }); + it('restores copied backups after a mid-transaction fallback failure', async () => { + const artifacts = buildEjectableComponentTemplates('react'); + const initialFiles = Object.fromEntries( + artifacts.map(({ logicalName }) => [ + destination('react', logicalName), + `original ${logicalName}`, + ]), + ); + const { files, rename } = mockInMemoryFilesystem(initialFiles); + linkMock.mockRejectedValue(filesystemError('ENOTSUP')); + let installCount = 0; + renameMock.mockImplementation(async (source: string, target: string) => { + if (source.includes('.emulsify-temporary-')) { + installCount += 1; + if (installCount === 2) throw new Error('install failed'); + } + await rename(source, target); + }); + + await expect( + componentEjectTemplates('react', { force: true }), + ).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching( + /install failed[\s\S]*All destination changes were rolled back\./u, + ), + }); + + expect(Object.fromEntries(files)).toEqual(initialFiles); + expect(copyFileMock).toHaveBeenCalledTimes(2); + for (const [target, backup] of copyFileMock.mock.calls) { + expect(renameMock).toHaveBeenCalledWith(backup, target); + } + }); + it('preserves and reports a backup when rollback cannot restore it', async () => { const firstTarget = destination('react', 'component.jsx'); const failedTarget = destination('react', 'component.scss'); diff --git a/src/handlers/componentEjectTemplates.ts b/src/handlers/componentEjectTemplates.ts index cc018b1..51399a0 100644 --- a/src/handlers/componentEjectTemplates.ts +++ b/src/handlers/componentEjectTemplates.ts @@ -2,7 +2,7 @@ import type { EjectComponentTemplatesHandlerOptions } from '@emulsify-cli/handle import { checkbox } from '@inquirer/prompts'; import { randomUUID } from 'crypto'; -import { promises as fs } from 'fs'; +import { constants as fsConstants, promises as fs } from 'fs'; import { basename, dirname, join } from 'path'; import { pathExists } from 'fs-extra'; @@ -193,6 +193,17 @@ function isMissingError(error: unknown): boolean { ); } +function isUnsupportedHardLinkError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + (error.code === 'EPERM' || + error.code === 'ENOTSUP' || + error.code === 'EOPNOTSUPP') + ); +} + function getTransactionPath( destination: string, kind: 'temporary' | 'backup', @@ -269,7 +280,26 @@ async function installTransactionItem( if (!force) { // link() publishes the fully-written staged file atomically and refuses an // existing destination, preserving the post-preflight EEXIST backstop. - await fs.link(item.temporaryPath, item.destination); + try { + await fs.link(item.temporaryPath, item.destination); + } catch (error) { + if (!isUnsupportedHardLinkError(error)) throw error; + + // Filesystems without hard links cannot publish the staged inode. Keep + // the exclusive-create backstop and record ownership before writing so + // rollback removes a destination left partial by a failed write. + item.installed = true; + try { + await fs.writeFile(item.destination, item.contents, { + encoding: 'utf-8', + flag: 'wx', + flush: true, + }); + } catch (writeError) { + if (isAlreadyExistsError(writeError)) item.installed = false; + throw writeError; + } + } item.installed = true; return; } @@ -280,7 +310,22 @@ async function installTransactionItem( await fs.link(item.destination, item.backupPath); item.hasBackup = true; } catch (error) { - if (!isMissingError(error)) throw error; + if (!isMissingError(error)) { + if (!isUnsupportedHardLinkError(error)) throw error; + + try { + // Copy to the private restore point before replacing the destination. + // A partial copy leaves hasBackup false and is removed by cleanup. + await fs.copyFile( + item.destination, + item.backupPath, + fsConstants.COPYFILE_EXCL, + ); + item.hasBackup = true; + } catch (copyError) { + if (!isMissingError(copyError)) throw copyError; + } + } } await fs.rename(item.temporaryPath, item.destination); diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index ce5de53..ff7380e 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -20,6 +20,12 @@ import { after, before, describe, test } from 'node:test'; const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); const cliPath = join(repositoryRoot, 'dist', 'index.js'); +const unsupportedHardLinksHookPath = join( + repositoryRoot, + 'test', + 'e2e', + 'unsupported-hard-links.cjs', +); const packageInfo = JSON.parse( readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), ); @@ -128,8 +134,8 @@ function snapshotFiles(directory, excludedRelativePaths = new Set()) { return files; } -function runCli(cwd, args, environment = isolatedEnvironment()) { - const result = spawnSync(process.execPath, [cliPath, ...args], { +function runCli(cwd, args, environment = isolatedEnvironment(), execArgv = []) { + const result = spawnSync(process.execPath, [...execArgv, cliPath, ...args], { cwd, encoding: 'utf8', env: environment, @@ -918,6 +924,96 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); }); + test('matches hard-link results when publish and backup links are unsupported', () => { + const linkedProjectRoot = join(projectsRoot, 'linked-template-project'); + const fallbackProjectRoot = join(projectsRoot, 'fallback-template-project'); + for (const [root, name] of [ + [linkedProjectRoot, 'Linked Template Project'], + [fallbackProjectRoot, 'Fallback Template Project'], + ]) { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'project.emulsify.json'), + json({ + project: { + platform: 'none', + name, + machineName: name.toLowerCase().replaceAll(' ', '-'), + }, + }), + ); + } + + const fallbackEnvironment = isolatedEnvironment(); + const linkTracePath = join(tempRoot, 'unsupported-hard-link-calls.log'); + fallbackEnvironment.EMULSIFY_E2E_LINK_ERROR = 'EPERM'; + fallbackEnvironment.EMULSIFY_E2E_LINK_TRACE = linkTracePath; + const execArgv = ['--require', unsupportedHardLinksHookPath]; + const args = ['component', 'eject-templates', 'react']; + const linkedResult = runCli(linkedProjectRoot, args); + const fallbackResult = runCli( + fallbackProjectRoot, + args, + fallbackEnvironment, + execArgv, + ); + assert.equal( + linkedResult.status, + 0, + commandFailure('linked template publish', linkedResult), + ); + assert.equal( + fallbackResult.status, + 0, + commandFailure('fallback template publish', fallbackResult), + ); + + const linkedTemplatesRoot = join(linkedProjectRoot, '.cli', 'templates'); + const fallbackTemplatesRoot = join( + fallbackProjectRoot, + '.cli', + 'templates', + ); + assert.deepEqual( + snapshotFiles(fallbackTemplatesRoot), + snapshotFiles(linkedTemplatesRoot), + ); + assert.match(readFileSync(linkTracePath, 'utf8'), /^EPERM$/mu); + + const relativeOverridePath = join('react', 'component.jsx'); + const sentinel = 'custom contents before --force\n'; + writeFileSync(join(linkedTemplatesRoot, relativeOverridePath), sentinel); + writeFileSync(join(fallbackTemplatesRoot, relativeOverridePath), sentinel); + fallbackEnvironment.EMULSIFY_E2E_LINK_ERROR = 'ENOTSUP'; + + const linkedForceResult = runCli(linkedProjectRoot, [...args, '--force']); + const fallbackForceResult = runCli( + fallbackProjectRoot, + [...args, '--force'], + fallbackEnvironment, + execArgv, + ); + assert.equal( + linkedForceResult.status, + 0, + commandFailure('linked template replacement', linkedForceResult), + ); + assert.equal( + fallbackForceResult.status, + 0, + commandFailure('fallback template replacement', fallbackForceResult), + ); + assert.deepEqual( + snapshotFiles(fallbackTemplatesRoot), + snapshotFiles(linkedTemplatesRoot), + ); + assert.notEqual( + readFileSync(join(fallbackTemplatesRoot, relativeOverridePath), 'utf8'), + sentinel, + ); + assert.match(readFileSync(linkTracePath, 'utf8'), /^ENOTSUP$/mu); + }); + test('uses a customized ejected template during component creation', () => { const ejectResult = runCli(projectRoot, [ 'component', diff --git a/test/e2e/unsupported-hard-links.cjs b/test/e2e/unsupported-hard-links.cjs new file mode 100644 index 0000000..49dda31 --- /dev/null +++ b/test/e2e/unsupported-hard-links.cjs @@ -0,0 +1,8 @@ +const { appendFileSync, promises: fs } = require('node:fs'); + +fs.link = async () => { + const code = process.env.EMULSIFY_E2E_LINK_ERROR || 'ENOTSUP'; + const tracePath = process.env.EMULSIFY_E2E_LINK_TRACE; + if (tracePath) appendFileSync(tracePath, `${code}\n`); + throw Object.assign(new Error(`simulated ${code} from fs.link`), { code }); +}; From 2b034fed652a688ec0d8d82fc4737ee0af078671 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 12:30:56 -0500 Subject: [PATCH 26/33] test(ci): make transform patterns platform-independent and recalibrate coverage floors --- jest.config.cjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/jest.config.cjs b/jest.config.cjs index e9c516d..323b743 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -9,12 +9,13 @@ module.exports = { '!src/index.ts', '!src/scripts/**', ], - // Temporary honest floor to ratchet back up once handler tests are added (Prompt 5). + // Keep honest floors with room for unrelated changes. Raise them only when + // durable tests cover behavior that is currently unexercised. coverageThreshold: { global: { branches: 88, functions: 91, - lines: 96, + lines: 95, statements: 94, }, }, @@ -24,7 +25,9 @@ module.exports = { { diagnostics: { ignoreCodes: [1324, 151002] }, useESM: false }, ], }, - transformIgnorePatterns: ['node_modules/(?!(@inquirer|fast-.+)/)'], + transformIgnorePatterns: [ + 'node_modules[\\\\/](?!(@inquirer|fast-.+)[\\\\/])', + ], "moduleNameMapper": { "^(\\.\\.?\\/.+)\\.js$": "$1", }, From 4a6efed69f0894ecfe659e240628a6097d739cb2 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 14:06:57 -0500 Subject: [PATCH 27/33] fix(component): never roll back a destination the eject did not create --- jest.setup.cjs | 1 + src/handlers/componentEjectTemplates.test.ts | 75 ++++++++++++++------ src/handlers/componentEjectTemplates.ts | 15 ++-- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/jest.setup.cjs b/jest.setup.cjs index 68c8c0c..4e1c206 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -37,6 +37,7 @@ jest.mock('fs', () => ({ mkdir: jest.fn(), mkdtemp: jest.fn(), link: jest.fn(), + open: jest.fn(), rename: jest.fn(), stat: jest.fn(), copyFile: jest.fn(), diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index e875b82..262a038 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -25,6 +25,7 @@ const findFileMock = findFileInCurrentPath as jest.Mock; const logMock = log as jest.Mock; const linkMock = fs.link as jest.Mock; const mkdirMock = fs.mkdir as jest.Mock; +const openMock = fs.open as jest.Mock; const pathExistsMock = pathExists as jest.Mock; const renameMock = fs.rename as jest.Mock; const rmMock = fs.rm as jest.Mock; @@ -56,6 +57,7 @@ function transactionPathPrefix( function expectNoWrites(): void { expect(copyFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); + expect(openMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); expect(linkMock).not.toHaveBeenCalled(); expect(renameMock).not.toHaveBeenCalled(); @@ -66,20 +68,41 @@ function filesystemError(code: string, message = code): NodeJS.ErrnoException { return Object.assign(new Error(message), { code }); } +type InMemoryFileHandle = { + target: string; + close: jest.Mock; +}; + function mockInMemoryFilesystem( initialFiles: Readonly> = {}, ) { const files = new Map(Object.entries(initialFiles)); + const handles = new Map(); const writeFile = async ( - target: string, + target: string | InMemoryFileHandle, contents: string, options?: { flag?: string }, ): Promise => { - if (options?.flag === 'wx' && files.has(target)) { + const writeTarget = typeof target === 'string' ? target : target.target; + if (options?.flag === 'wx' && files.has(writeTarget)) { throw filesystemError('EEXIST'); } - files.set(target, String(contents)); + files.set(writeTarget, String(contents)); + }; + const open = async ( + target: string, + flag: string, + ): Promise => { + if (flag === 'wx' && files.has(target)) throw filesystemError('EEXIST'); + + files.set(target, ''); + const handle = { + target, + close: jest.fn().mockResolvedValue(undefined), + }; + handles.set(target, handle); + return handle; }; const link = async (source: string, target: string): Promise => { if (!files.has(source)) throw filesystemError('ENOENT'); @@ -101,6 +124,7 @@ function mockInMemoryFilesystem( files.has(target), ); writeFileMock.mockImplementation(writeFile); + openMock.mockImplementation(open); linkMock.mockImplementation(link); copyFileMock.mockImplementation(copyFile); renameMock.mockImplementation(rename); @@ -108,7 +132,7 @@ function mockInMemoryFilesystem( files.delete(target); }); - return { files, rename, writeFile }; + return { files, handles, open, rename, writeFile }; } describe('buildComponentTemplateEjectionPlan', () => { @@ -148,6 +172,9 @@ describe('componentEjectTemplates', () => { pathExistsMock.mockResolvedValue(false); mkdirMock.mockResolvedValue(undefined); linkMock.mockResolvedValue(undefined); + openMock.mockResolvedValue({ + close: jest.fn().mockResolvedValue(undefined), + }); renameMock.mockResolvedValue(undefined); rmMock.mockResolvedValue(undefined); writeFileMock.mockResolvedValue(undefined); @@ -208,7 +235,7 @@ describe('componentEjectTemplates', () => { it.each(['EPERM', 'ENOTSUP'])( 'publishes exclusive destination files when hard links fail with %s', async (code) => { - const { files } = mockInMemoryFilesystem(); + const { files, handles } = mockInMemoryFilesystem(); linkMock.mockRejectedValue(filesystemError(code, 'links unsupported')); await componentEjectTemplates('react'); @@ -223,11 +250,14 @@ describe('componentEjectTemplates', () => { ), ); for (const artifact of artifacts) { - expect(writeFileMock).toHaveBeenCalledWith( - destination('react', artifact.logicalName), - artifact.contents, - { encoding: 'utf-8', flag: 'wx', flush: true }, - ); + const target = destination('react', artifact.logicalName); + const handle = handles.get(target); + expect(openMock).toHaveBeenCalledWith(target, 'wx'); + expect(writeFileMock).toHaveBeenCalledWith(handle, artifact.contents, { + encoding: 'utf-8', + flush: true, + }); + expect(handle?.close).toHaveBeenCalledTimes(1); } expect(copyFileMock).not.toHaveBeenCalled(); expect(renameMock).not.toHaveBeenCalled(); @@ -484,12 +514,18 @@ describe('componentEjectTemplates', () => { it('rolls back successful and partial exclusive writes after publication fails', async () => { const firstTarget = destination('react', 'component.jsx'); const failedTarget = destination('react', 'component.scss'); - const { files, writeFile } = mockInMemoryFilesystem(); + const { files, handles, writeFile } = mockInMemoryFilesystem(); linkMock.mockRejectedValue(filesystemError('EPERM')); writeFileMock.mockImplementation( - async (target: string, contents: string, options?: { flag?: string }) => { + async ( + target: string | InMemoryFileHandle, + contents: string, + options?: { flag?: string }, + ) => { await writeFile(target, contents, options); - if (target === failedTarget) throw new Error('write interrupted'); + if (typeof target !== 'string' && target.target === failedTarget) { + throw new Error('write interrupted'); + } }, ); @@ -503,18 +539,17 @@ describe('componentEjectTemplates', () => { expect(Object.fromEntries(files)).toEqual({}); expect(rmMock).toHaveBeenCalledWith(firstTarget, { force: true }); expect(rmMock).toHaveBeenCalledWith(failedTarget, { force: true }); + expect(handles.get(failedTarget)?.close).toHaveBeenCalledTimes(1); }); it('preserves a destination that appears before the exclusive-write fallback', async () => { const firstTarget = destination('react', 'component.jsx'); - const { files, writeFile } = mockInMemoryFilesystem(); + const { files, open } = mockInMemoryFilesystem(); linkMock.mockRejectedValue(filesystemError('EPERM')); - writeFileMock.mockImplementation( - async (target: string, contents: string, options?: { flag?: string }) => { - if (target === firstTarget) files.set(target, 'concurrent contents'); - await writeFile(target, contents, options); - }, - ); + openMock.mockImplementation(async (target: string, flag: string) => { + if (target === firstTarget) files.set(target, 'concurrent contents'); + return open(target, flag); + }); await expect(componentEjectTemplates('react')).rejects.toMatchObject({ name: 'CliError', diff --git a/src/handlers/componentEjectTemplates.ts b/src/handlers/componentEjectTemplates.ts index 51399a0..c39aa3b 100644 --- a/src/handlers/componentEjectTemplates.ts +++ b/src/handlers/componentEjectTemplates.ts @@ -285,19 +285,18 @@ async function installTransactionItem( } catch (error) { if (!isUnsupportedHardLinkError(error)) throw error; - // Filesystems without hard links cannot publish the staged inode. Keep - // the exclusive-create backstop and record ownership before writing so - // rollback removes a destination left partial by a failed write. + // Filesystems without hard links cannot publish the staged inode. An + // exclusive open proves this transaction created the destination before + // rollback is allowed to remove a partial write. + const destinationHandle = await fs.open(item.destination, 'wx'); item.installed = true; try { - await fs.writeFile(item.destination, item.contents, { + await fs.writeFile(destinationHandle, item.contents, { encoding: 'utf-8', - flag: 'wx', flush: true, }); - } catch (writeError) { - if (isAlreadyExistsError(writeError)) item.installed = false; - throw writeError; + } finally { + await destinationHandle.close(); } } item.installed = true; From 7ebf50793a69ae980fac0ba4d38138a2916028bc Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 14:08:25 -0500 Subject: [PATCH 28/33] test(component): cover hard-link fallback error paths --- src/handlers/componentEjectTemplates.test.ts | 87 +++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index 262a038..22bcd8b 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -232,7 +232,7 @@ describe('componentEjectTemplates', () => { ); }); - it.each(['EPERM', 'ENOTSUP'])( + it.each(['EPERM', 'ENOTSUP', 'EOPNOTSUPP'])( 'publishes exclusive destination files when hard links fail with %s', async (code) => { const { files, handles } = mockInMemoryFilesystem(); @@ -264,6 +264,25 @@ describe('componentEjectTemplates', () => { }, ); + it('surfaces non-link publish errors without using the fallback', async () => { + const firstTarget = destination('react', 'component.jsx'); + const { files } = mockInMemoryFilesystem(); + const linkError = filesystemError('EACCES', 'link permission denied'); + linkMock.mockRejectedValueOnce(linkError); + + await expect(componentEjectTemplates('react')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringContaining('link permission denied'), + }); + + expect(linkMock).toHaveBeenCalledTimes(1); + expect(openMock).not.toHaveBeenCalled(); + expect(copyFileMock).not.toHaveBeenCalled(); + expect(renameMock).not.toHaveBeenCalled(); + expect(rmMock).not.toHaveBeenCalledWith(firstTarget, { force: true }); + expect(Object.fromEntries(files)).toEqual({}); + }); + it('writes all 15 templates without prompting when --all is passed', async () => { await componentEjectTemplates(undefined, { all: true }); @@ -459,7 +478,7 @@ describe('componentEjectTemplates', () => { } }); - it.each(['EPERM', 'ENOTSUP'])( + it.each(['EPERM', 'ENOTSUP', 'EOPNOTSUPP'])( 'copies restore points before replacement when hard links fail with %s', async (code) => { const artifacts = buildEjectableComponentTemplates('react'); @@ -493,6 +512,45 @@ describe('componentEjectTemplates', () => { }, ); + it('keeps originals intact when fallback backup copying fails', async () => { + const artifacts = buildEjectableComponentTemplates('react'); + const firstTarget = destination('react', 'component.jsx'); + const initialFiles = Object.fromEntries( + artifacts.map(({ logicalName }) => [ + destination('react', logicalName), + `original ${logicalName}`, + ]), + ); + const { files } = mockInMemoryFilesystem(initialFiles); + linkMock.mockRejectedValue(filesystemError('ENOTSUP')); + let partialBackup: string | undefined; + copyFileMock.mockImplementationOnce( + async (_source: string, target: string) => { + partialBackup = target; + files.set(target, 'partial backup'); + throw filesystemError('EIO', 'backup copy failed'); + }, + ); + + await expect( + componentEjectTemplates('react', { force: true }), + ).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringContaining('backup copy failed'), + }); + + expect(copyFileMock).toHaveBeenCalledTimes(1); + expect(copyFileMock).toHaveBeenCalledWith( + firstTarget, + expect.stringContaining(transactionPathPrefix(firstTarget, 'backup')), + fsConstants.COPYFILE_EXCL, + ); + expect(renameMock).not.toHaveBeenCalled(); + expect(rmMock).toHaveBeenCalledWith(partialBackup, { force: true }); + expect(rmMock).not.toHaveBeenCalledWith(firstTarget, { force: true }); + expect(Object.fromEntries(files)).toEqual(initialFiles); + }); + it('installs new files with --force when fallback backup copying finds no source', async () => { const { files } = mockInMemoryFilesystem(); linkMock.mockRejectedValue(filesystemError('ENOTSUP')); @@ -562,6 +620,31 @@ describe('componentEjectTemplates', () => { expect(rmMock).not.toHaveBeenCalledWith(firstTarget, { force: true }); }); + it('preserves a pre-existing destination when fallback exclusive open fails', async () => { + const firstTarget = destination('react', 'component.jsx'); + const userContents = 'user contents'; + const { files } = mockInMemoryFilesystem(); + linkMock.mockRejectedValue(filesystemError('EPERM', 'link unavailable')); + openMock.mockImplementationOnce(async (target: string) => { + files.set(target, userContents); + throw filesystemError('EACCES', 'exclusive open denied'); + }); + + await expect(componentEjectTemplates('react')).rejects.toMatchObject({ + name: 'CliError', + message: expect.stringMatching( + /component\.jsx[\s\S]*exclusive open denied[\s\S]*All destination changes were rolled back\./u, + ), + }); + + expect(openMock).toHaveBeenCalledTimes(1); + expect(openMock).toHaveBeenCalledWith(firstTarget, 'wx'); + expect(rmMock).not.toHaveBeenCalledWith(firstTarget, { force: true }); + expect(Object.fromEntries(files)).toEqual({ + [firstTarget]: userContents, + }); + }); + it('does not overwrite a template created after the conflict preflight', async () => { linkMock.mockRejectedValueOnce( Object.assign(new Error('already exists'), { code: 'EEXIST' }), From 92e0306b575ed0a357632c0e6a2a7260ef7b389b Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 14:15:02 -0500 Subject: [PATCH 29/33] test(ci): exclude test helpers from coverage and guard packaging --- jest.config.cjs | 7 ++++--- scripts/package-helpers.mjs | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/jest.config.cjs b/jest.config.cjs index 323b743..4290326 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -8,14 +8,15 @@ module.exports = { '!src/**/*.test.ts', '!src/index.ts', '!src/scripts/**', + '!src/testUtils/**', ], - // Keep honest floors with room for unrelated changes. Raise them only when - // durable tests cover behavior that is currently unexercised. + // Keep roughly one percentage point of headroom for unrelated changes. Raise + // a floor after durable tests lift production coverage enough to retain it. coverageThreshold: { global: { branches: 88, functions: 91, - lines: 95, + lines: 95.5, statements: 94, }, }, diff --git a/scripts/package-helpers.mjs b/scripts/package-helpers.mjs index c4d4087..1a51baf 100644 --- a/scripts/package-helpers.mjs +++ b/scripts/package-helpers.mjs @@ -222,6 +222,7 @@ export function assertPackageContents(packResult) { 'test', 'tests', '__tests__', + 'testutils', 'spec', 'specs', '__specs__', From ba9be5839568813d5ba70a6d6b51be3de06f58ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:32:53 +0000 Subject: [PATCH 30/33] chore(release): bump version to 2.4.0 --- 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 6e42f66..1166636 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@emulsify/cli", - "version": "2.3.1", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.3.1", + "version": "2.4.0", "license": "GPL-2.0", "dependencies": { "@inquirer/prompts": "^8.7.0", diff --git a/package.json b/package.json index 58a9b73..73beaea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.3.1", + "version": "2.4.0", "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 ", From 7559fe9e063f00c83bb969e9e88bf92aa3d7244e Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 21:01:18 -0500 Subject: [PATCH 31/33] test(ci): map coverage to TypeScript sources --- jest.config.cjs | 16 +++--- src/handlers/componentEjectTemplates.test.ts | 54 ++++++++++++++++++++ src/handlers/systemInstall.test.ts | 42 +++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/jest.config.cjs b/jest.config.cjs index 4290326..6856a75 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,3 +1,10 @@ +const tsJestOptions = { + diagnostics: { ignoreCodes: [1324, 151002] }, + // Keep coverage mapped to TypeScript without changing the distribution build. + tsconfig: { sourceMap: true }, + useESM: false, +}; + module.exports = { testEnvironment: 'node', roots: ['/src'], @@ -21,16 +28,13 @@ module.exports = { }, }, transform: { - '\\.[jt]sx?$': [ - 'ts-jest', - { diagnostics: { ignoreCodes: [1324, 151002] }, useESM: false }, - ], + '\\.[jt]sx?$': ['ts-jest', tsJestOptions], }, transformIgnorePatterns: [ 'node_modules[\\\\/](?!(@inquirer|fast-.+)[\\\\/])', ], - "moduleNameMapper": { - "^(\\.\\.?\\/.+)\\.js$": "$1", + moduleNameMapper: { + '^(\\.\\.?\\/.+)\\.js$': '$1', }, coveragePathIgnorePatterns: [ '[\\\\/]node_modules[\\\\/]', diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index 22bcd8b..33e919c 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -693,6 +693,60 @@ describe('componentEjectTemplates', () => { expect(logMock).not.toHaveBeenCalled(); }); + it('reports transaction files that cannot be cleaned up after installation', async () => { + rmMock.mockRejectedValueOnce(new Error('cleanup denied')); + + const error = await componentEjectTemplates('twig').catch( + (reason: unknown) => reason, + ); + + const failedCleanupPath = writeFileMock.mock.calls[0][0]; + expect(error).toMatchObject({ name: 'CliError' }); + expect((error as Error).message).toContain( + 'Component templates were installed, but transaction cleanup was incomplete:', + ); + expect((error as Error).message).toContain( + ` - ${failedCleanupPath}: cleanup denied`, + ); + expect(linkMock).toHaveBeenCalledTimes( + buildEjectableComponentTemplates('twig').length, + ); + expect(logMock).not.toHaveBeenCalled(); + }); + + it('reports rollback and transaction cleanup removal failures together', async () => { + const installedTarget = destination('react', 'component.jsx'); + linkMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('publish failed')); + rmMock.mockImplementation(async (target: string) => { + if (target === installedTarget) { + throw new Error('rollback denied'); + } + if (target.includes('.emulsify-temporary-')) { + throw new Error('cleanup denied'); + } + }); + + const error = await componentEjectTemplates('react').catch( + (reason: unknown) => reason, + ); + + expect(error).toMatchObject({ name: 'CliError' }); + expect((error as Error).message).toContain( + `Unable to install component template "${destination('react', 'component.scss')}": publish failed.`, + ); + expect((error as Error).message).toContain('Rollback was incomplete:'); + expect((error as Error).message).toContain( + `Could not remove newly installed "${installedTarget}": rollback denied`, + ); + expect((error as Error).message).toContain( + 'Temporary transaction files could not be removed:', + ); + expect((error as Error).message).toContain('cleanup denied'); + expect(logMock).not.toHaveBeenCalled(); + }); + it('restores replaced files and removes new files after a mid-finalization failure', async () => { const replacedTarget = destination('react', 'component.jsx'); const newTarget = destination('react', 'component.scss'); diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index 17a3a8f..ea7cb4f 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -22,6 +22,7 @@ jest.mock('@inquirer/prompts'); import fs from 'fs'; import { join, resolve } from 'path'; +import { fileURLToPath } from 'url'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; import { confirm, input, select, Separator } from '@inquirer/prompts'; import log from '../lib/log.js'; @@ -318,6 +319,32 @@ describe('formatSystemInstallReview', () => { ), ).toContain('Will install 1 component → .'); }); + + it('decodes a file URL source and removes its Git suffix', () => { + const repository = 'file:///fixtures/local%20system.git'; + const repositoryPath = fileURLToPath(repository).replace(/\.git$/u, ''); + + expect( + formatSystemInstallReview( + 'Local System', + repository, + 'main', + variant, + { + components: [], + requiredComponentCount: 0, + totalComponentCount: 0, + componentParentDestinations: [], + directoryAssetDestinations: [], + fileAssetDestinations: [], + directoryAssetCount: 0, + fileAssetCount: 0, + totalAssetCount: 0, + }, + false, + ), + ).toContain(`Source ${repositoryPath}`); + }); }); describe('systemInstall', () => { @@ -1555,6 +1582,21 @@ describe('systemInstall', () => { ); }); + it('throws when neither the latest tag nor cache identifies the loaded checkout', async () => { + getRepositoryLatestTagMock.mockResolvedValueOnce(undefined); + getCachedItemCheckoutMock.mockResolvedValueOnce(undefined); + + await expect(systemInstall('compound', {})).rejects.toThrow( + 'Unable to determine which system checkout was loaded. Retry with --checkout .', + ); + + expect(cloneSystemMock).toHaveBeenCalledWith({ + repository: 'https://github.com/emulsify-ds/compound.git', + checkout: undefined, + }); + expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); + }); + it('skips the install hook when no project config path is found', async () => { findFileInCurrentPathMock.mockReturnValueOnce(undefined); From 6afa6679e524116b11bae24a175f71ae2cadbd71 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 21:04:35 -0500 Subject: [PATCH 32/33] test(system): use a platform-shaped file URL fixture --- src/handlers/systemInstall.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index ea7cb4f..ece911c 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -22,7 +22,7 @@ jest.mock('@inquirer/prompts'); import fs from 'fs'; import { join, resolve } from 'path'; -import { fileURLToPath } from 'url'; +import { pathToFileURL } from 'url'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; import { confirm, input, select, Separator } from '@inquirer/prompts'; import log from '../lib/log.js'; @@ -321,8 +321,8 @@ describe('formatSystemInstallReview', () => { }); it('decodes a file URL source and removes its Git suffix', () => { - const repository = 'file:///fixtures/local%20system.git'; - const repositoryPath = fileURLToPath(repository).replace(/\.git$/u, ''); + const repositoryPath = resolve('/fixtures/local system'); + const repository = pathToFileURL(`${repositoryPath}.git`).href; expect( formatSystemInstallReview( From 68a334caa8eb020feb594f2134546e4aa1aa167a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 21:07:05 -0500 Subject: [PATCH 33/33] test(system): assert file URL formatting portably --- src/handlers/systemInstall.test.ts | 45 +++++++++++++++++------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index ece911c..f1e95b6 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -324,26 +324,31 @@ describe('formatSystemInstallReview', () => { const repositoryPath = resolve('/fixtures/local system'); const repository = pathToFileURL(`${repositoryPath}.git`).href; - expect( - formatSystemInstallReview( - 'Local System', - repository, - 'main', - variant, - { - components: [], - requiredComponentCount: 0, - totalComponentCount: 0, - componentParentDestinations: [], - directoryAssetDestinations: [], - fileAssetDestinations: [], - directoryAssetCount: 0, - fileAssetCount: 0, - totalAssetCount: 0, - }, - false, - ), - ).toContain(`Source ${repositoryPath}`); + const review = formatSystemInstallReview( + 'Local System', + repository, + 'main', + variant, + { + components: [], + requiredComponentCount: 0, + totalComponentCount: 0, + componentParentDestinations: [], + directoryAssetDestinations: [], + fileAssetDestinations: [], + directoryAssetCount: 0, + fileAssetCount: 0, + totalAssetCount: 0, + }, + false, + ); + const sourceLine = review + .split('\n') + .find((line) => line.includes('Source')); + + expect(sourceLine).toContain('local system'); + expect(sourceLine).not.toContain('%20'); + expect(sourceLine).not.toContain('.git'); }); });