Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ Options:

- `--force`: Replaces an installed component.
- `--all`: Installs all available components instead of one named component.
- `--dry-run`: Previews planned component installs, dependencies, destinations, and overwrite behavior without copying or removing files.

Examples:

```bash
emulsify component install button
emulsify component install card --dry-run
emulsify component i card --force
emulsify component install --all
```
Expand All @@ -111,14 +113,17 @@ Options:
- `--directory <directory>`: Sets the variant structure directory where the component is created.
- `--format <format>`: Sets the component format. Supported values are `default` and `sdc`.
- `--yes`: Replaces an existing component without an overwrite confirmation prompt.
- `--dry-run`: Previews the destination and generated files without writing, removing, or creating files.

In non-interactive environments, pass both `--directory` and `--format`.

Examples:

```bash
emulsify component create card --directory base --format default
emulsify component create card --directory base --format default --dry-run
emulsify component create teaser --directory molecules --format sdc --yes
emulsify component create teaser --directory molecules --format sdc --dry-run
```

### Component Template Overrides
Expand Down
5 changes: 5 additions & 0 deletions docs/emulsify-info-cli-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,13 @@ Options:

- `--force`: Replaces an installed component.
- `--all`: Installs all available components instead of one named component.
- `--dry-run`: Previews planned component installs, dependencies, destinations, and overwrite behavior without copying or removing files.

Examples:

```bash
emulsify component install button
emulsify component install card --dry-run
emulsify component i card --force
emulsify component install --all
```
Expand All @@ -104,14 +106,17 @@ Options:
- `--directory <directory>`: Sets the variant structure directory where the component is created.
- `--format <format>`: Sets the component format. Supported values are `default` and `sdc`.
- `--yes`: Replaces an existing component without an overwrite confirmation prompt.
- `--dry-run`: Previews the destination and generated files without writing, removing, or creating files.

In non-interactive environments, pass both `--directory` and `--format`.

Examples:

```bash
emulsify component create card --directory base --format default
emulsify component create card --directory base --format default --dry-run
emulsify component create teaser --directory molecules --format sdc --yes
emulsify component create teaser --directory molecules --format sdc --dry-run
```

## Component Template Overrides
Expand Down
57 changes: 57 additions & 0 deletions src/handlers/componentInstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,63 @@ describe('componentInstall', () => {
);
});

it('previews a single component install without copying in dry-run mode', async () => {
await componentInstall('card', { dryRun: true });

expect(confirmMock).not.toHaveBeenCalled();
expect(copyItemFromCacheMock).not.toHaveBeenCalled();
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining('Dry run: component install "card"'),
);
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining('/project/components/00-base/card'),
);
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining('Real run would: copy component'),
);
});

it('previews dependency installs without copying in dry-run mode', async () => {
await componentInstall('button', { dryRun: true });

expect(confirmMock).not.toHaveBeenCalled();
expect(copyItemFromCacheMock).not.toHaveBeenCalled();
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining('Dependencies:\n - icon'),
);
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining(' - icon (dependency of "button")'),
);
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining('/project/components/00-base/icon'),
);
});

it('previews existing component destinations without prompting in dry-run mode', async () => {
pathExistsMock.mockResolvedValue(true);

await componentInstall('card', { dryRun: true });

expect(confirmMock).not.toHaveBeenCalled();
expect(copyItemFromCacheMock).not.toHaveBeenCalled();
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining('Destination exists: yes'),
);
expect(logMock).toHaveBeenCalledWith(
'info',
expect.stringContaining(
'Real run would: prompt before replacing or skipping',
),
);
});

it('installs a component when no project config path is found for destination checks', async () => {
findFileInCurrentPathMock.mockReturnValueOnce(undefined);

Expand Down
147 changes: 132 additions & 15 deletions src/handlers/componentInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import log from '../lib/log.js';
import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js';
import CliError from '../lib/CliError.js';
import type { InstallComponentHandlerOptions } from '@emulsify-cli/handlers';
import type { EmulsifyVariant } from '@emulsify-cli/config';
import installComponentFromCache, {
getComponentDestination,
} from '../util/project/installComponentFromCache.js';
Expand All @@ -12,6 +13,95 @@ import catchLater from '../util/catchLater.js';
import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js';
import { withEmulsifySystem } from './hofs/withEmulsifySystem.js';

type ComponentInstallPlanItem = {
name: string;
isDependency: boolean;
destination: string;
exists: boolean;
action: string;
};

function getDryRunInstallAction(exists: boolean, force: boolean): string {
if (!exists) {
return 'copy component';
}

if (force) {
return 'replace existing destination';
}

return 'prompt before replacing or skipping';
}

async function buildComponentInstallPlan(
variant: EmulsifyVariant,
componentNames: string[],
rootComponentName: string | undefined,
force: boolean,
): Promise<ComponentInstallPlanItem[]> {
const projectConfigPath = findFileInCurrentPath(EMULSIFY_PROJECT_CONFIG_FILE);
if (!projectConfigPath) {
throw new CliError(
'Unable to find an Emulsify project to preview component installation into.',
);
}

const plan: ComponentInstallPlanItem[] = [];
for (const componentName of componentNames) {
const destination = getComponentDestination(
variant,
componentName,
projectConfigPath,
);
const exists = await pathExists(destination);

plan.push({
name: componentName,
isDependency: Boolean(
rootComponentName && componentName !== rootComponentName,
),
destination,
exists,
action: getDryRunInstallAction(exists, force),
});
}

return plan;
}

function logComponentInstallDryRun(
targetLabel: string,
dependencies: string[],
plan: ComponentInstallPlanItem[],
): void {
const dependencyList =
dependencies.length > 0
? dependencies.map((dependency) => ` - ${dependency}`).join('\n')
: ' - none';
const plannedInstalls = plan
.map((item) =>
[
` - ${item.name}${item.isDependency ? ` (dependency of "${targetLabel}")` : ''}`,
` Destination: ${item.destination}`,
` Destination exists: ${item.exists ? 'yes' : 'no'}`,
` Real run would: ${item.action}`,
].join('\n'),
)
.join('\n');

log(
'info',
[
`Dry run: component install "${targetLabel}"`,
'Dependencies:',
dependencyList,
'Planned component installs:',
plannedInstalls,
'No files were copied, removed, or overwritten.',
].join('\n'),
);
}

/**
* Handler for the `component install` command.
*
Expand All @@ -21,7 +111,7 @@ import { withEmulsifySystem } from './hofs/withEmulsifySystem.js';
*/
export default async function componentInstall(
name: string,
{ force, all }: InstallComponentHandlerOptions,
{ force, all, dryRun }: InstallComponentHandlerOptions,
): Promise<void> {
if (!name && !all) {
throw new CliError(
Expand All @@ -36,22 +126,34 @@ export default async function componentInstall(
// If all components are to be installed, spawn promises for installing all available components.
const components: [string, boolean, Promise<void>][] = [];
if (all) {
const componentNames = variantConf.components.map(
(component) => component.name,
);
if (dryRun) {
const plan = await buildComponentInstallPlan(
variantConf,
componentNames,
undefined,
true,
);
logComponentInstallDryRun('all components', [], plan);
return;
}

components.push(
...variantConf.components.map(
(component): [string, boolean, Promise<void>] => [
component.name,
false,
catchLater(
installComponentFromCache(
systemConf,
variantConf,
component.name,
// Force install all components.
true,
),
...componentNames.map((component): [string, boolean, Promise<void>] => [
component,
false,
catchLater(
installComponentFromCache(
systemConf,
variantConf,
component,
// Force install all components.
true,
),
],
),
),
]),
);
}
// If there is only one component to install, add one single promise for the single component.
Expand All @@ -65,6 +167,21 @@ export default async function componentInstall(
`Cannot find the definition for component "${name}".\n\nRun "emulsify component list" to see the full list.`,
);
}

if (dryRun) {
const dependencies = componentsWithDependencies.filter(
(componentName) => componentName !== name,
);
const plan = await buildComponentInstallPlan(
variantConf,
componentsWithDependencies,
name,
Boolean(force),
);
logComponentInstallDryRun(name, dependencies, plan);
return;
}

const projectConfigPath = findFileInCurrentPath(
EMULSIFY_PROJECT_CONFIG_FILE,
);
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ component
'-a --all',
'Use this to install all available components, rather than specifying a single component to install',
)
.option(
'--dry-run',
'Preview component installs without copying or removing files.',
)
.alias('i')
.action(componentInstall);
component
Expand All @@ -124,6 +128,10 @@ component
'-y --yes',
'Skip overwrite confirmation prompts and replace existing components.',
)
.option(
'--dry-run',
'Preview generated component files without writing or removing files.',
)
.alias('c')
.description(
"Create a component from within the current project's system and variant",
Expand Down
3 changes: 3 additions & 0 deletions src/types/handlers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ declare module '@emulsify-cli/handlers' {
export type InstallComponentHandlerOptions = {
force?: boolean;
all?: boolean;
dryRun?: boolean;
};

export type CreateComponentHandlerOptions = {
Expand All @@ -34,5 +35,7 @@ declare module '@emulsify-cli/handlers' {
format?: string;
/** Skip overwrite confirmation prompts and replace existing components. */
yes?: boolean;
/** Preview planned component operations without writing, copying, or removing files. */
dryRun?: boolean;
};
}
Loading
Loading