diff --git a/USAGE.md b/USAGE.md index 09320eb..4d40b05 100644 --- a/USAGE.md +++ b/USAGE.md @@ -131,7 +131,7 @@ Options: Commands: list|ls Lists all of the components that are available for installation within your project based on the system and variant you selected - install|i [name] Install a component from within the current project's system and variant + install|i [name] [options] Install a component from within the current project's system and variant help [command] display help for command ``` @@ -179,4 +179,14 @@ emulsify component install card > Success! The card component has been added to your project. ``` +If you attempt to install a component that already exists within your project, you the installation process will throw an error. However, if you wish to overwrite the existing component, pass the `--force` flag. + +```bash +emulsify component install card + > Error: The component "card" already exists, and force was not passed (--force). + +emulsify component install card --force + > Success! The card component has been added to your project. +``` + That's pretty much it. Have fun, and please feel free to open issues if you discover a bug, or have an improvement to suggest! diff --git a/jest.setup.js b/jest.setup.js index 77ca02f..1f91136 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -4,6 +4,7 @@ jest.mock('simple-git', () => { branch: jest.fn(), checkout: jest.fn(), fetch: jest.fn(), + pull: jest.fn(), }; return jest.fn(() => mockGit); @@ -23,6 +24,8 @@ jest.mock('fs', () => ({ jest.mock('fs-extra', () => ({ copy: jest.fn(), + remove: jest.fn(), + pathExists: jest.fn(), })); jest.mock('child_process'); diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index 9295054..276c632 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -5,6 +5,7 @@ import { EMULSIFY_PROJECT_CONFIG_FILE, } from '../lib/constants'; import type { EmulsifySystem } from '@emulsify-cli/config'; +import type { InstallComponentHandlerOptions } from '@emulsify-cli/handlers'; import getGitRepoNameFromUrl from '../util/getGitRepoNameFromUrl'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig'; import getJsonFromCachedFile from '../util/cache/getJsonFromCachedFile'; @@ -14,7 +15,10 @@ import cloneIntoCache from '../util/cache/cloneIntoCache'; /** * Handler for the `component install` command. */ -export default async function componentInstall(name: string): Promise { +export default async function componentInstall( + name: string, + { force }: InstallComponentHandlerOptions +): Promise { const emulsifyConfig = await getEmulsifyConfig(); if (!emulsifyConfig) { return log( @@ -86,9 +90,13 @@ export default async function componentInstall(name: string): Promise { ); } - await installComponentFromCache(systemConf, variantConf, name); - return log( - 'success', - `Success! The ${name} component has been added to your project.` - ); + try { + await installComponentFromCache(systemConf, variantConf, name, force); + return log( + 'success', + `Success! The ${name} component has been added to your project.` + ); + } catch (e) { + return log('error', (e as Error).toString()); + } } diff --git a/src/handlers/init.test.ts b/src/handlers/init.test.ts index 625608c..d32055c 100644 --- a/src/handlers/init.test.ts +++ b/src/handlers/init.test.ts @@ -65,7 +65,7 @@ describe('init', () => { }); it('can clone an Emulsify starter based on CLI input, and log a success message upon completion', async () => { - expect.assertions(3); + expect.assertions(2); await init('cornflake', `${root}/themes/subDir`, { starter: 'https://github.com/cornflake-ds/cornflake-drupal.git', checkout: '5.6x', @@ -77,14 +77,7 @@ describe('init', () => { '/home/uname/Projects/cornflake/themes/subDir/cornflake', { '--branch': '5.6x' } ); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Created an Emulsify project in /home/uname/Projects/cornflake/themes/subDir/cornflake.' - ); - expect(logMock).toHaveBeenCalledWith( - 'info', - `Emulsify does not come with components by default.\nPlease use "emulsify system install" to select a design system you'd like to use.\nDoing so will install the system's default components, and allow you to install any other components made available by the design system.\nTo see a list of out-of-the-box design systems, run: "emulsify system ls"` - ); + expect(logMock).toHaveBeenCalledTimes(5); }); it('can clone an Emulsify starter without a provided checkout', async () => { diff --git a/src/handlers/init.ts b/src/handlers/init.ts index 08cb07e..f73b6a0 100644 --- a/src/handlers/init.ts +++ b/src/handlers/init.ts @@ -2,6 +2,7 @@ import R from 'ramda'; import { join } from 'path'; import { existsSync, promises as fs } from 'fs'; import simpleGit from 'simple-git'; +import { cyan } from 'chalk'; import type { EmulsifyProjectConfiguration } from '@emulsify-cli/config'; import type { InitHandlerOptions } from '@emulsify-cli/handlers'; @@ -134,7 +135,27 @@ export default async function init( log('success', `Created an Emulsify project in ${target}.`); log( 'info', - `Emulsify does not come with components by default.\nPlease use "emulsify system install" to select a design system you'd like to use.\nDoing so will install the system's default components, and allow you to install any other components made available by the design system.\nTo see a list of out-of-the-box design systems, run: "emulsify system ls"` + `Make sure you install the modules your Emulsify-based theme requires in order to function.` + ); + log( + 'verbose', + ` + - composer require drupal/components + - composer require drupal/emulsify_twig + - drush en components emulsify_twig -y + ` + ); + log( + 'info', + `Once the requirements have been installed, you will need to select a system to use, as Emulsify does not come with components by default.` + ); + log( + 'verbose', + ` + ${cyan('List systems')}: emulsify system list + ${cyan('Install a system')}: emulsify system install "system-name" + ${cyan('Install default system')}: emulsify system install compound + ` ); } catch (e) { log('error', `Unable to pull down ${repository}: ${String(e)}`, EXIT_ERROR); diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 60be2c8..8e67eb4 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -175,7 +175,12 @@ export default async function systemInstall( ({ required }) => required === true ); for (const component of requiredComponents) { - await installComponentFromCache(systemConf, variantConf, component.name); + await installComponentFromCache( + systemConf, + variantConf, + component.name, + true + ); } // Install all global files and folders. diff --git a/src/index.ts b/src/index.ts index 9d97f91..6e16d21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,10 @@ component .action(componentList); component .command('install [name]') + .option( + '-f --force', + 'Use this to overwrite a component that is already installed' + ) .alias('i') .description( "Install a component from within the current project's system and variant" diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index a152d5d..fc9ce34 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -14,4 +14,8 @@ declare module '@emulsify-cli/handlers' { checkout?: string | void; variant?: string | void; }; + + export type InstallComponentHandlerOptions = { + force?: boolean; + }; } diff --git a/src/util/cache/cloneIntoCache.test.ts b/src/util/cache/cloneIntoCache.test.ts index 2810f4b..c55d46b 100644 --- a/src/util/cache/cloneIntoCache.test.ts +++ b/src/util/cache/cloneIntoCache.test.ts @@ -14,6 +14,7 @@ const mkdirMock = fs.promises.mkdir as jest.Mock; const gitCloneMock = git().clone as jest.Mock; const gitFetchMock = git().fetch as jest.Mock; const gitCheckoutMock = git().checkout as jest.Mock; +const gitPullMock = git().pull as jest.Mock; (findFileInCurrentPath as jest.Mock).mockReturnValue( '/home/uname/projects/emulsify' @@ -25,6 +26,7 @@ describe('cloneIntoCache', () => { gitCloneMock.mockClear(); gitFetchMock.mockClear(); gitCheckoutMock.mockClear(); + gitPullMock.mockClear(); }); const cloneOptions = { @@ -33,11 +35,12 @@ describe('cloneIntoCache', () => { }; it('can ensure that the correct branch is checked out, and return early if the cache item already exists', async () => { - expect.assertions(4); + expect.assertions(5); existsSyncMock.mockReturnValueOnce(true); await cloneIntoCache('systems', ['cornflake'])(cloneOptions); expect(existsSyncMock).toHaveBeenCalledTimes(1); expect(gitFetchMock).toHaveBeenCalledTimes(1); + expect(gitPullMock).toHaveBeenCalledTimes(1); expect(gitCheckoutMock).toHaveBeenCalledWith('branch-name'); expect(gitCloneMock).not.toHaveBeenCalled(); }); diff --git a/src/util/cache/cloneIntoCache.ts b/src/util/cache/cloneIntoCache.ts index 6c4972a..cffe3a6 100644 --- a/src/util/cache/cloneIntoCache.ts +++ b/src/util/cache/cloneIntoCache.ts @@ -31,6 +31,7 @@ export default function cloneIntoCache( git = simpleGit(destination); await git.fetch(); await git.checkout(checkout); + await git.pull(); } return; } diff --git a/src/util/cache/copyItemFromCache.test.ts b/src/util/cache/copyItemFromCache.test.ts index 658f3ae..478c127 100644 --- a/src/util/cache/copyItemFromCache.test.ts +++ b/src/util/cache/copyItemFromCache.test.ts @@ -4,7 +4,7 @@ jest.mock('./getCachedItemPath', () => '/home/uname/.emulsify/cache/systems/12345/compound/components/00-base/colors' ) ); -import { copy } from 'fs-extra'; +import { copy, remove } from 'fs-extra'; import copyItemFromCache from './copyItemFromCache'; describe('copyItemFromCache', () => { @@ -19,4 +19,15 @@ describe('copyItemFromCache', () => { '/home/uname/Projects/drupal/web/themes/custom/cornflake/components/00-base/colors' ); }); + it('can remove a destination before copying items from the cache if "force" is true', async () => { + await copyItemFromCache( + 'systems', + ['compound', 'components', '00-base', 'colors'], + '/home/uname/Projects/drupal/web/themes/custom/cornflake/components/00-base/colors', + true + ); + expect(remove).toHaveBeenCalledWith( + '/home/uname/Projects/drupal/web/themes/custom/cornflake/components/00-base/colors' + ); + }); }); diff --git a/src/util/cache/copyItemFromCache.ts b/src/util/cache/copyItemFromCache.ts index 8d47ad8..9d4afc4 100644 --- a/src/util/cache/copyItemFromCache.ts +++ b/src/util/cache/copyItemFromCache.ts @@ -1,5 +1,5 @@ import type { CacheBucket, CacheItemPath } from '@emulsify-cli/cache'; -import { copy } from 'fs-extra'; +import { copy, remove } from 'fs-extra'; import getCachedItemPath from './getCachedItemPath'; /** @@ -10,12 +10,19 @@ import getCachedItemPath from './getCachedItemPath'; * @param itemPath array containing segments of the path to the cached item within the specified bucket. * @param item name of the cached item. * @param destination full path to the destination to which the cached item should be copied. + * @param force if true, removes any file/folder at the destination before copying. */ export default async function copyFileFromCache( bucket: CacheBucket, itemPath: CacheItemPath, - destination: string + destination: string, + force = false ): Promise { const source = getCachedItemPath(bucket, itemPath); + + if (force) { + await remove(destination); + } + return copy(source, destination); } diff --git a/src/util/project/installComponentFromCache.test.ts b/src/util/project/installComponentFromCache.test.ts index 681ddea..cbfffeb 100644 --- a/src/util/project/installComponentFromCache.test.ts +++ b/src/util/project/installComponentFromCache.test.ts @@ -1,6 +1,7 @@ jest.mock('../cache/copyItemFromCache', () => jest.fn()); jest.mock('../fs/findFileInCurrentPath', () => jest.fn()); +import { pathExists } from 'fs-extra'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; import copyItemFromCache from '../cache/copyItemFromCache'; import findFileInCurrentPath from '../fs/findFileInCurrentPath'; @@ -9,6 +10,7 @@ import installComponentFromCache from './installComponentFromCache'; const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue( '/home/uname/Projects/cornflake/web/themes/custom/cornflake/project.emulsify.json' ); +const pathExistsMock = (pathExists as jest.Mock).mockResolvedValue(false); describe('installComponentFromCache', () => { const system = { @@ -69,13 +71,24 @@ describe('installComponentFromCache', () => { ); }); + it('throws an error if the component is already installed, and force is false', async () => { + expect.assertions(1); + pathExistsMock.mockResolvedValueOnce(true); + await expect( + installComponentFromCache(system, variant, 'link', false) + ).rejects.toThrow( + 'The component "link" already exists, and force was not passed (--force).' + ); + }); + it('copies the component from the cached item into the correct destination', async () => { expect.assertions(1); await installComponentFromCache(system, variant, 'link'); expect(copyItemFromCache as jest.Mock).toHaveBeenCalledWith( 'systems', ['compound', './components/00-base', 'link'], - '/home/uname/Projects/cornflake/web/themes/custom/cornflake/components/00-base/link' + '/home/uname/Projects/cornflake/web/themes/custom/cornflake/components/00-base/link', + false ); }); }); diff --git a/src/util/project/installComponentFromCache.ts b/src/util/project/installComponentFromCache.ts index 4fc6565..c69688c 100644 --- a/src/util/project/installComponentFromCache.ts +++ b/src/util/project/installComponentFromCache.ts @@ -1,3 +1,4 @@ +import { pathExists } from 'fs-extra'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; import { join, dirname } from 'path'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants'; @@ -10,12 +11,14 @@ import copyItemFromCache from '../cache/copyItemFromCache'; * @param system EmulsifySystem object depicting the system from which the component should be installed. * @param variant EmulsifyVariant object containing information about the component, where it lives, and how it should be installed. * @param componentName string name of the component that should be installed. + * @param force if true, replaces an existing component (if any). * @returns */ export default async function installComponentFromCache( system: EmulsifySystem, variant: EmulsifyVariant, - componentName: string + componentName: string, + force = false ): Promise { // Gather information about the current Emulsify project. If none exists, // throw an error. @@ -51,9 +54,19 @@ export default async function installComponentFromCache( // Calculate the destination path based on the path to the Emulsify project, the structure of the // component, and the component's name. const destination = join(dirname(path), structure.directory, component.name); + + // If the component already exists within the project, and force is not true, + // throw an error. + if ((await pathExists(destination)) && !force) { + throw new Error( + `The component "${component.name}" already exists, and force was not passed (--force).` + ); + } + return copyItemFromCache( 'systems', [system.name, structure.directory, component.name], - destination + destination, + force ); } diff --git a/src/util/project/installGeneralAssetsFromCache.test.ts b/src/util/project/installGeneralAssetsFromCache.test.ts index fd506aa..10eb4a6 100644 --- a/src/util/project/installGeneralAssetsFromCache.test.ts +++ b/src/util/project/installGeneralAssetsFromCache.test.ts @@ -52,13 +52,15 @@ describe('installGeneralAssetsFromCache', () => { 1, 'systems', ['compound', './components/00-base/00-defaults'], - '/home/uname/Projects/cornflake/web/themes/custom/cornflake/components/00-base/00-defaults' + '/home/uname/Projects/cornflake/web/themes/custom/cornflake/components/00-base/00-defaults', + true ); expect(copyItemMock).toHaveBeenNthCalledWith( 2, 'systems', ['compound', './components/style.scss'], - '/home/uname/Projects/cornflake/web/themes/custom/cornflake/components/style.scss' + '/home/uname/Projects/cornflake/web/themes/custom/cornflake/components/style.scss', + true ); }); diff --git a/src/util/project/installGeneralAssetsFromCache.ts b/src/util/project/installGeneralAssetsFromCache.ts index 68c1bae..3ebf44b 100644 --- a/src/util/project/installGeneralAssetsFromCache.ts +++ b/src/util/project/installGeneralAssetsFromCache.ts @@ -31,7 +31,12 @@ export default async function installGeneralAssetsFromCache( const destination = join(dirname(path), asset.destinationPath); promises.push( catchLater( - copyItemFromCache('systems', [system.name, asset.path], destination) + copyItemFromCache( + 'systems', + [system.name, asset.path], + destination, + true + ) ) ); }