From f9341a144be42d0f39a2eea8d9f3881720e5f3de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:50:38 +0000 Subject: [PATCH 1/7] feat(cli): draw every command as a single session Output was produced by five unrelated renderers, each with its own symbols, colour logic and idea of what a line looks like. Every command now opens a session, threads each step onto one gutter, and closes with a summary or a cancellation. Adds lib/session.ts as the single renderer and rewrites log.ts on top of it, keeping the log(method, message) signature so call sites are unchanged. Prompts return a cancel sentinel rather than throwing, so runPrompt unwraps it into a PromptCancelledError and cancellation is handled in one place. Diagnostics move to stderr through a stream that removes escape sequences when stderr is redirected, so `emulsify init 2> log.txt` writes plain text while stdout stays a terminal. Piped and redirected output is read by scripts, so it gets plain lines with no gutter, symbols or colour, and spinners report phases as lines instead of animating. Commander failures are rendered through the same session and now suggest the closest command, replacing bare `error: unknown command 'x'`. The version is a value scripts read, so it prints as one plain line instead of a drawn box. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Utf5UMza9HgyUvKHWjYmpY --- jest.config.cjs | 4 +- jest.setup.cjs | 41 +++- src/handlers/hofs/withProgressBar.test.ts | 14 -- src/handlers/hofs/withProgressBar.ts | 24 --- src/handlers/hofs/withSession.test.ts | 65 +++++++ src/handlers/hofs/withSession.ts | 33 ++++ src/index.ts | 114 +++++++----- src/lib/log.test.ts | 96 +++++++--- src/lib/log.ts | 62 +++---- src/lib/session.test.ts | 209 +++++++++++++++++++++ src/lib/session.ts | 217 ++++++++++++++++++++++ src/util/prompt/index.test.ts | 70 +++++-- src/util/prompt/index.ts | 65 ++++++- 13 files changed, 847 insertions(+), 167 deletions(-) delete mode 100644 src/handlers/hofs/withProgressBar.test.ts delete mode 100644 src/handlers/hofs/withProgressBar.ts create mode 100644 src/handlers/hofs/withSession.test.ts create mode 100644 src/handlers/hofs/withSession.ts create mode 100644 src/lib/session.test.ts create mode 100644 src/lib/session.ts diff --git a/jest.config.cjs b/jest.config.cjs index 6856a75..900dd29 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -30,9 +30,7 @@ module.exports = { transform: { '\\.[jt]sx?$': ['ts-jest', tsJestOptions], }, - transformIgnorePatterns: [ - 'node_modules[\\\\/](?!(@inquirer|fast-.+)[\\\\/])', - ], + transformIgnorePatterns: ['node_modules[\\\\/](?!(fast-.+)[\\\\/])'], moduleNameMapper: { '^(\\.\\.?\\/.+)\\.js$': '$1', }, diff --git a/jest.setup.cjs b/jest.setup.cjs index 4e1c206..9ea74a4 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -51,4 +51,43 @@ jest.mock('fs-extra', () => ({ })); jest.mock('child_process'); -jest.mock('progress'); + +// @clack/prompts ships ESM only and draws to a real terminal, so it is mocked +// globally. Suites that assert on a specific prompt override this with their +// own factory; the defaults here simply keep every other suite loadable. +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value) => value === CANCEL_SYMBOL, + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + note: jest.fn(), + box: jest.fn(), + text: jest.fn(), + select: jest.fn(), + confirm: jest.fn(), + multiselect: jest.fn(), + spinner: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, + })), + log: { + message: jest.fn(), + info: jest.fn(), + success: jest.fn(), + step: jest.fn(), + warn: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, + }; +}); diff --git a/src/handlers/hofs/withProgressBar.test.ts b/src/handlers/hofs/withProgressBar.test.ts deleted file mode 100644 index 6414154..0000000 --- a/src/handlers/hofs/withProgressBar.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -class PB {} -jest.mock('progress', () => { - return PB; -}); -import withProgressBar from './withProgressBar.js'; - -describe('withProgressBar', () => { - it('creates a ProgressBar object, and passes it into the given handler fn', () => { - expect.assertions(2); - const handler = jest.fn().mockReturnValue('cookies'); - expect(withProgressBar(handler)).toEqual('cookies'); - expect(handler).toHaveBeenCalledWith(new PB()); - }); -}); diff --git a/src/handlers/hofs/withProgressBar.ts b/src/handlers/hofs/withProgressBar.ts deleted file mode 100644 index 8c91bb1..0000000 --- a/src/handlers/hofs/withProgressBar.ts +++ /dev/null @@ -1,24 +0,0 @@ -import ProgressBar from 'progress'; - -// In this case, we actually do not care what the args are, so any is fine. -export type HandlerWithProgress = ( - progress: InstanceType, -) => (...args: any[]) => Promise | void; - -/** - * HOF that passes a progress bar into a given handler fn. - * @param handler fn that, when called, returns a fn that will be executed by commander. - * @returns the function that handler returns. - */ -export default function withProgressBar( - handler: HandlerWithProgress, -): ReturnType { - const progress = new ProgressBar('[:bar] :percent :message', { - total: 100, - complete: '=', - incomplete: ' ', - width: 100, - }); - - return handler(progress); -} diff --git a/src/handlers/hofs/withSession.test.ts b/src/handlers/hofs/withSession.test.ts new file mode 100644 index 0000000..741125b --- /dev/null +++ b/src/handlers/hofs/withSession.test.ts @@ -0,0 +1,65 @@ +jest.mock('../../lib/session', () => ({ + startSession: jest.fn(), + finishSession: jest.fn(), +})); + +import { finishSession, startSession } from '../../lib/session.js'; +import withSession from './withSession.js'; + +const startSessionMock = startSession as jest.Mock; +const finishSessionMock = finishSession as jest.Mock; + +describe('withSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('frames the handler and passes every argument through', async () => { + const action = jest.fn().mockResolvedValue(undefined); + + await withSession('emulsify init', action)('cornflake', './themes', { + yes: true, + }); + + expect(startSessionMock).toHaveBeenCalledWith('emulsify init'); + expect(action).toHaveBeenCalledWith('cornflake', './themes', { + yes: true, + }); + expect(finishSessionMock).toHaveBeenCalledWith(undefined); + }); + + it('uses a returned string as the closing summary', async () => { + const action = jest.fn().mockResolvedValue('cornflake is ready'); + + await withSession('emulsify init', action)(); + + expect(finishSessionMock).toHaveBeenCalledWith('cornflake is ready'); + }); + + it('accepts a synchronous handler', async () => { + await withSession('emulsify cache clear', () => 'cache cleared')(); + + expect(finishSessionMock).toHaveBeenCalledWith('cache cleared'); + }); + + it('opens the session before the handler runs', async () => { + const action = jest.fn(() => { + expect(startSessionMock).toHaveBeenCalledTimes(1); + return undefined; + }); + + await withSession('emulsify system list', action)(); + + expect(action).toHaveBeenCalledTimes(1); + }); + + it('leaves the frame open when the handler fails so the failure reads inside it', async () => { + const failure = new Error('clone failed'); + const action = jest.fn().mockRejectedValue(failure); + + await expect(withSession('emulsify init', action)()).rejects.toBe(failure); + + expect(startSessionMock).toHaveBeenCalledTimes(1); + expect(finishSessionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/handlers/hofs/withSession.ts b/src/handlers/hofs/withSession.ts new file mode 100644 index 0000000..f1871b9 --- /dev/null +++ b/src/handlers/hofs/withSession.ts @@ -0,0 +1,33 @@ +import { finishSession, startSession } from '../../lib/session.js'; + +export type SessionAction = ( + ...args: Args +) => Promise | string | void; + +/** + * HOF that frames a command handler as a session. + * + * The session opens before the handler runs and closes once it resolves, so + * every line the handler logs is threaded onto one gutter. A handler may return + * a string to set the closing summary. + * + * @remarks Failures are rethrown with the frame still open; the top-level + * command runner closes it as cancelled so the failure reads as part of the + * same session rather than as loose output after it. + * + * @param title short command label shown in the session header. + * @param action handler that will be executed by commander. + * @returns a commander action that runs the handler inside a session. + */ +export default function withSession( + title: string, + action: SessionAction, +): (...args: Args) => Promise { + return async (...args: Args): Promise => { + startSession(title); + + const summary = await action(...args); + + finishSession(typeof summary === 'string' ? summary : undefined); + }; +} diff --git a/src/index.ts b/src/index.ts index 594c926..c79e15f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { program } from 'commander'; -import withProgressBar from './handlers/hofs/withProgressBar.js'; +import withSession from './handlers/hofs/withSession.js'; import init from './handlers/init.js'; import systemList from './handlers/systemList.js'; import systemInstall from './handlers/systemInstall.js'; @@ -15,17 +15,58 @@ 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 getTerminalColors from './lib/terminalColors.js'; +import { cancelSession, isSessionOpen, startSession } from './lib/session.js'; +import { isPromptCancelledError } from './util/prompt/index.js'; import { createRequire } from 'module'; -import boxen from 'boxen'; const packageInfo = createRequire(import.meta.url)('../package.json'); +/** + * Render a Commander failure as a session so it matches every other message. + * + * @param text error text produced by Commander, including any suggestion. + */ +function renderCommanderError(text: string): void { + const [summary, ...hints] = text + .split('\n') + .map((line) => line.replace(/^error:\s*/i, '').trim()) + .filter(Boolean); + + if (!summary) { + return; + } + + startSession('emulsify'); + log('error', summary); + + for (const hint of hints) { + log('info', hint); + } + + cancelSession('Run emulsify --help for the full command list.'); +} + +/** + * Close a session that ended in cancellation or failure. + * + * @remarks A failure that happens before any session opened prints on its own + * and needs no closing line. + * + * @param message closing line shown beneath the reported failure. + */ +function closeFailedSession(message: string): void { + if (isSessionOpen()) { + cancelSession(message); + } +} + // Main program commands. -program.name('emulsify').enablePositionalOptions(); +program + .name('emulsify') + .enablePositionalOptions() + .showSuggestionAfterError() + .configureOutput({ outputError: renderCommanderError }); program .command('init [name] [path]') @@ -47,7 +88,7 @@ program '-y, --yes', 'Accept default init values for any missing options without prompting.', ) - .action(withProgressBar(init)); + .action(withSession('emulsify init', init)); program .command('audit [args...]') @@ -65,7 +106,7 @@ system .command('list') .description('List built-in systems available for installation') .alias('ls') - .action(systemList); + .action(withSession('emulsify system list', systemList)); system .command('create [name]') .description('Scaffold a standalone component-system repository') @@ -95,7 +136,7 @@ system '--dry-run', 'Preview the system scaffold without creating files or initializing Git.', ) - .action(systemCreate); + .action(withSession('emulsify system create', systemCreate)); system .command('install [name]') .description('Install a component system or open the guided installer') @@ -119,12 +160,12 @@ system '-y, --yes', 'Accept the final guided-install review without prompting.', ) - .action(systemInstall); + .action(withSession('emulsify system install', systemInstall)); system .command('detach') .description('Detach the configured system and keep project components') .option('-y, --yes', 'Detach without prompting for confirmation.') - .action(systemDetach); + .action(withSession('emulsify system detach', systemDetach)); // Component sub-commands. const component = program @@ -141,7 +182,7 @@ component 'Check the configured system remote before reusing its local cache entry.', ) .alias('ls') - .action(componentList); + .action(withSession('emulsify component list', componentList)); component .command('install [name]') .description('Install one component from the installed system and variant') @@ -159,7 +200,7 @@ component 'Check the configured system remote before reusing its local cache entry.', ) .alias('i') - .action(componentInstall); + .action(withSession('emulsify component install', componentInstall)); component .command('create [name]') .option( @@ -196,7 +237,7 @@ component ) .alias('c') .description('Generate a new local component in the current project') - .action(componentCreate); + .action(withSession('emulsify component create', componentCreate)); component .command('eject-templates [type]') .description('Write editable copies of the built-in component templates') @@ -209,7 +250,9 @@ component '--dry-run', 'Preview template destinations and conflicts without writing files.', ) - .action(componentEjectTemplates); + .action( + withSession('emulsify component eject-templates', componentEjectTemplates), + ); // Cache sub-commands. const cache = program @@ -219,33 +262,11 @@ cache .command('clear') .description('Remove all locally cached Emulsify repositories') .option('--dry-run', 'Report cache contents without removing files.') - .action(cacheClear); + .action(withSession('emulsify cache clear', cacheClear)); -/* - * Generate a styled version message using boxen and colorette. - * This displays the product name and version in a visually appealing format. - * - * ╭ Emulsify CLI ──────╮ - * | │ - * │ Version: 2.0.0 │ - * │ │ - * ╰────────────────────╯ - */ -const { cyan, green } = getTerminalColors(); -const title = cyan(packageInfo.productName); -const message = `Version: ${green(packageInfo.version)}`; +// The version is a value scripts read, so it is printed as one plain line. +program.version(packageInfo.version, '-V, --version', 'Print the CLI version'); -const boxedMessage = boxen(message, { - title: title, - borderStyle: 'round', - padding: 1, - margin: 1, - ...(terminalSupportsColor() - ? { backgroundColor: 'black', borderColor: 'blue' } - : {}), -}); - -program.version(boxedMessage); const rootHelpRequested = process.argv.length <= 2 || (process.argv.length === 3 && @@ -268,19 +289,22 @@ 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; + // Ctrl-C is an expected prompt cancellation, not a command failure. It closes + // the session on its own line rather than being reported as an error. + if (isPromptCancelledError(err)) { + closeFailedSession(err.message); + process.exitCode = err.exitCode; } // Expected CliError failures map their message and exitCode to the process; // unexpected failures still produce a message and a default non-zero exit. else if (err instanceof CliError) { log('error', err.message); + closeFailedSession('No changes were made.'); process.exitCode = err.exitCode; } else { const message = err instanceof Error ? err.message : String(err); log('error', message); + closeFailedSession('No changes were made.'); process.exitCode = 1; } } diff --git a/src/lib/log.test.ts b/src/lib/log.test.ts index bdf887f..b0b7c80 100644 --- a/src/lib/log.test.ts +++ b/src/lib/log.test.ts @@ -1,4 +1,8 @@ -import consolaGlobalInstance from 'consola'; +import { log as render } from '@clack/prompts'; + +import log from './log.js'; + +const renderMock = render as jest.Mocked; /** * Return the provided value unchanged. @@ -9,51 +13,52 @@ import consolaGlobalInstance from 'consola'; */ const identity = (value: T): T => value; -const consolaLogMock = jest - .spyOn(consolaGlobalInstance, 'log') - .mockImplementation(identity); -const consolaInfoMock = jest - .spyOn(consolaGlobalInstance, 'info') - .mockImplementation(identity); -const consolaErrorMock = jest - .spyOn(consolaGlobalInstance, 'error') - .mockImplementation(identity); -const consolaWarnMock = jest - .spyOn(consolaGlobalInstance, 'warn') - .mockImplementation(identity); const exitMock = jest .spyOn(global.process, 'exit') .mockImplementation(identity as () => never); -import log from './log.js'; +const originalStdoutIsTTY = process.stdout.isTTY; + +function setStdoutIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdout, 'isTTY', { + value, + configurable: true, + }); +} describe('log', () => { beforeEach(() => { jest.clearAllMocks(); + setStdoutIsTTY(true); + }); + + afterAll(() => { + setStdoutIsTTY(originalStdoutIsTTY); }); it('can log info messages', () => { expect.assertions(1); log('info', 'information'); - expect(consolaInfoMock).toHaveBeenCalledTimes(1); + expect(renderMock.info).toHaveBeenCalledTimes(1); }); it('can log error messages', () => { expect.assertions(1); log('error', 'error message'); - expect(consolaErrorMock).toHaveBeenCalledTimes(1); + expect(renderMock.error).toHaveBeenCalledTimes(1); }); it('can log warning messages', () => { expect.assertions(1); log('warn', 'warn message'); - expect(consolaWarnMock).toHaveBeenCalledTimes(1); + expect(renderMock.warn).toHaveBeenCalledTimes(1); }); - it('can write other types of messages', () => { - expect.assertions(1); + it('marks success messages with their own symbol', () => { + expect.assertions(2); log('success', 'success message'); - expect(consolaLogMock).toHaveBeenCalledTimes(1); + expect(renderMock.success).toHaveBeenCalledTimes(1); + expect(renderMock.message).not.toHaveBeenCalled(); }); it('does not exit when logging', () => { @@ -64,11 +69,58 @@ describe('log', () => { it('can log debug messages', () => { log('debug', 'debug message'); - expect(consolaLogMock).toHaveBeenCalled(); + expect(renderMock.message).toHaveBeenCalled(); }); it('can log verbose messages', () => { log('verbose', 'verbose message'); - expect(consolaLogMock).toHaveBeenCalled(); + expect(renderMock.message).toHaveBeenCalled(); + }); + + it('writes diagnostics to stderr, not stdout', () => { + expect.assertions(2); + + log('error', 'error message'); + log('warn', 'warn message'); + + expect(renderMock.error).toHaveBeenCalledWith('error message', { + output: expect.anything(), + }); + expect(renderMock.warn).toHaveBeenCalledWith('warn message', { + output: expect.anything(), + }); + }); + + describe('outside a terminal', () => { + beforeEach(() => { + setStdoutIsTTY(false); + }); + + it('writes plain lines with no gutter, symbols, or color', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + log('info', 'base -> button'); + + expect(stdoutWrite).toHaveBeenCalledWith('base -> button\n'); + expect(renderMock.info).not.toHaveBeenCalled(); + + stdoutWrite.mockRestore(); + }); + + it('still separates diagnostics onto stderr', () => { + const stderrWrite = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + log('error', 'clone failed'); + log('warn', 'deprecated flag'); + + expect(stderrWrite).toHaveBeenCalledWith('clone failed\n'); + expect(stderrWrite).toHaveBeenCalledWith('deprecated flag\n'); + + stderrWrite.mockRestore(); + }); }); }); diff --git a/src/lib/log.ts b/src/lib/log.ts index 5c74a3c..3e63ef6 100644 --- a/src/lib/log.ts +++ b/src/lib/log.ts @@ -3,56 +3,50 @@ * Exports methods that MUST be used when writing to the console. */ -import consolaGlobalInstance, { type ConsolaInstance } from 'consola'; -import getTerminalColors from './terminalColors.js'; +import { log as render } from '@clack/prompts'; +import { dim } from 'colorette'; +import { getDiagnosticOutput, isInteractiveOutput } from './session.js'; export type LogMethod = 'info' | 'error' | 'warn' | 'debug' | 'verbose' | 'success'; -const withColor = - (logger: ConsolaInstance['log']) => - (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 - * to be written to the console with consistent methods and colors. This function + * to be written to the console with consistent symbols and colors. This function * only writes log output and never exits the process. * + * @remarks Every message is drawn on the session gutter so a command reads as + * one continuous transcript. Warnings and errors are written to stderr; the rest + * go to stdout. + * * @param method method that should be used to log, such as 'error', or 'warn'. * @param message string containing message to be logged. */ export default function log(method: LogMethod, message: string): void { - // @TODO: add support for --verbose flag, and suppress verbose messages - // by default when --verbose is false or void. + // Piped or redirected output is read by scripts, so it gets plain lines with + // no gutter, no symbols, and no color. + if (!isInteractiveOutput()) { + const stream = + method === 'error' || method === 'warn' ? process.stderr : process.stdout; + + stream.write(`${message}\n`); + return; + } - // emit log message based off of method. switch (method) { case 'error': - withColor(consolaGlobalInstance.error)(method, message); - break; - case 'info': - withColor(consolaGlobalInstance.info)(method, message); - break; + render.error(message, { output: getDiagnosticOutput() }); + return; case 'warn': - withColor(consolaGlobalInstance.warn)(method, message); - break; + render.warn(message, { output: getDiagnosticOutput() }); + return; + case 'info': + render.info(message); + return; + case 'success': + render.success(message); + return; default: - withColor(consolaGlobalInstance.log)(method, message); + render.message(dim(message)); } - - return; } diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts new file mode 100644 index 0000000..69d0448 --- /dev/null +++ b/src/lib/session.test.ts @@ -0,0 +1,209 @@ +import { + cancel as renderCancel, + intro as renderIntro, + note as renderNote, + outro as renderOutro, + spinner as createSpinner, +} from '@clack/prompts'; + +import { + cancelSession, + finishSession, + getDiagnosticOutput, + isSessionOpen, + note, + resetSessionForTesting, + spinner, + startSession, + stripAnsi, +} from './session.js'; + +const introMock = renderIntro as jest.Mock; +const outroMock = renderOutro as jest.Mock; +const cancelMock = renderCancel as jest.Mock; +const noteMock = renderNote as jest.Mock; +const spinnerMock = createSpinner as jest.Mock; + +describe('stripAnsi', () => { + it('removes color and cursor sequences', () => { + expect(stripAnsi('done')).toBe('done'); + expect(stripAnsi('redraw')).toBe('redraw'); + }); + + it('leaves plain text and box-drawing characters untouched', () => { + expect(stripAnsi('│ emulsify init')).toBe('│ emulsify init'); + }); +}); + +describe('getDiagnosticOutput', () => { + it('writes straight to an interactive stream', () => { + const stream = { isTTY: true, write: jest.fn() }; + + expect(getDiagnosticOutput(stream as unknown as NodeJS.WriteStream)).toBe( + stream, + ); + }); + + it('strips color when the stream is redirected to a file', () => { + const write = jest.fn(); + const stream = { isTTY: false, write }; + + const output = getDiagnosticOutput(stream as unknown as NodeJS.WriteStream); + output.write('failed'); + + // Redirecting stderr must not leave escape sequences in the file, even + // while stdout is still a terminal. + expect(write).toHaveBeenCalledWith('failed'); + }); +}); + +const originalStdoutIsTTY = process.stdout.isTTY; + +function setStdoutIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdout, 'isTTY', { + value, + configurable: true, + }); +} + +describe('session framing', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetSessionForTesting(); + setStdoutIsTTY(true); + }); + + afterAll(() => { + setStdoutIsTTY(originalStdoutIsTTY); + }); + + it('opens and closes exactly once', () => { + expect(isSessionOpen()).toBe(false); + + startSession('emulsify init'); + expect(isSessionOpen()).toBe(true); + expect(introMock).toHaveBeenCalledWith('emulsify init'); + + startSession('emulsify init'); + expect(introMock).toHaveBeenCalledTimes(1); + + finishSession('done'); + expect(isSessionOpen()).toBe(false); + expect(outroMock).toHaveBeenCalledWith('done'); + + finishSession('done again'); + expect(outroMock).toHaveBeenCalledTimes(1); + }); + + it('closes a cancelled session with its reason', () => { + startSession('emulsify system detach'); + cancelSession('Nothing was changed.'); + + expect(cancelMock).toHaveBeenCalledWith('Nothing was changed.'); + expect(isSessionOpen()).toBe(false); + }); + + it('ignores a cancellation when no session is open', () => { + cancelSession('Nothing was changed.'); + + expect(cancelMock).not.toHaveBeenCalled(); + }); + + it('renders a titled block', () => { + note('a\nb', 'Next'); + + expect(noteMock).toHaveBeenCalledWith('a\nb', 'Next'); + }); + + it('creates a spinner that reports elapsed time rather than a fake total', () => { + spinner(); + + expect(spinnerMock).toHaveBeenCalledWith({ indicator: 'timer' }); + }); + + describe('outside a terminal', () => { + beforeEach(() => { + setStdoutIsTTY(false); + }); + + it('draws no frame, but still reports the closing summary', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + startSession('emulsify init'); + finishSession('cornflake is ready'); + + expect(introMock).not.toHaveBeenCalled(); + expect(outroMock).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith('cornflake is ready\n'); + + stdoutWrite.mockRestore(); + }); + + it('reports a cancellation on stderr without a frame', () => { + const stderrWrite = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + startSession('emulsify system detach'); + cancelSession('Nothing was changed.'); + + expect(cancelMock).not.toHaveBeenCalled(); + expect(stderrWrite).toHaveBeenCalledWith('Nothing was changed.\n'); + + stderrWrite.mockRestore(); + }); + + it('writes a block as plain lines', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + note('a\nb', 'Next'); + + expect(noteMock).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith('Next\na\nb\n'); + + stdoutWrite.mockRestore(); + }); + + it('reports spinner phases as plain lines instead of animating', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + const progress = spinner(); + progress.start('Cloning the starter'); + progress.message('Installing dependencies'); + progress.stop('Created cornflake'); + progress.clear(); + + expect(spinnerMock).not.toHaveBeenCalled(); + expect(progress.isCancelled).toBe(false); + expect(stdoutWrite).toHaveBeenCalledWith('Cloning the starter\n'); + expect(stdoutWrite).toHaveBeenCalledWith('Installing dependencies\n'); + expect(stdoutWrite).toHaveBeenCalledWith('Created cornflake\n'); + + stdoutWrite.mockRestore(); + }); + + it('sends spinner failures to stderr and skips empty phases', () => { + const stderrWrite = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + const progress = spinner(); + progress.error('Could not finish cloning the starter'); + progress.cancel('Cancelled'); + progress.stop(); + + expect(stderrWrite).toHaveBeenCalledWith( + 'Could not finish cloning the starter\n', + ); + expect(stderrWrite).toHaveBeenCalledWith('Cancelled\n'); + + stderrWrite.mockRestore(); + }); + }); +}); diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..4290359 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,217 @@ +/** + * @file session.ts + * Shared session framing for every command that talks to a person. + * + * A session opens with a title, threads each step onto a single gutter, and + * closes with either a summary or a cancellation. All command output is drawn + * by one renderer so prompts, logs, and spinners share a visual language. + */ + +import { Writable } from 'node:stream'; +import { + cancel as renderCancel, + intro as renderIntro, + note as renderNote, + outro as renderOutro, + spinner as createSpinner, + type SpinnerResult, +} from '@clack/prompts'; + +/** + * Matches SGR and other ANSI escape sequences. + * + * @remarks Built from a string so the two control characters it matches stay + * written as escapes rather than being embedded literally in the source. + */ +const ANSI_PATTERN = new RegExp( + '[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nqry=><]', + 'g', +); + +let sessionIsOpen = false; + +/** + * Remove ANSI escape sequences from a string. + * + * @param value string that may contain escape sequences. + * @returns the string with every escape sequence removed. + */ +export function stripAnsi(value: string): string { + return value.replace(ANSI_PATTERN, ''); +} + +/** + * Wrap a stream so anything written to it is stripped of ANSI escapes first. + * + * @param target stream that should only ever receive plain text. + * @returns a writable that forwards plain text to the target. + */ +function createPlainTextStream(target: NodeJS.WritableStream): Writable { + return new Writable({ + write(chunk, _encoding, callback) { + target.write(stripAnsi(String(chunk))); + callback(); + }, + }); +} + +/** + * Resolve the stream diagnostics should be written to. + * + * @remarks Color is chosen from stdout by the renderer, but warnings and errors + * are written to stderr. When stderr is redirected to a file, escape sequences + * are removed so the file holds plain text even while stdout stays a terminal. + * + * @param stream stderr stream to write to. + * @returns stderr itself when it is a terminal, otherwise a plain-text wrapper. + */ +export function getDiagnosticOutput( + stream: NodeJS.WriteStream = process.stderr, +): Writable { + return stream.isTTY === true ? stream : createPlainTextStream(stream); +} + +/** + * Whether output is being drawn for a person at a terminal. + * + * @remarks Piped and redirected output is read by scripts and other commands, + * so it gets plain lines instead of a drawn session frame. + * + * @param stream stdout stream to test. + * @returns true when stdout is an interactive terminal. + */ +export function isInteractiveOutput( + stream: NodeJS.WriteStream = process.stdout, +): boolean { + return stream.isTTY === true; +} + +/** + * Whether a session frame is currently open. + * + * @returns true between `startSession` and `finishSession`/`cancelSession`. + */ +export function isSessionOpen(): boolean { + return sessionIsOpen; +} + +/** + * Open a session frame. + * + * @param title short command label shown in the session header. + */ +export function startSession(title: string): void { + if (sessionIsOpen) { + return; + } + + sessionIsOpen = true; + + if (isInteractiveOutput()) { + renderIntro(title); + } +} + +/** + * Close an open session frame with an optional summary. + * + * @param message summary shown on the closing line. + */ +export function finishSession(message?: string): void { + if (!sessionIsOpen) { + return; + } + + sessionIsOpen = false; + + if (isInteractiveOutput()) { + renderOutro(message); + return; + } + + if (message) { + process.stdout.write(`${message}\n`); + } +} + +/** + * Close an open session frame as cancelled or failed. + * + * @param message reason shown on the closing line. + */ +export function cancelSession(message: string): void { + if (!sessionIsOpen) { + return; + } + + sessionIsOpen = false; + + if (isInteractiveOutput()) { + renderCancel(message); + return; + } + + process.stderr.write(`${message}\n`); +} + +/** + * Render a bordered block of related lines inside the current session. + * + * @param message block body. + * @param title optional block heading. + */ +export function note(message: string, title?: string): void { + if (isInteractiveOutput()) { + renderNote(message, title); + return; + } + + process.stdout.write(`${title ? `${title}\n` : ''}${message}\n`); +} + +/** + * Write one plain progress line. + * + * @param stream stream to write to. + * @param message line to write, if any. + */ +function writeLine(stream: NodeJS.WriteStream, message?: string): void { + if (message) { + stream.write(`${message}\n`); + } +} + +/** + * Create a spinner bound to the current session. + * + * @remarks Long-running work reports which phase it is in rather than + * pretending to know how much of the total work is complete. Outside a + * terminal there is nothing to animate, so each phase is written as one plain + * line and the log stays readable in CI. + * + * @returns a spinner whose `message` updates the active phase. + */ +export function spinner(): SpinnerResult { + if (isInteractiveOutput()) { + return createSpinner({ indicator: 'timer' }); + } + + return { + start: (message) => writeLine(process.stdout, message), + stop: (message) => writeLine(process.stdout, message), + message: (message) => writeLine(process.stdout, message), + cancel: (message) => writeLine(process.stderr, message), + error: (message) => writeLine(process.stderr, message), + clear: () => undefined, + isCancelled: false, + }; +} + +/** + * Reset session state. + * + * @remarks Exported for tests; production code opens and closes sessions in pairs. + */ +export function resetSessionForTesting(): void { + sessionIsOpen = false; +} diff --git a/src/util/prompt/index.test.ts b/src/util/prompt/index.test.ts index 6577bed..8004208 100644 --- a/src/util/prompt/index.test.ts +++ b/src/util/prompt/index.test.ts @@ -1,9 +1,13 @@ +import { CANCEL_SYMBOL } from '@clack/prompts'; import CliError from '../../lib/CliError.js'; import { - isExitPromptError, + PROMPT_CANCELLED_EXIT_CODE, + PromptCancelledError, isInteractiveTerminal, + isPromptCancelledError, requireInteractiveTerminal, runPrompt, + unwrapPromptResult, } from './index.js'; const originalStdinIsTTY = process.stdin.isTTY; @@ -58,17 +62,41 @@ describe('prompt utilities', () => { }); }); - 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); + describe('isPromptCancelledError', () => { + it('recognizes a cancellation raised by a prompt', () => { + expect(isPromptCancelledError(new PromptCancelledError())).toBe(true); }); it('rejects other errors and non-Error lookalikes', () => { - expect(isExitPromptError(new Error('different failure'))).toBe(false); - expect(isExitPromptError({ name: 'ExitPromptError' })).toBe(false); + expect(isPromptCancelledError(new Error('different failure'))).toBe( + false, + ); + expect(isPromptCancelledError(new CliError('different failure'))).toBe( + false, + ); + expect(isPromptCancelledError({ name: 'PromptCancelledError' })).toBe( + false, + ); + }); + + it('carries the conventional cancellation exit code', () => { + expect(new PromptCancelledError()).toMatchObject({ + exitCode: PROMPT_CANCELLED_EXIT_CODE, + }); + expect(PROMPT_CANCELLED_EXIT_CODE).toBe(130); + }); + }); + + describe('unwrapPromptResult', () => { + it('returns a submitted value unchanged', () => { + expect(unwrapPromptResult('chosen')).toBe('chosen'); + expect(unwrapPromptResult(false)).toBe(false); + }); + + it('converts the cancel sentinel into a cancellation error', () => { + expect(() => unwrapPromptResult(CANCEL_SYMBOL)).toThrow( + PromptCancelledError, + ); }); }); @@ -144,19 +172,29 @@ describe('prompt utilities', () => { expect(prompt).toHaveBeenCalledTimes(1); }); - it('preserves prompt cancellation errors for the top-level handler', async () => { + it('turns a cancelled prompt into a cancellation the top-level handler can report', async () => { setStdinIsTTY(true); - const cancellation = new Error('User force closed the prompt'); - cancellation.name = 'ExitPromptError'; await expect( runPrompt({ - prompt: async () => { - throw cancellation; - }, + prompt: async () => CANCEL_SYMBOL, nonInteractive: { error: 'Pass --value.' }, }), - ).rejects.toBe(cancellation); + ).rejects.toMatchObject({ + name: 'PromptCancelledError', + exitCode: PROMPT_CANCELLED_EXIT_CODE, + }); + }); + + it('turns a cancelled prompt into a cancellation even when a fallback exists', async () => { + setStdinIsTTY(true); + + await expect( + runPrompt({ + prompt: async () => CANCEL_SYMBOL, + nonInteractive: { value: false }, + }), + ).rejects.toBeInstanceOf(PromptCancelledError); }); }); }); diff --git a/src/util/prompt/index.ts b/src/util/prompt/index.ts index 0af3b76..df2d2b1 100644 --- a/src/util/prompt/index.ts +++ b/src/util/prompt/index.ts @@ -1,9 +1,36 @@ +import { CANCEL_SYMBOL, isCancel } from '@clack/prompts'; import CliError from '../../lib/CliError.js'; +/** + * Value a prompt resolves with, including the cancellation sentinel. + * + * @remarks Naming the sentinel concretely rather than as a bare `symbol` keeps + * inference working, so call sites do not have to restate their value type. + */ +export type PromptResult = T | typeof CANCEL_SYMBOL; + +/** Exit code reported when a person cancels a prompt. */ +export const PROMPT_CANCELLED_EXIT_CODE = 130; + +/** Message shown when a person cancels a prompt. */ +export const PROMPT_CANCELLED_MESSAGE = 'Cancelled. Nothing was changed.'; + +/** + * Error thrown when a person cancels a prompt with Ctrl-C or Escape. + * + * @param message user-facing cancellation message. + */ +export class PromptCancelledError extends CliError { + constructor(message: string = PROMPT_CANCELLED_MESSAGE) { + super(message, PROMPT_CANCELLED_EXIT_CODE); + this.name = 'PromptCancelledError'; + } +} + type NonInteractivePromptBehavior = { error: string } | { value: T }; export type RunPromptOptions = { - prompt: () => Promise; + prompt: () => Promise>; nonInteractive: NonInteractivePromptBehavior; accept?: { when: boolean; @@ -31,19 +58,41 @@ export function requireInteractiveTerminal(nonInteractiveError: string): void { } /** - * Identify the error Inquirer throws when a user cancels a prompt with Ctrl-C. + * Identify a cancellation raised by a prompt. + * + * @param error value thrown from a prompt or command handler. + * @returns true when the value represents a cancelled prompt. + */ +export function isPromptCancelledError( + error: unknown, +): error is PromptCancelledError { + return error instanceof PromptCancelledError; +} + +/** + * Convert a prompt result into a value, treating cancellation as an error. + * + * @remarks Prompts return a cancel symbol rather than throwing, so every prompt + * result passes through here to keep cancellation handling in one place. * - * @remarks `@inquirer/prompts` does not export this error class, so use its - * stable Error name without depending directly on Inquirer's internal package. + * @param result value returned by a prompt. + * @returns the prompt value. + * @throws {PromptCancelledError} when the person cancelled the prompt. */ -export function isExitPromptError(error: unknown): error is Error { - return error instanceof Error && error.name === 'ExitPromptError'; +export function unwrapPromptResult(result: PromptResult): T { + if (isCancel(result)) { + throw new PromptCancelledError(); + } + + return result as T; } /** * 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. + * + * @throws {PromptCancelledError} when the person cancelled the prompt. */ export async function runPrompt({ prompt, @@ -56,12 +105,12 @@ export async function runPrompt({ if ('error' in nonInteractive) { requireInteractiveTerminal(nonInteractive.error); - return prompt(); + return unwrapPromptResult(await prompt()); } if (!isInteractiveTerminal()) { return nonInteractive.value; } - return prompt(); + return unwrapPromptResult(await prompt()); } From 06b3f7f6e05151e6ee675e09481db882942e717b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:50:48 +0000 Subject: [PATCH 2/7] feat(init): report real phases and stop styling guidance as a warning The progress bar was hardcoded to 100 cells. With a 44-character status message it rendered 117 characters on an 80-column terminal, wrapped, and redrew to column 1 of the wrapped line, so every tick left the previous one stranded above it. Its five ticks also spent half their travel on a fraction of the work: the 40-tick covered `npm install`, so the bar sat at 50% through the longest wait and then jumped to 90%. A spinner now names the phase it is in, which is the part a person can act on, and it only starts once every value is validated and the target is reserved, so no earlier failure can leave it spinning. Failures stop it before the error is reported. A successful init also ended in a yellow WARN badge, because the Drupal setup instructions were returned with method 'warn'. They are guidance on a run that worked, so they are now blocks on the success path, and the closing summary names the project that was created. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Utf5UMza9HgyUvKHWjYmpY --- src/handlers/init.test.ts | 266 ++++++----- src/handlers/init.ts | 424 +++++++++--------- .../getInitSuccessMessageForPlatform.test.ts | 32 +- .../getInitSuccessMessageForPlatform.ts | 50 ++- 4 files changed, 429 insertions(+), 343 deletions(-) diff --git a/src/handlers/init.test.ts b/src/handlers/init.test.ts index 4279b2f..6ef3688 100644 --- a/src/handlers/init.test.ts +++ b/src/handlers/init.test.ts @@ -4,14 +4,28 @@ jest.mock('../util/fs/loadJsonFile', () => jest.fn()); jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('../util/fs/executeScript', () => jest.fn()); jest.mock('../util/project/installDependencies', () => jest.fn()); -jest.mock('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ + note: jest.fn(), + spinner: jest.fn(), +})); +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value: unknown) => value === CANCEL_SYMBOL, + text: jest.fn(), + select: jest.fn(), + }; +}); import fs from 'fs'; import { join, resolve } from 'path'; import { simpleGit as git } from 'simple-git'; import log from '../lib/log.js'; -import { input, select } from '@inquirer/prompts'; -import ProgressBar from 'progress'; +import { select, text } from '@clack/prompts'; +import { note, spinner } from '../lib/session.js'; import installDependencies from '../util/project/installDependencies.js'; import getPlatformInfo from '../util/platform/getPlatformInfo.js'; import loadJsonFile from '../util/fs/loadJsonFile.js'; @@ -55,16 +69,25 @@ const loadJsonFileMock = (loadJsonFile as jest.Mock).mockResolvedValue( const writeJsonFileMock = writeToJsonFile as jest.Mock; const installDependenciesMock = installDependencies as jest.Mock; const executeScriptMock = executeScript as jest.Mock; -const progressMock = { - tick: jest.fn(), +const spinnerMock = { + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, }; -const progress = progressMock as unknown as InstanceType; -const inputMock = input as jest.Mock; +const noteMock = note as jest.Mock; +const spinnerFactoryMock = (spinner as jest.Mock).mockReturnValue(spinnerMock); +const textMock = text as jest.Mock; const selectMock = select as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; const systemSelectionMessage = [ - 'Next, choose a component system:', - ' emulsify system install', + 'emulsify system install', + '', + 'Pick a component system to render with. Nothing renders until one', + 'is installed.', ].join('\n'); function setStdinIsTTY(value: boolean | undefined) { @@ -85,8 +108,13 @@ describe('init', () => { writeJsonFileMock.mockClear(); installDependenciesMock.mockClear(); executeScriptMock.mockClear(); - progressMock.tick.mockClear(); - inputMock.mockClear(); + spinnerFactoryMock.mockClear(); + spinnerMock.start.mockClear(); + spinnerMock.stop.mockClear(); + spinnerMock.message.mockClear(); + spinnerMock.error.mockClear(); + noteMock.mockClear(); + textMock.mockClear(); selectMock.mockClear(); setStdinIsTTY(false); }); @@ -96,7 +124,7 @@ describe('init', () => { }); it('should execute the returned function', async () => { - await expect(init(progress)()).rejects.toThrow( + await expect(init()).rejects.toThrow( 'Unable to determine the project name. Please provide a valid project name.', ); }); @@ -104,19 +132,20 @@ describe('init', () => { it('should prompt for the name if not provided in an interactive terminal', async () => { expect.assertions(2); setStdinIsTTY(true); - inputMock.mockResolvedValueOnce('cornflake'); + textMock.mockResolvedValueOnce('cornflake'); - await init(progress)(); - expect(input).toHaveBeenCalledTimes(1); - expect(input).toHaveBeenNthCalledWith(1, { - message: 'Project name:', - default: 'emulsifyTheme', + await init(); + expect(text).toHaveBeenCalledTimes(1); + expect(text).toHaveBeenNthCalledWith(1, { + message: 'Project name', + placeholder: 'emulsifyTheme', + defaultValue: 'emulsifyTheme', }); }); it('can detect the platform, and use information about the platform to autodetect the target directory and Emulsify starter', async () => { expect.assertions(4); - await init(progress)('cornflake'); + await init('cornflake'); expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', @@ -156,7 +185,7 @@ describe('init', () => { }; loadJsonFileMock.mockResolvedValueOnce(starterConfig); - await init(progress)('cornflake'); + await init('cornflake'); expect(loadJsonFileMock).toHaveBeenCalledWith(defaultConfigPath); expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { @@ -183,26 +212,22 @@ describe('init', () => { platformMajorVersion: 11, }); - await init(progress)('cornflake'); + await init('cornflake'); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-drupal-starter', drupalTarget, { '--branch': 'main' }, ); - expect(logMock).toHaveBeenCalledWith( - 'warn', + // Guidance on a successful run is a note, never a warning. + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining( 'composer require drupal/emulsify drupal/emulsify_tools', ), + 'Drupal also needs', ); - expect(logMock).toHaveBeenCalledWith( - 'warn', - expect.stringContaining( - 'drupal/emulsify as its base theme and emulsify_tools for Drupal integration', - ), - ); - expect(logMock).toHaveBeenCalledWith('info', systemSelectionMessage); + expect(noteMock).toHaveBeenCalledWith(systemSelectionMessage, 'Next'); + expect(logMock).not.toHaveBeenCalledWith('warn', expect.anything()); }); it('clones the WordPress starter into the detected themes directory when WordPress is auto-detected', async () => { @@ -213,7 +238,7 @@ describe('init', () => { emulsifyParentDirectory: wordpressThemesDirectory, }); - await init(progress)('My Theme'); + await init('My Theme'); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-wordpress-starter', @@ -236,31 +261,51 @@ describe('init', () => { ); }); - it('uses the progress obj to display information on the init process', async () => { - expect.assertions(5); - await init(progress)('cornflake'); - expect(progress.tick).toHaveBeenNthCalledWith(1, 10, { - message: - 'using starter for none as the selected platform, validating config', - }); - expect(progress.tick).toHaveBeenNthCalledWith(2, 10, { - message: 'validation complete, cloning starter', - }); - expect(progress.tick).toHaveBeenNthCalledWith(3, 30, { - message: - 'starter cloned, installing dependencies (this will take a moment)', - }); - expect(progress.tick).toHaveBeenNthCalledWith(4, 40, { - message: 'dependencies installed, executing init script', - }); - expect(progress.tick).toHaveBeenNthCalledWith(5, 10, { - message: 'init script executed, initialization complete', - }); + it('reports each phase of the init process as it starts', async () => { + expect.assertions(4); + await init('cornflake'); + + expect(spinnerMock.start).toHaveBeenCalledWith( + 'Cloning https://github.com/emulsify-ds/emulsify-starter', + ); + expect(spinnerMock.message).toHaveBeenNthCalledWith( + 1, + 'Installing dependencies', + ); + expect(spinnerMock.message).toHaveBeenNthCalledWith( + 2, + 'Running the starter init hook', + ); + expect(spinnerMock.stop).toHaveBeenCalledWith( + `Created cornflake in ${defaultTarget}`, + ); + }); + + it('never starts the spinner before the target is reserved', async () => { + expect.assertions(2); + existsSyncMock.mockReturnValueOnce(true); + + await expect(init('cornflake')).rejects.toThrow( + 'The intended target is already occupied', + ); + expect(spinnerMock.start).not.toHaveBeenCalled(); + }); + + it('stops the spinner before reporting a failure', async () => { + expect.assertions(2); + installDependenciesMock.mockRejectedValueOnce(new Error('npm exploded')); + + await expect(init('cornflake')).rejects.toThrow( + 'Unable to initialize project while installing dependencies', + ); + expect(spinnerMock.error).toHaveBeenCalledWith( + 'Could not finish installing dependencies', + ); }); - it('can clone an Emulsify starter based on CLI input, and log a success message upon completion', async () => { - expect.assertions(3); - await init(progress)('cornflake', `${root}`, { + it('can clone an Emulsify starter based on CLI input, and summarize the result', async () => { + expect.assertions(2); + await init('cornflake', `${root}`, { starter: 'https://github.com/emulsify-ds/emulsify-starter', checkout: 'main', }); @@ -269,18 +314,15 @@ describe('init', () => { defaultTarget, { '--branch': 'main' }, ); - expect(logMock).toHaveBeenCalledTimes(2); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'success', - `Created an Emulsify project in ${defaultTarget}.`, + expect(spinnerMock.stop).toHaveBeenCalledWith( + `Created cornflake in ${defaultTarget}`, ); }); it('can clone an Emulsify starter without a provided checkout', async () => { expect.assertions(1); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)('cornflake', `${root}`, { + await init('cornflake', `${root}`, { starter: 'https://github.com/emulsify-ds/emulsify-starter', platform: 'wordpress', }); @@ -295,7 +337,7 @@ describe('init', () => { expect.assertions(1); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)('cornflake', `${root}`, { + await init('cornflake', `${root}`, { starter: 'git@github.com:example/custom-starter.git', platform: 'drupal', }); @@ -311,7 +353,7 @@ describe('init', () => { expect.assertions(2); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)('cornflake', `${root}`, { + await init('cornflake', `${root}`, { platform: 'wordpress', }); @@ -336,13 +378,13 @@ describe('init', () => { expect.assertions(5); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)(undefined, root, { + await init(undefined, root, { machineName: 'cornflake', starter: 'https://github.com/emulsify-ds/emulsify-starter', platform: 'drupal', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', @@ -359,9 +401,9 @@ describe('init', () => { repository: 'https://github.com/emulsify-ds/emulsify-starter', }, }); - expect(logMock).not.toHaveBeenCalledWith( - 'warn', + expect(noteMock).not.toHaveBeenCalledWith( expect.stringContaining('composer require drupal/emulsify'), + expect.anything(), ); }); @@ -371,16 +413,20 @@ describe('init', () => { getPlatformInfoMock.mockReturnValueOnce(undefined); selectMock.mockResolvedValueOnce('none'); - await init(progress)('cornflake', root, { + await init('cornflake', root, { starter: 'https://github.com/emulsify-ds/emulsify-starter', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenNthCalledWith(1, { - message: 'Platform:', - choices: ['drupal', 'wordpress', 'none'], - default: 'drupal', + message: 'Platform', + options: [ + { value: 'drupal', label: 'Drupal', hint: 'Drupal 10 or 11 theme' }, + { value: 'wordpress', label: 'WordPress', hint: 'WordPress theme' }, + { value: 'none', label: 'None', hint: 'standalone front end' }, + ], + initialValue: 'drupal', }); expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { project: { @@ -392,9 +438,9 @@ describe('init', () => { repository: 'https://github.com/emulsify-ds/emulsify-starter', }, }); - expect(logMock).not.toHaveBeenCalledWith( - 'warn', + expect(noteMock).not.toHaveBeenCalledWith( expect.stringContaining('composer require drupal/emulsify'), + expect.anything(), ); }); @@ -402,12 +448,12 @@ describe('init', () => { expect.assertions(4); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)(undefined, undefined, { + await init(undefined, undefined, { starter: 'https://github.com/emulsify-ds/emulsify-starter', yes: true, }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', @@ -431,21 +477,21 @@ describe('init', () => { it('installs the project dependencies', async () => { expect.assertions(1); - await init(progress)('cornflake'); + await init('cornflake'); expect(installDependencies).toHaveBeenCalledWith(defaultTarget); }); it('executes the init script within the Emulsify starter, if it exists', async () => { expect.assertions(1); existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); - await init(progress)('cornflake'); + await init('cornflake'); expect(executeScript).toHaveBeenCalledWith(defaultInitHookPath); }); it('throws if no valid platform name is detectable', async () => { expect.assertions(1); getPlatformInfoMock.mockReturnValueOnce(undefined); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( '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.', ); }); @@ -453,7 +499,7 @@ describe('init', () => { it('reports the clone phase and removes a partial target when cloning fails', async () => { gitCloneMock.mockRejectedValueOnce(new Error('Does not exist!')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while cloning the starter: Error: Does not exist!. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -467,7 +513,7 @@ describe('init', () => { new Error('Invalid JSON in project.emulsify.json'), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while reading the starter project configuration: Error: Invalid JSON in project.emulsify.json. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -479,7 +525,7 @@ describe('init', () => { it('reports the configuration-write phase and removes the incomplete target', async () => { writeJsonFileMock.mockRejectedValueOnce(new Error('config write failed')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while writing the project configuration: Error: config write failed. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -493,7 +539,7 @@ describe('init', () => { new Error('npm install failed'), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while installing dependencies: Error: npm install failed. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -506,7 +552,7 @@ describe('init', () => { existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); executeScriptMock.mockRejectedValueOnce(new Error('hook failed')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while executing the starter init hook: Error: hook failed. Removed the incomplete target "${defaultTarget}".`, ); expect(executeScriptMock).toHaveBeenCalledWith(defaultInitHookPath); @@ -522,7 +568,7 @@ describe('init', () => { ); rmMock.mockRejectedValueOnce(new Error('EACCES')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while installing dependencies: Error: npm install failed. Automatic cleanup of the incomplete target "${defaultTarget}" also failed: Error: EACCES. Remove it manually before retrying.`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -534,7 +580,7 @@ describe('init', () => { it('rolls back the target when removing starter Git metadata fails', async () => { rmMock.mockRejectedValueOnce(new Error('Git metadata is locked')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while removing the starter Git metadata: Error: Git metadata is locked. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenNthCalledWith(1, defaultGitPath, { @@ -547,13 +593,11 @@ describe('init', () => { }); it('does not roll back a completed project when success logging fails', async () => { - logMock.mockImplementationOnce(() => { + noteMock.mockImplementationOnce(() => { throw new Error('terminal output failed'); }); - await expect(init(progress)('cornflake')).rejects.toThrow( - 'terminal output failed', - ); + await expect(init('cornflake')).rejects.toThrow('terminal output failed'); expect(rmMock).toHaveBeenCalledTimes(1); expect(rmMock).toHaveBeenCalledWith(defaultGitPath, { recursive: true }); expect(rmMock).not.toHaveBeenCalledWith(defaultTarget, { @@ -567,7 +611,7 @@ describe('init', () => { getPlatformInfoMock.mockReturnValueOnce({ name: 'drupal', }); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( 'Unable to find a directory to put Emulsify in. Please specify a directory using the "path" argument: emulsify init myTheme ./themes', ); }); @@ -577,7 +621,7 @@ describe('init', () => { getPlatformInfoMock.mockReturnValueOnce({ name: 'invalid', }); - await expect(init(progress)('cornflake', root)).rejects.toThrow( + await expect(init('cornflake', root)).rejects.toThrow( 'Unsupported platform "invalid". Supported platforms are "none", "drupal", and "wordpress".', ); }); @@ -585,7 +629,7 @@ describe('init', () => { it('throws if the target directory already exists', async () => { expect.assertions(4); existsSyncMock.mockReturnValueOnce(true); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `The intended target is already occupied: ${defaultTarget}`, ); expect(mkdirMock).not.toHaveBeenCalled(); @@ -598,7 +642,7 @@ describe('init', () => { Object.assign(new Error('already exists'), { code: 'EEXIST' }), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `The intended target is already occupied: ${defaultTarget}`, ); expect(gitCloneMock).not.toHaveBeenCalled(); @@ -610,7 +654,7 @@ describe('init', () => { Object.assign(new Error('permission denied'), { code: 'EACCES' }), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while creating the target directory "${defaultTarget}": Error: permission denied`, ); expect(gitCloneMock).not.toHaveBeenCalled(); @@ -620,30 +664,36 @@ describe('init', () => { it('should prompt for all info if name is missing', async () => { setStdinIsTTY(true); getPlatformInfoMock.mockReturnValueOnce(undefined); - inputMock.mockResolvedValueOnce('new-project').mockResolvedValueOnce(root); + textMock.mockResolvedValueOnce('new-project').mockResolvedValueOnce(root); selectMock.mockResolvedValueOnce('drupal'); - await init(progress)(); + await init(); - expect(input).toHaveBeenCalledTimes(2); - expect(input).toHaveBeenNthCalledWith(1, { - message: 'Project name:', - default: 'emulsifyTheme', + expect(text).toHaveBeenCalledTimes(2); + expect(text).toHaveBeenNthCalledWith(1, { + message: 'Project name', + placeholder: 'emulsifyTheme', + defaultValue: 'emulsifyTheme', }); - expect(input).toHaveBeenNthCalledWith(2, { - message: 'Target directory:', - default: './', + expect(text).toHaveBeenNthCalledWith(2, { + message: 'Target directory', + placeholder: './', + defaultValue: './', }); expect(select).toHaveBeenCalledTimes(1); expect(select).toHaveBeenNthCalledWith(1, { - message: 'Platform:', - choices: ['drupal', 'wordpress', 'none'], - default: 'drupal', + message: 'Platform', + options: [ + { value: 'drupal', label: 'Drupal', hint: 'Drupal 10 or 11 theme' }, + { value: 'wordpress', label: 'WordPress', hint: 'WordPress theme' }, + { value: 'none', label: 'None', hint: 'standalone front end' }, + ], + initialValue: 'drupal', }); expect(gitCloneMock).toHaveBeenCalled(); - expect(logMock).not.toHaveBeenCalledWith( - 'warn', + expect(noteMock).not.toHaveBeenCalledWith( expect.stringContaining('composer require drupal/emulsify'), + expect.anything(), ); }); }); diff --git a/src/handlers/init.ts b/src/handlers/init.ts index 9bb5ff8..d908b65 100644 --- a/src/handlers/init.ts +++ b/src/handlers/init.ts @@ -1,8 +1,7 @@ import { join } from 'path'; import { existsSync, promises as fs } from 'fs'; import { simpleGit } from 'simple-git'; -import ProgressBar from 'progress'; -import { input, select } from '@inquirer/prompts'; +import { select, text } from '@clack/prompts'; import type { EmulsifyProjectConfiguration, @@ -24,6 +23,7 @@ import executeScript from '../util/fs/executeScript.js'; import getInitSuccessMessageForPlatform from '../util/platform/getInitSuccessMessageForPlatform.js'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; +import { note, spinner } from '../lib/session.js'; import { isPlatform } from '../util/platform/platformCompatibility.js'; import { runPrompt } from '../util/prompt/index.js'; @@ -33,10 +33,14 @@ export const DIRECTORY = 1; const DEFAULT_PROJECT_NAME = 'emulsifyTheme'; const DEFAULT_PLATFORM: Platform = 'drupal'; const PLATFORM_CHOICES = [ - 'drupal', - 'wordpress', - 'none', -] as const satisfies readonly Platform[]; + { value: 'drupal', label: 'Drupal', hint: 'Drupal 10 or 11 theme' }, + { value: 'wordpress', label: 'WordPress', hint: 'WordPress theme' }, + { value: 'none', label: 'None', hint: 'standalone front end' }, +] as const satisfies readonly { + value: Platform; + label: string; + hint: string; +}[]; type InitializationPhase = | 'cloning the starter' @@ -89,229 +93,235 @@ async function rollbackFailedInitialization( * * @throws {CliError} if required project information cannot be determined or initialization fails. */ -export default function init(progress: InstanceType) { - return async ( - name?: string, - targetDirectory?: string, - options?: InitHandlerOptions, - ): Promise => { - // Load information about the project and platform. - const { name: autoPlatformName, emulsifyParentDirectory } = - (await getPlatformInfo()) || {}; - const isDetectedDrupalProject = autoPlatformName === 'drupal'; - 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) { - 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, - }, - }); - } +export default async function init( + name?: string, + targetDirectory?: string, + options?: InitHandlerOptions, +): Promise { + // Load information about the project and platform. + const { name: autoPlatformName, emulsifyParentDirectory } = + (await getPlatformInfo()) || {}; + const isDetectedDrupalProject = autoPlatformName === 'drupal'; + 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) { + projectName = await runPrompt({ + prompt: () => + text({ + message: 'Project name', + placeholder: DEFAULT_PROJECT_NAME, + defaultValue: 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) { - throw new CliError( - 'Unable to determine the project name. Please provide a valid project name.', - ); - } + if (!projectName) { + throw new CliError( + 'Unable to determine the project name. Please provide a valid project name.', + ); + } - let targetParent = targetDirectory || emulsifyParentDirectory; - if (!targetParent) { - 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: './' }, - }); - } + let targetParent = targetDirectory || emulsifyParentDirectory; + if (!targetParent) { + targetParent = await runPrompt({ + prompt: () => + text({ + message: 'Target directory', + placeholder: './', + defaultValue: './', + }), + // 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. - const requestedPlatformName = options?.platform || autoPlatformName; - let platformName = isPlatform(requestedPlatformName) - ? requestedPlatformName - : undefined; - if (requestedPlatformName && !platformName) { - throw new CliError( - `Unsupported platform "${requestedPlatformName}". Supported platforms are "none", "drupal", and "wordpress".`, - ); - } - if (!platformName) { - 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 no platform name is given, and none can be detected, exit and error. + const requestedPlatformName = options?.platform || autoPlatformName; + let platformName = isPlatform(requestedPlatformName) + ? requestedPlatformName + : undefined; + if (requestedPlatformName && !platformName) { + throw new CliError( + `Unsupported platform "${requestedPlatformName}". Supported platforms are "none", "drupal", and "wordpress".`, + ); + } + if (!platformName) { + platformName = await runPrompt({ + prompt: () => + select({ + message: 'Platform', + options: [...PLATFORM_CHOICES], + initialValue: 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) { - throw new CliError( - '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.', - ); - } + if (!platformName) { + throw new CliError( + '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.', + ); + } - progress.tick(10, { - message: `using starter for ${platformName} as the selected platform, validating config`, - }); + // Choose a folder name. If no machineName is given, create one using the project name. + const machineName = + options?.machineName || strToMachineName(projectName, platformName); - // Choose a folder name. If no machineName is given, create one using the project name. - const machineName = - options?.machineName || strToMachineName(projectName, platformName); + // Collection information about the starter kit, such as the target directory, + // starter repository, and checkout version. + const starters = getAvailableStarters(); + const starter = starters.find((s) => s.platform === platformName); - // Collection information about the starter kit, such as the target directory, - // starter repository, and checkout version. - const starters = getAvailableStarters(); - const starter = starters.find((s) => s.platform === platformName); + const target = targetParent ? join(targetParent, machineName) : undefined; - const target = targetParent ? join(targetParent, machineName) : undefined; + const repository = options?.starter || starter?.repository; + const checkout = + options?.checkout || (options?.starter ? undefined : starter?.checkout); - const repository = options?.starter || starter?.repository; - const checkout = - options?.checkout || (options?.starter ? undefined : starter?.checkout); + if (!target) { + throw new CliError( + 'Unable to find a directory to put Emulsify in. Please specify a directory using the "path" argument: emulsify init myTheme ./themes', + ); + } - if (!target) { - throw new CliError( - 'Unable to find a directory to put Emulsify in. Please specify a directory using the "path" argument: emulsify init myTheme ./themes', - ); - } + if (!repository) { + throw new CliError( + `Unable to find an Emulsify starter for your project. Please specify one using the --starter flag: emulsify init myTheme --starter ${ + getAvailableStarters()[0].repository + }`, + ); + } - if (!repository) { - throw new CliError( - `Unable to find an Emulsify starter for your project. Please specify one using the --starter flag: emulsify init myTheme --starter ${ - getAvailableStarters()[0].repository - }`, - ); - } + if (existsSync(target)) { + throw new CliError(`The intended target is already occupied: ${target}`); + } - if (existsSync(target)) { + // Reserve the target atomically before cloning so rollback only ever + // removes a directory created by this command run. Git can clone into an + // existing empty directory. + try { + await fs.mkdir(target); + } catch (error) { + if (isAlreadyExistsError(error)) { throw new CliError(`The intended target is already occupied: ${target}`); } - // Reserve the target atomically before cloning so rollback only ever - // removes a directory created by this command run. Git can clone into an - // existing empty directory. - try { - await fs.mkdir(target); - } catch (error) { - if (isAlreadyExistsError(error)) { - throw new CliError( - `The intended target is already occupied: ${target}`, - ); - } - - throw new CliError( - `Unable to initialize project while creating the target directory "${target}": ${String(error)}`, - ); - } + throw new CliError( + `Unable to initialize project while creating the target directory "${target}": ${String(error)}`, + ); + } + + let phase: InitializationPhase = 'cloning the starter'; - let phase: InitializationPhase = 'cloning the starter'; + // The spinner only runs once every value is validated and the target is + // reserved, so no failure above this point can leave it spinning. + const progress = spinner(); - try { - progress.tick(10, { message: 'validation complete, cloning starter' }); + try { + progress.start(`Cloning ${repository}`); + + // Clone the Emulsify starter into the target directory, and checkout + // the correct tag/branch/commit. + await git.clone( + repository, + target, + checkout + ? { + '--branch': checkout, + } + : {}, + ); - // Clone the Emulsify starter into the target directory, and checkout - // the correct tag/branch/commit. - await git.clone( + // Preserve starter-provided settings while replacing the values that + // describe this concrete generated project. + phase = 'reading the starter project configuration'; + const configPath = join(target, EMULSIFY_PROJECT_CONFIG_FILE); + const starterConfig = + await loadJsonFile>(configPath); + + phase = 'writing the project configuration'; + await writeToJsonFile(configPath, { + ...starterConfig, + project: { + ...starterConfig?.project, + platform: platformName, + name: projectName, + machineName, + }, + starter: { + ...starterConfig?.starter, repository, - target, - checkout - ? { - '--branch': checkout, - } - : {}, - ); - - // Preserve starter-provided settings while replacing the values that - // describe this concrete generated project. - phase = 'reading the starter project configuration'; - const configPath = join(target, EMULSIFY_PROJECT_CONFIG_FILE); - const starterConfig = - await loadJsonFile>(configPath); - - phase = 'writing the project configuration'; - await writeToJsonFile(configPath, { - ...starterConfig, - project: { - ...starterConfig?.project, - platform: platformName, - name: projectName, - machineName, - }, - starter: { - ...starterConfig?.starter, - repository, - }, - }); - - progress.tick(30, { - message: - 'starter cloned, installing dependencies (this will take a moment)', - }); - - // Install project dependencies. - phase = 'installing dependencies'; - await installDependencies(target); - - progress.tick(40, { - message: 'dependencies installed, executing init script', - }); - - // Execute the init script, if one exists. - const initPath = join( - target, - EMULSIFY_PROJECT_HOOK_FOLDER, - EMULSIFY_PROJECT_HOOK_INIT, - ); - if (existsSync(initPath)) { - phase = 'executing the starter init hook'; - await executeScript(initPath); - } - - // Remove the .git directory, as this is a starter kit. This step - // should happen after dependencies are installed, and init scripts are - // executed, otherwise git-reliant dev deps in the starter may error out. - phase = 'removing the starter Git metadata'; - await fs.rm(join(target, '.git'), { recursive: true }); - } catch (e) { - throw await rollbackFailedInitialization(target, phase, e); + }, + }); + + progress.message('Installing dependencies'); + + // Install project dependencies. + phase = 'installing dependencies'; + await installDependencies(target); + + progress.message('Running the starter init hook'); + + // Execute the init script, if one exists. + const initPath = join( + target, + EMULSIFY_PROJECT_HOOK_FOLDER, + EMULSIFY_PROJECT_HOOK_INIT, + ); + if (existsSync(initPath)) { + phase = 'executing the starter init hook'; + await executeScript(initPath); } - // The filesystem transaction is complete. Keep display-only work outside - // the rollback boundary so a terminal/logging failure cannot delete a - // successfully initialized project. - progress.tick(10, { - message: 'init script executed, initialization complete', - }); + // Remove the .git directory, as this is a starter kit. This step + // should happen after dependencies are installed, and init scripts are + // executed, otherwise git-reliant dev deps in the starter may error out. + phase = 'removing the starter Git metadata'; + await fs.rm(join(target, '.git'), { recursive: true }); + } catch (e) { + // Stop the spinner before rollback so the failure is not reported beneath + // an indicator that is still spinning. + progress.error(`Could not finish ${phase}`); + throw await rollbackFailedInitialization(target, phase, e); + } - log('success', `Created an Emulsify project in ${target}.`); - getInitSuccessMessageForPlatform(platformName, target, { + // The filesystem transaction is complete. Keep display-only work outside + // the rollback boundary so a terminal/logging failure cannot delete a + // successfully initialized project. + progress.stop(`Created ${machineName} in ${target}`); + + for (const { method, message, title } of getInitSuccessMessageForPlatform( + platformName, + target, + { includeDrupalInstallReminder: isDetectedDrupalProject && platformName === 'drupal', - }).map(({ method, message }) => log(method, message)); - }; + }, + )) { + if (title) { + note(message, title); + continue; + } + + log(method, message); + } + + return `${machineName} is ready`; } diff --git a/src/util/platform/getInitSuccessMessageForPlatform.test.ts b/src/util/platform/getInitSuccessMessageForPlatform.test.ts index 015532e..627f218 100644 --- a/src/util/platform/getInitSuccessMessageForPlatform.test.ts +++ b/src/util/platform/getInitSuccessMessageForPlatform.test.ts @@ -1,17 +1,18 @@ import getInitSuccessMessageForPlatform from './getInitSuccessMessageForPlatform.js'; const systemSelectionMessage = [ - 'Next, choose a component system:', - ' emulsify system install', + 'emulsify system install', + '', + 'Pick a component system to render with. Nothing renders until one', + 'is installed.', ].join('\n'); const drupalIntegrationMessage = [ - 'Detected a Drupal project.', - '', - 'Install the required Drupal packages with Composer:', - ' composer require drupal/emulsify drupal/emulsify_tools', - ' drush en emulsify_tools -y', + 'composer require drupal/emulsify drupal/emulsify_tools', + 'drush en emulsify_tools -y', '', - 'The generated Drupal starter uses drupal/emulsify as its base theme and emulsify_tools for Drupal integration, so both packages must exist in the Drupal codebase.', + 'The generated starter uses drupal/emulsify as its base theme and', + 'emulsify_tools for Drupal integration, so both packages must exist', + 'in the Drupal codebase.', ].join('\n'); describe('getInitSuccessMessageForPlatform', () => { @@ -23,12 +24,14 @@ describe('getInitSuccessMessageForPlatform', () => { }), ).toEqual([ { - method: 'warn', + method: 'info', message: drupalIntegrationMessage, + title: 'Drupal also needs', }, { method: 'info', message: systemSelectionMessage, + title: 'Next', }, ]); }); @@ -42,6 +45,7 @@ describe('getInitSuccessMessageForPlatform', () => { { method: 'info', message: systemSelectionMessage, + title: 'Next', }, ]); expect(messages).not.toContain('composer require drupal/'); @@ -72,6 +76,7 @@ describe('getInitSuccessMessageForPlatform', () => { { method: 'info', message: systemSelectionMessage, + title: 'Next', }, ]); expect(messages).not.toContain('Drupal integration module'); @@ -91,6 +96,15 @@ describe('getInitSuccessMessageForPlatform', () => { ); }); + it('never reports success-path guidance as a warning', () => { + expect.assertions(1); + const methods = getInitSuccessMessageForPlatform('drupal', '/directory', { + includeDrupalInstallReminder: true, + }).map(({ method }) => method); + + expect(methods).not.toContain('warn'); + }); + it('returns an empty array if the given platform does not correspond with any success messages', () => { expect.assertions(1); expect(getInitSuccessMessageForPlatform('java', '/directory')).toEqual([]); diff --git a/src/util/platform/getInitSuccessMessageForPlatform.ts b/src/util/platform/getInitSuccessMessageForPlatform.ts index 3253153..3265b99 100644 --- a/src/util/platform/getInitSuccessMessageForPlatform.ts +++ b/src/util/platform/getInitSuccessMessageForPlatform.ts @@ -1,55 +1,66 @@ import { LogMethod } from 'src/lib/log.js'; +const DRUPAL_INTEGRATION_TITLE = 'Drupal also needs'; const DRUPAL_INTEGRATION_MESSAGE = [ - 'Detected a Drupal project.', + 'composer require drupal/emulsify drupal/emulsify_tools', + 'drush en emulsify_tools -y', '', - 'Install the required Drupal packages with Composer:', - ' composer require drupal/emulsify drupal/emulsify_tools', - ' drush en emulsify_tools -y', - '', - 'The generated Drupal starter uses drupal/emulsify as its base theme and emulsify_tools for Drupal integration, so both packages must exist in the Drupal codebase.', + 'The generated starter uses drupal/emulsify as its base theme and', + 'emulsify_tools for Drupal integration, so both packages must exist', + 'in the Drupal codebase.', ].join('\n'); +const SYSTEM_SELECTION_TITLE = 'Next'; const SYSTEM_SELECTION_MESSAGE = [ - 'Next, choose a component system:', - ' emulsify system install', + 'emulsify system install', + '', + 'Pick a component system to render with. Nothing renders until one', + 'is installed.', ].join('\n'); +export type InitSuccessMessage = { + /** Log level used when the message is written as a single line. */ + method: LogMethod; + /** Message body. */ + message: string; + /** Heading shown when the message is rendered as a block. */ + title?: string; +}; + type InitSuccessMessageOptions = { includeDrupalInstallReminder?: boolean; }; /** - * Returns the init success log messages for a given platform. + * Returns the init success messages for a given platform. + * + * @remarks These describe what to do next on a run that succeeded, so they are + * written as guidance rather than as warnings. * * @param platform name of platform. * @param options.includeDrupalInstallReminder whether to include Composer package guidance for an auto-detected Drupal project. - * @returns array containing objects with a log method, and message. + * @returns array containing objects with a log method, message, and optional block title. */ export default function getInitSuccessMessageForPlatform( platform: string, _directory: string, options: InitSuccessMessageOptions = {}, -): { - method: LogMethod; - message: string; -}[] { +): InitSuccessMessage[] { if (platform === 'drupal') { - const messages: { - method: LogMethod; - message: string; - }[] = []; + const messages: InitSuccessMessage[] = []; if (options.includeDrupalInstallReminder) { messages.push({ - method: 'warn', + method: 'info', message: DRUPAL_INTEGRATION_MESSAGE, + title: DRUPAL_INTEGRATION_TITLE, }); } messages.push({ method: 'info', message: SYSTEM_SELECTION_MESSAGE, + title: SYSTEM_SELECTION_TITLE, }); return messages; @@ -60,6 +71,7 @@ export default function getInitSuccessMessageForPlatform( { method: 'info', message: SYSTEM_SELECTION_MESSAGE, + title: SYSTEM_SELECTION_TITLE, }, ]; } From c1df8dd8f58926de937f3ab070ca7d9863edef82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:51:01 +0000 Subject: [PATCH 3/7] feat(component): group the component list and say what each one pulls in `component list` printed one line per component with nothing on it but the structure and the name. For Compound that is 29 near-identical lines, while the system config already records which components are required and what each one depends on, so installing `accordion` quietly brings five more with it. The list is now grouped by structure, counts the required components in each group, and names the dependencies a component brings with it. `system list` aligns its repositories into a column. Both render as one block rather than a run of loose lines, and both close with a summary that says what to run next. `cache clear` reports its outcome the same way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Utf5UMza9HgyUvKHWjYmpY --- src/handlers/cacheClear.test.ts | 37 ++++++----- src/handlers/cacheClear.ts | 21 ++---- src/handlers/componentList.test.ts | 61 ++++++++++++++++- src/handlers/componentList.ts | 103 +++++++++++++++++++++++++++-- src/handlers/systemList.test.ts | 67 +++++++++++++------ src/handlers/systemList.ts | 37 +++++++++-- 6 files changed, 260 insertions(+), 66 deletions(-) diff --git a/src/handlers/cacheClear.test.ts b/src/handlers/cacheClear.test.ts index 39eb1bd..cb194f6 100644 --- a/src/handlers/cacheClear.test.ts +++ b/src/handlers/cacheClear.test.ts @@ -45,28 +45,24 @@ describe('cacheClear', () => { it('reports bucket and entry counts before removing the cache', async () => { mockPopulatedCache(); - await cacheClear(); + await expect(cacheClear()).resolves.toBe( + 'Cleared the Emulsify cache: removed 2 buckets and 3 entries.', + ); expect(rmMock).toHaveBeenCalledWith('/home/uname/.emulsify/cache', { recursive: true, force: true, }); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Cleared the Emulsify cache: removed 2 buckets and 3 entries.', - ); }); it('reports cache contents without removing them during a dry run', async () => { mockPopulatedCache(); - await cacheClear({ dryRun: true }); - - expect(rmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith( - 'info', + await expect(cacheClear({ dryRun: true })).resolves.toBe( 'Dry run: the Emulsify cache contains 2 buckets and 3 entries. No files were removed.', ); + + expect(rmMock).not.toHaveBeenCalled(); }); it('exits successfully when the cache directory does not exist', async () => { @@ -74,27 +70,32 @@ describe('cacheClear', () => { Object.assign(new Error('missing'), { code: 'ENOENT' }), ); - await expect(cacheClear()).resolves.toBeUndefined(); - - expect(rmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith( - 'info', + await expect(cacheClear()).resolves.toBe( 'The Emulsify cache is already empty: 0 buckets and 0 entries.', ); + + expect(rmMock).not.toHaveBeenCalled(); }); it('clears an existing cache with no buckets', async () => { readdirMock.mockResolvedValueOnce([]); - await cacheClear(); + const summary = await cacheClear(); expect(rmMock).toHaveBeenCalledTimes(1); - expect(logMock).toHaveBeenCalledWith( - 'success', + expect(summary).toBe( 'Cleared the Emulsify cache: removed 0 buckets and 0 entries.', ); }); + it('reports its outcome as the session summary, not as a loose line', async () => { + mockPopulatedCache(); + + await cacheClear(); + + expect(logMock).not.toHaveBeenCalled(); + }); + it('wraps cache inspection failures in CliError', async () => { readdirMock.mockRejectedValueOnce( Object.assign(new Error('permission denied'), { code: 'EACCES' }), diff --git a/src/handlers/cacheClear.ts b/src/handlers/cacheClear.ts index 21102b1..f40db9d 100644 --- a/src/handlers/cacheClear.ts +++ b/src/handlers/cacheClear.ts @@ -4,7 +4,6 @@ import { promises as fs } from 'fs'; import { join } from 'path'; import { CACHE_DIR } from '../lib/constants.js'; import CliError from '../lib/CliError.js'; -import log from '../lib/log.js'; type CacheStats = { exists: boolean; @@ -66,26 +65,19 @@ async function inspectCache(): Promise { * Clear all local Emulsify repository cache entries. * * @param options command options, including dry-run behavior. + * @returns a short summary of what the cache held and what was removed. * @throws {CliError} if the cache cannot be inspected or removed. */ export default async function cacheClear({ dryRun = false, -}: ClearCacheHandlerOptions = {}): Promise { +}: ClearCacheHandlerOptions = {}): Promise { const stats = await inspectCache(); if (!stats.exists) { - log( - 'info', - 'The Emulsify cache is already empty: 0 buckets and 0 entries.', - ); - return; + return 'The Emulsify cache is already empty: 0 buckets and 0 entries.'; } if (dryRun) { - log( - 'info', - `Dry run: the Emulsify cache contains ${stats.bucketCount} buckets and ${stats.entryCount} entries. No files were removed.`, - ); - return; + return `Dry run: the Emulsify cache contains ${stats.bucketCount} buckets and ${stats.entryCount} entries. No files were removed.`; } try { @@ -96,8 +88,5 @@ export default async function cacheClear({ ); } - log( - 'success', - `Cleared the Emulsify cache: removed ${stats.bucketCount} buckets and ${stats.entryCount} entries.`, - ); + return `Cleared the Emulsify cache: removed ${stats.bucketCount} buckets and ${stats.entryCount} entries.`; } diff --git a/src/handlers/componentList.test.ts b/src/handlers/componentList.test.ts index 32d2c20..44a81b7 100644 --- a/src/handlers/componentList.test.ts +++ b/src/handlers/componentList.test.ts @@ -3,19 +3,22 @@ */ jest.mock('../lib/log', () => jest.fn()); +jest.mock('../lib/session', () => ({ note: jest.fn() })); jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; +import { note } from '../lib/session.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 componentList from './componentList.js'; +import componentList, { formatComponentList } from './componentList.js'; const logMock = log as jest.Mock; +const noteMock = note as jest.Mock; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; @@ -190,8 +193,23 @@ describe('componentList', () => { it('lists each component from the configured variant on the happy path', async () => { await componentList(); - expect(logMock).toHaveBeenCalledWith('info', 'base -> button'); - expect(logMock).toHaveBeenCalledWith('info', 'base -> card'); + expect(noteMock).toHaveBeenCalledTimes(1); + const [body, title] = noteMock.mock.calls[0]; + expect(title).toBe('Available components'); + expect(body).toContain('button'); + expect(body).toContain('card'); + }); + + it('summarizes what the variant offers', async () => { + await expect(componentList()).resolves.toBe( + '2 components across 1 structure', + ); + }); + + it('does not write one loose line per component', async () => { + await componentList(); + + expect(logMock).not.toHaveBeenCalled(); }); it('requests a remote freshness check when refresh is enabled', async () => { @@ -202,3 +220,40 @@ describe('componentList', () => { }); }); }); + +describe('formatComponentList', () => { + it('groups components by structure and counts the required ones', () => { + const block = formatComponentList([ + { name: 'buttons', structure: 'atoms', required: true }, + { name: 'tooltip', structure: 'atoms' }, + { name: 'card', structure: 'molecules', dependency: ['buttons', 'text'] }, + ]); + + expect(block).toBe( + [ + 'atoms \u00b7 2 components \u00b7 1 required', + ' buttons required', + ' tooltip', + '', + 'molecules \u00b7 1 component', + ' card needs buttons, text', + ].join('\n'), + ); + }); + + it('surfaces what each component drags in with it', () => { + const block = formatComponentList([ + { + name: 'accordion', + structure: 'molecules', + dependency: ['buttons', 'images', 'text', 'links', 'lists'], + }, + ]); + + expect(block).toContain('needs buttons, images, text, links, lists'); + }); + + it('reports an empty variant plainly', () => { + expect(formatComponentList([])).toBe('This variant offers no components.'); + }); +}); diff --git a/src/handlers/componentList.ts b/src/handlers/componentList.ts index 52ca68b..210b5b4 100644 --- a/src/handlers/componentList.ts +++ b/src/handlers/componentList.ts @@ -1,21 +1,114 @@ +import type { Components } from '@emulsify-cli/config'; import type { ListComponentHandlerOptions } from '@emulsify-cli/handlers'; -import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; +/** + * Render a count with the right plural form. + * + * @param count number of things. + * @param singular singular noun. + * @returns the count followed by the correctly pluralized noun. + */ +function pluralize(count: number, singular: string): string { + return `${count} ${count === 1 ? singular : `${singular}s`}`; +} + +/** + * Group the variant's components by the structure they belong to. + * + * @param components every component the variant offers. + * @returns structure names mapped to their components, in declaration order. + */ +function groupByStructure(components: Components): Map { + const groups = new Map(); + + for (const component of components) { + const group = groups.get(component.structure); + + if (group) { + group.push(component); + continue; + } + + groups.set(component.structure, [component]); + } + + return groups; +} + +/** + * Render the variant's components as an aligned, grouped block. + * + * @remarks The system already records which components are required and what + * each one depends on. Showing both turns a flat list of names into something + * you can plan an install from. + * + * @param components every component the variant offers. + * @returns a multi-line block grouped by structure. + */ +export function formatComponentList(components: Components): string { + if (components.length === 0) { + return 'This variant offers no components.'; + } + + const nameWidth = Math.max(...components.map(({ name }) => name.length)); + const lines: string[] = []; + + for (const [structure, group] of groupByStructure(components)) { + const requiredCount = group.filter(({ required }) => required).length; + const heading = [ + structure, + pluralize(group.length, 'component'), + ...(requiredCount > 0 ? [`${requiredCount} required`] : []), + ].join(' · '); + + if (lines.length > 0) { + lines.push(''); + } + + lines.push(heading); + + for (const { name, required, dependency } of group) { + const notes = [ + ...(required ? ['required'] : []), + ...(dependency && dependency.length > 0 + ? [`needs ${dependency.join(', ')}`] + : []), + ]; + + lines.push( + ` ${name.padEnd(nameWidth)} ${notes.join(' · ')}`.trimEnd(), + ); + } + } + + return lines.join('\n'); +} + /** * Handler for the `component list` command. * + * @param options.refresh whether to check the system remote before reusing its cache entry. + * @returns a short summary of what the installed variant offers. * @throws {CliError} if the current project does not have a usable system and variant configuration. */ export default async function componentList({ refresh, -}: ListComponentHandlerOptions = {}): Promise { +}: ListComponentHandlerOptions = {}): Promise { // Load the configured system and variant before printing available components. const { variantConf } = await withEmulsifySystem('list components', { refresh, }); - variantConf.components.map(({ name, structure }) => { - log('info', `${structure} -> ${name}`); - }); + const { components } = variantConf; + + note(formatComponentList(components), 'Available components'); + + const structureCount = groupByStructure(components).size; + + return `${pluralize(components.length, 'component')} across ${pluralize( + structureCount, + 'structure', + )}`; } diff --git a/src/handlers/systemList.test.ts b/src/handlers/systemList.test.ts index 9991721..477dc18 100644 --- a/src/handlers/systemList.test.ts +++ b/src/handlers/systemList.test.ts @@ -1,37 +1,66 @@ jest.mock('../lib/log', () => jest.fn()); +jest.mock('../lib/session', () => ({ note: jest.fn() })); import log from '../lib/log.js'; -import systemList from './systemList.js'; +import { note } from '../lib/session.js'; +import systemList, { formatSystemList } from './systemList.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; const logMock = log as jest.Mock; +const noteMock = note as jest.Mock; describe('systemList', () => { beforeEach(() => { - logMock.mockClear(); + jest.clearAllMocks(); }); it('can list all available out-of-the-box systems', async () => { - expect.assertions(5); - const systems = await getAvailableSystems(); + expect.assertions(4); await systemList(); - const loggedMessages = logMock.mock.calls.map(([, message]) => message); - expect(logMock).toHaveBeenCalledTimes(systems.length); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'info', - 'compound - https://github.com/emulsify-ds/compound.git', + expect(noteMock).toHaveBeenCalledTimes(1); + const [body, title] = noteMock.mock.calls[0]; + + expect(title).toBe('Built-in systems'); + expect(body).toContain( + 'compound https://github.com/emulsify-ds/compound.git', ); - expect(logMock).toHaveBeenNthCalledWith( - 2, - 'info', - 'emulsify-ui-kit - https://github.com/emulsify-ds/emulsify-ui-kit.git', + expect(body).toContain( + 'emulsify-ui-kit https://github.com/emulsify-ds/emulsify-ui-kit.git', ); - expect(loggedMessages).toEqual([ - 'compound - https://github.com/emulsify-ds/compound.git', - 'emulsify-ui-kit - https://github.com/emulsify-ds/emulsify-ui-kit.git', - ]); - expect(new Set(loggedMessages).size).toBe(loggedMessages.length); + }); + + it('summarizes how many systems are available and what to do next', async () => { + const systems = await getAvailableSystems(); + + await expect(systemList()).resolves.toBe( + `${systems.length} systems \u00b7 install one with emulsify system install`, + ); + }); + + it('does not write one loose line per system', async () => { + await systemList(); + + expect(logMock).not.toHaveBeenCalled(); + }); +}); + +describe('formatSystemList', () => { + it('aligns repositories into a column', () => { + expect( + formatSystemList([ + { name: 'compound', repository: 'https://example.com/compound.git' }, + { name: 'ui', repository: 'https://example.com/ui.git' }, + ]), + ).toBe( + [ + 'compound https://example.com/compound.git', + 'ui https://example.com/ui.git', + ].join('\n'), + ); + }); + + it('reports an empty catalog plainly', () => { + expect(formatSystemList([])).toBe('No built-in systems are available.'); }); }); diff --git a/src/handlers/systemList.ts b/src/handlers/systemList.ts index 2a1f781..f09631c 100644 --- a/src/handlers/systemList.ts +++ b/src/handlers/systemList.ts @@ -1,11 +1,38 @@ -import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; +type AvailableSystem = { + name: string; + repository: string; +}; + +/** + * Render the built-in systems as an aligned block. + * + * @param systems systems available for installation. + * @returns a multi-line block with one system per line. + */ +export function formatSystemList(systems: AvailableSystem[]): string { + if (systems.length === 0) { + return 'No built-in systems are available.'; + } + + const nameWidth = Math.max(...systems.map(({ name }) => name.length)); + + return systems + .map(({ name, repository }) => `${name.padEnd(nameWidth)} ${repository}`) + .join('\n'); +} + /** * Handler for the `system list` command. + * + * @returns a short summary of how many systems are available. */ -export default async function systemList(): Promise { - (await getAvailableSystems()).map(({ name, repository }) => - log('info', `${name} - ${repository}`), - ); +export default async function systemList(): Promise { + const systems = await getAvailableSystems(); + + note(formatSystemList(systems), 'Built-in systems'); + + return `${systems.length} system${systems.length === 1 ? '' : 's'} · install one with emulsify system install`; } From 8c0bf1a700d8b2e27d3af3b7558c7a064146d0fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:51:12 +0000 Subject: [PATCH 4/7] feat(prompts): move every prompt and result onto the one renderer Questions were drawn by one library and results by another, so a command visibly changed voice between asking and reporting. Every prompt in the CLI now comes from the same renderer as its output. Each handler returns the outcome it actually reached instead of printing a final line, so the session closes on the truth: a declined overwrite in `component create` no longer reports a component as created, and `system install` names the component set that landed. Multi-line output that was packed into single log lines is now rendered as blocks: installed dependencies, ejected template paths, dry-run previews and the system install review. `system install`'s wizard header drops its 60-column padding, which assumed a bare line and collided with the gutter. Select and multi-select options carry a hint, so component lists show their structure and eject types explain what they write. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Utf5UMza9HgyUvKHWjYmpY --- src/handlers/componentCreate.test.ts | 80 +++++- src/handlers/componentCreate.ts | 28 ++- src/handlers/componentEjectTemplates.test.ts | 83 +++++-- src/handlers/componentEjectTemplates.ts | 58 ++--- src/handlers/componentInstall.test.ts | 67 +++-- src/handlers/componentInstall.ts | 39 ++- src/handlers/systemCreate.test.ts | 130 +++++----- src/handlers/systemCreate.ts | 74 +++--- src/handlers/systemDetach.test.ts | 39 +-- src/handlers/systemDetach.ts | 21 +- src/handlers/systemInstall.test.ts | 242 +++++++++---------- src/handlers/systemInstall.ts | 121 +++++----- src/util/project/generateComponent.test.ts | 154 ++++++++---- src/util/project/generateComponent.ts | 86 +++---- 14 files changed, 709 insertions(+), 513 deletions(-) diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index f15feb5..5eb7aa6 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -6,9 +6,33 @@ jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); jest.mock('../util/project/generateComponent', () => jest.fn()); -jest.mock('@inquirer/prompts'); +// The prompt library is ESM-only, so the mock is built from a factory rather +// than automocked from the real module. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + text: jest.fn(), + select: jest.fn(), + confirm: jest.fn(), + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + note: jest.fn(), + log: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn(), + message: jest.fn(), + }, + }; +}); -import { input } from '@inquirer/prompts'; +import { text } from '@clack/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import CliError from '../lib/CliError.js'; import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; @@ -16,6 +40,7 @@ import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import getJsonFromCachedFile from '../util/cache/getJsonFromCachedFile.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; import generateComponent from '../util/project/generateComponent.js'; +import { PromptCancelledError } from '../util/prompt/index.js'; import componentCreate from './componentCreate.js'; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; @@ -23,7 +48,7 @@ const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; const cloneSystemMock = jest.fn(); const generateComponentMock = generateComponent as jest.Mock; -const inputMock = input as jest.Mock; +const textMock = text as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; function setStdinIsTTY(value: boolean | undefined) { @@ -89,8 +114,8 @@ describe('componentCreate', () => { cloneSystemMock.mockResolvedValue(undefined); getEmulsifyConfigMock.mockResolvedValue(projectConfig); getJsonFromCachedFileMock.mockResolvedValue(system); - generateComponentMock.mockResolvedValue(undefined); - inputMock.mockResolvedValue('button'); + generateComponentMock.mockResolvedValue('Created the button component'); + textMock.mockResolvedValue('button'); }); afterAll(() => { @@ -126,7 +151,7 @@ describe('componentCreate', () => { 'You must select and install a system before you can create components.', ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(generateComponentMock).not.toHaveBeenCalled(); }); @@ -225,28 +250,29 @@ describe('componentCreate', () => { componentCreate('', { refresh: true, type: 'twig' }), ).rejects.toThrow('Please specify a name for the new component.'); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(getEmulsifyConfigMock).not.toHaveBeenCalled(); expect(generateComponentMock).not.toHaveBeenCalled(); }); it('prompts for a missing component name and validates it before continuing', async () => { - inputMock.mockImplementationOnce(async ({ validate }) => { + textMock.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); + // A valid name returns no validation message. + expect(validate('promo-card')).toBeUndefined(); return 'promo-card'; }); const options = { directory: 'base', type: 'twig' }; await componentCreate(undefined, options); - expect(inputMock).toHaveBeenCalledWith({ - message: 'Component name:', + expect(textMock).toHaveBeenCalledWith({ + message: 'Component name', validate: expect.any(Function), }); expect(generateComponentMock).toHaveBeenCalledWith( @@ -347,12 +373,40 @@ describe('componentCreate', () => { }); it('preserves prompt cancellation for the top-level handler', async () => { - const cancellation = new Error('User force closed the prompt'); - cancellation.name = 'ExitPromptError'; + const cancellation = new PromptCancelledError(); generateComponentMock.mockRejectedValueOnce(cancellation); await expect(componentCreate('button', { directory: 'base' })).rejects.toBe( cancellation, ); }); + + it('summarizes the created component for the session', async () => { + await expect( + componentCreate('button', { directory: 'base', type: 'twig' }), + ).resolves.toBe('Created the button component'); + }); + + it('summarizes a dry run without claiming the component was created', async () => { + generateComponentMock.mockResolvedValueOnce('Dry run complete'); + + await expect( + componentCreate('button', { + directory: 'base', + type: 'twig', + dryRun: true, + }), + ).resolves.toBe('Dry run complete'); + }); + + it('reports the outcome generateComponent actually reached', async () => { + // A declined overwrite must not be summarized as a successful creation. + generateComponentMock.mockResolvedValueOnce( + 'Canceled; the existing button component was kept', + ); + + await expect( + componentCreate('button', { directory: 'base', type: 'twig' }), + ).resolves.toBe('Canceled; the existing button component was kept'); + }); }); diff --git a/src/handlers/componentCreate.ts b/src/handlers/componentCreate.ts index cd4f4ea..6e995f4 100644 --- a/src/handlers/componentCreate.ts +++ b/src/handlers/componentCreate.ts @@ -1,11 +1,11 @@ import type { CreateComponentHandlerOptions } from '@emulsify-cli/handlers'; -import { input } from '@inquirer/prompts'; +import { text } from '@clack/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, + isPromptCancelledError, requireInteractiveTerminal, runPrompt, } from '../util/prompt/index.js'; @@ -17,10 +17,10 @@ import { const MISSING_COMPONENT_NAME_ERROR = 'Please specify a name for the new component.'; -function validateComponentName(name: string): true | string { +function validateComponentName(name: string | undefined): string | undefined { try { - deriveComponentNames(name); - return true; + deriveComponentNames(name ?? ''); + return undefined; } catch (error) { return (error as Error).message; } @@ -29,6 +29,8 @@ function validateComponentName(name: string): true | string { /** * Handler for the `component create` command. * + * @returns summary shown on the closing line of the session. + * * @throws {CliError} if the component name is missing. * @throws {CliError} if the current project does not have a usable system and variant configuration. * @throws {CliError} if component generation fails. @@ -36,7 +38,7 @@ function validateComponentName(name: string): true | string { export default async function componentCreate( name: string | void, options: CreateComponentHandlerOptions = {}, -): Promise { +): Promise { const providedComponentName = name?.trim() ? name : undefined; // Missing prompt values can be rejected before loading or refreshing the @@ -61,24 +63,28 @@ export default async function componentCreate( const componentName = providedComponentName ?? - (await runPrompt({ + (await runPrompt({ prompt: () => - input({ - message: 'Component name:', + text({ + message: 'Component name', validate: validateComponentName, }), nonInteractive: { error: MISSING_COMPONENT_NAME_ERROR }, })); try { - await generateComponent( + // generateComponent reports the outcome it actually reached, which may be a + // declined overwrite rather than a new component. + return await generateComponent( variantConf, emulsifyConfig, componentName, options, ); } catch (e) { - if (isExitPromptError(e)) { + // A cancelled prompt is closed by the top-level runner, which reports it as + // a cancellation rather than a failure. Wrapping it here would hide it. + if (isPromptCancelledError(e)) { throw e; } diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index 33e919c..2d84dfb 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -1,8 +1,42 @@ -jest.mock('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ note: jest.fn() })); + +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value: unknown) => value === CANCEL_SYMBOL, + multiselect: jest.fn(), + note: jest.fn(), + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + spinner: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, + })), + log: { + message: jest.fn(), + info: jest.fn(), + success: jest.fn(), + step: jest.fn(), + warn: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, + }; +}); jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); -import { checkbox } from '@inquirer/prompts'; +import { multiselect } from '@clack/prompts'; +import { note } from '../lib/session.js'; import { constants as fsConstants, promises as fs } from 'fs'; import { basename, dirname, join, resolve } from 'path'; import { pathExists } from 'fs-extra'; @@ -19,7 +53,8 @@ import componentEjectTemplates, { MISSING_TEMPLATE_TYPE_ERROR, } from './componentEjectTemplates.js'; -const checkboxMock = checkbox as jest.Mock; +const multiselectMock = multiselect as jest.Mock; +const noteMock = note as jest.Mock; const copyFileMock = fs.copyFile as jest.Mock; const findFileMock = findFileInCurrentPath as jest.Mock; const logMock = log as jest.Mock; @@ -188,7 +223,7 @@ describe('componentEjectTemplates', () => { await componentEjectTemplates('twig'); const artifacts = buildEjectableComponentTemplates('twig'); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).toHaveBeenCalledTimes(artifacts.length); expect(mkdirMock).toHaveBeenCalledTimes(artifacts.length); expect(writeFileMock).toHaveBeenCalledTimes(artifacts.length); @@ -220,13 +255,11 @@ describe('componentEjectTemplates', () => { Math.min(...linkMock.mock.invocationCallOrder), ); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'success', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining(destination('twig', 'component.twig')), + 'Ejected', ); - expect(logMock).toHaveBeenNthCalledWith( - 2, + expect(logMock).toHaveBeenCalledWith( 'info', 'Edit these files to customize component create. Delete an override to restore its built-in template.', ); @@ -286,7 +319,7 @@ describe('componentEjectTemplates', () => { it('writes all 15 templates without prompting when --all is passed', async () => { await componentEjectTemplates(undefined, { all: true }); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).toHaveBeenCalledTimes(15); expect(mkdirMock).toHaveBeenCalledTimes(15); expect(writeFileMock).toHaveBeenCalledTimes(15); @@ -329,20 +362,22 @@ describe('componentEjectTemplates', () => { it('prompts interactively for one, several, or all component types', async () => { setStdinIsTTY(true); - checkboxMock.mockResolvedValueOnce(['web-component', 'twig']); + multiselectMock.mockResolvedValueOnce(['web-component', 'twig']); await componentEjectTemplates(undefined); - expect(checkboxMock).toHaveBeenCalledTimes(1); - const prompt = checkboxMock.mock.calls[0][0]; + expect(multiselectMock).toHaveBeenCalledTimes(1); + const prompt = multiselectMock.mock.calls[0][0]; expect(prompt).toMatchObject({ message: 'Which component template types should be ejected?', }); - expect(prompt.choices.map(({ value }: { value: string }) => value)).toEqual( + expect(prompt.options.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); + // Every option explains what it writes, so the list is readable without docs. + expect( + prompt.options.every(({ hint }: { hint?: string }) => Boolean(hint)), + ).toBe(true); const expectedCount = buildEjectableComponentTemplates('twig').length + @@ -365,7 +400,7 @@ describe('componentEjectTemplates', () => { }); expect(findFileMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).not.toHaveBeenCalled(); expectNoWrites(); }, @@ -382,7 +417,7 @@ describe('componentEjectTemplates', () => { MISSING_TEMPLATE_TYPE_ERROR, ); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).not.toHaveBeenCalled(); expectNoWrites(); }); @@ -397,7 +432,7 @@ describe('componentEjectTemplates', () => { exitCode: 1, }); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).not.toHaveBeenCalled(); expectNoWrites(); }); @@ -414,7 +449,7 @@ describe('componentEjectTemplates', () => { it('rejects an empty interactive selection defensively', async () => { setStdinIsTTY(true); - checkboxMock.mockResolvedValueOnce([]); + multiselectMock.mockResolvedValueOnce([]); await expect(componentEjectTemplates(undefined)).rejects.toMatchObject({ name: 'CliError', @@ -859,11 +894,11 @@ describe('componentEjectTemplates', () => { await componentEjectTemplates('twig', { dryRun: true }); expectNoWrites(); - expect(logMock).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringMatching( /component\.twig \(would create\)[\s\S]*component\.scss \(conflict; a real run requires --force\)[\s\S]*No files were written or replaced\./, ), + 'Dry run: component eject-templates', ); }); @@ -876,9 +911,9 @@ describe('componentEjectTemplates', () => { }); expectNoWrites(); - expect(logMock).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining('(would replace)'), + 'Dry run: component eject-templates', ); }); diff --git a/src/handlers/componentEjectTemplates.ts b/src/handlers/componentEjectTemplates.ts index c39aa3b..1d7573f 100644 --- a/src/handlers/componentEjectTemplates.ts +++ b/src/handlers/componentEjectTemplates.ts @@ -1,6 +1,6 @@ import type { EjectComponentTemplatesHandlerOptions } from '@emulsify-cli/handlers'; -import { checkbox } from '@inquirer/prompts'; +import { multiselect } from '@clack/prompts'; import { randomUUID } from 'crypto'; import { constants as fsConstants, promises as fs } from 'fs'; import { basename, dirname, join } from 'path'; @@ -12,6 +12,7 @@ import { EMULSIFY_PROJECT_TEMPLATES_FOLDER, } from '../lib/constants.js'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import safeResolveWithin from '../util/fs/safeResolveWithin.js'; import { @@ -27,30 +28,30 @@ export const MISSING_TEMPLATE_TYPE_ERROR = export const CONFLICTING_TEMPLATE_TYPE_ERROR = 'Pass either the [type] positional argument or --all, not both.'; -const TYPE_CHOICES: { - name: string; +const TYPE_OPTIONS: { value: ComponentType; - description: string; + label: string; + hint: string; }[] = [ { - name: 'Twig', value: 'twig', - description: 'Twig markup, SCSS, YAML data, and a Storybook story', + label: 'Twig', + hint: 'Twig markup, SCSS, YAML data, and a Storybook story', }, { - name: 'Twig SDC', value: 'twig-sdc', - description: 'Drupal Single Directory Component templates', + label: 'Twig SDC', + hint: 'Drupal Single Directory Component templates', }, { - name: 'React', value: 'react', - description: 'React JSX, SCSS, and a standard Storybook story', + label: 'React', + hint: 'React JSX, SCSS, and a standard Storybook story', }, { - name: 'Web Component', value: 'web-component', - description: 'Custom element, SCSS, and an Emulsify Core story', + label: 'Web Component', + hint: 'Custom element, SCSS, and an Emulsify Core story', }, ]; @@ -135,7 +136,7 @@ function getDryRunAction(exists: boolean, force: boolean): string { return 'conflict; a real run requires --force'; } -function logDryRun( +function noteDryRun( types: ComponentType[], plan: InspectedPlanItem[], force: boolean, @@ -147,15 +148,14 @@ function logDryRun( ) .join('\n'); - log( - 'info', + note( [ - 'Dry run: component eject-templates', `Types: ${types.join(', ')}`, 'Template files:', destinations, 'No files were written or replaced.', ].join('\n'), + 'Dry run: component eject-templates', ); } @@ -427,7 +427,11 @@ async function executeTransaction( } } -/** Handler for `emulsify component eject-templates [type]`. */ +/** + * Handler for `emulsify component eject-templates [type]`. + * + * @returns summary shown on the closing line of the session. + */ export default async function componentEjectTemplates( type: string | void, { @@ -435,7 +439,7 @@ export default async function componentEjectTemplates( force = false, dryRun = false, }: EjectComponentTemplatesHandlerOptions = {}, -): Promise { +): Promise { const requestedType = type?.trim(); if (requestedType && all) { @@ -463,11 +467,10 @@ export default async function componentEjectTemplates( } else { selectedTypes = await runPrompt({ prompt: () => - checkbox({ + multiselect({ message: 'Which component template types should be ejected?', - choices: TYPE_CHOICES, - validate: (values) => - values.length > 0 || 'Select at least one component type.', + options: TYPE_OPTIONS, + required: false, }), nonInteractive: { error: MISSING_TEMPLATE_TYPE_ERROR }, }); @@ -483,8 +486,8 @@ export default async function componentEjectTemplates( const conflicts = inspectedPlan.filter(({ exists }) => exists); if (dryRun) { - logDryRun(canonicalTypes, inspectedPlan, force); - return; + noteDryRun(canonicalTypes, inspectedPlan, force); + return 'Dry run complete'; } if (conflicts.length > 0 && !force) { @@ -496,12 +499,11 @@ export default async function componentEjectTemplates( const paths = inspectedPlan .map(({ destination }) => ` - ${destination}`) .join('\n'); - log( - 'success', - `Ejected ${inspectedPlan.length} built-in component templates:\n${paths}`, - ); + note(paths, 'Ejected'); log( 'info', 'Edit these files to customize component create. Delete an override to restore its built-in template.', ); + + return `Ejected ${inspectedPlan.length} built-in component templates`; } diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index d23b79b..fe482f1 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -8,13 +8,27 @@ jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); jest.mock('../util/cache/copyItemFromCache', () => jest.fn()); jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); -jest.mock('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ note: jest.fn() })); +// The prompt library is ESM-only, so the mock is built from a factory rather +// than automocked from the real module. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + select: jest.fn(), + confirm: jest.fn(), + }; +}); import { pathExists } from 'fs-extra'; import { join, resolve } from 'path'; -import { confirm, select } from '@inquirer/prompts'; +import { confirm, select } from '@clack/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import CliError from '../lib/CliError.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, @@ -28,6 +42,7 @@ import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import componentInstall from './componentInstall.js'; const logMock = log as jest.Mock; +const noteMock = note as jest.Mock; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; @@ -265,26 +280,26 @@ describe('componentInstall', () => { await componentInstall(undefined, { force: true }); expect(selectMock).toHaveBeenCalledWith({ - message: 'Choose a component to install:', - choices: [ + message: 'Choose a component to install', + options: [ { - name: 'button', value: 'button', - description: undefined, + label: 'button', + hint: 'base', }, { - name: 'icon', value: 'icon', - description: undefined, + label: 'icon', + hint: 'base', }, { - name: 'card', value: 'card', - description: undefined, + label: 'card', + hint: 'base', }, { - name: 'Install all available components', value: expect.any(Symbol), + label: 'Install all available components', }, ], }); @@ -305,8 +320,8 @@ describe('componentInstall', () => { }); it('installs all components when the interactive all choice is selected', async () => { - selectMock.mockImplementationOnce(async ({ choices }) => { - return choices.at(-1).value; + selectMock.mockImplementationOnce(async ({ options }) => { + return options.at(-1).value; }); await componentInstall(undefined, {}); @@ -349,7 +364,9 @@ describe('componentInstall', () => { }); it('installs a component and its dependencies on the happy path', async () => { - await componentInstall('button', { force: true }); + await expect(componentInstall('button', { force: true })).resolves.toBe( + 'Installed 2 components', + ); expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( 1, @@ -369,10 +386,8 @@ describe('componentInstall', () => { 'success', 'Success! The button component has been added to your project.', ); - expect(logMock).toHaveBeenCalledWith( - 'info', - 'The following dependencies were also installed:\n → icon', - ); + // The dependency list reads as a block rather than a log line. + expect(noteMock).toHaveBeenCalledWith(' → icon', 'Also installed'); }); it('requests a remote freshness check when refresh is enabled', async () => { @@ -384,7 +399,9 @@ describe('componentInstall', () => { }); it('previews a single component install without copying in dry-run mode', async () => { - await componentInstall('card', { dryRun: true }); + await expect(componentInstall('card', { dryRun: true })).resolves.toBe( + 'Dry run complete', + ); expect(confirmMock).not.toHaveBeenCalled(); expect(copyItemFromCacheMock).not.toHaveBeenCalled(); @@ -443,7 +460,9 @@ describe('componentInstall', () => { it('installs a component when no project config path is found for destination checks', async () => { findFileInCurrentPathMock.mockReturnValueOnce(undefined); - await componentInstall('card', {}); + await expect(componentInstall('card', {})).resolves.toBe( + 'Installed 1 component', + ); expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', @@ -462,7 +481,7 @@ describe('componentInstall', () => { expect(confirmMock).toHaveBeenCalledWith({ message: 'The component "button" already exists. Would you like to replace it?', - default: false, + initialValue: false, }); expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', @@ -499,7 +518,7 @@ describe('componentInstall', () => { expect(confirmMock).toHaveBeenCalledWith({ message: 'The component "icon" (required by "button") already exists. Would you like to replace it?', - default: false, + initialValue: false, }); expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( 2, @@ -514,7 +533,9 @@ describe('componentInstall', () => { pathExistsMock.mockResolvedValue(true); confirmMock.mockResolvedValue(false); - await componentInstall('button', {}); + await expect(componentInstall('button', {})).resolves.toBe( + 'No components were installed', + ); expect(logMock).toHaveBeenCalledWith( 'info', diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index 15901e2..b278d32 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -1,6 +1,7 @@ import { pathExists } from 'fs-extra'; -import { confirm, select } from '@inquirer/prompts'; +import { confirm, select } from '@clack/prompts'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js'; import CliError from '../lib/CliError.js'; import type { InstallComponentHandlerOptions } from '@emulsify-cli/handlers'; @@ -111,6 +112,8 @@ function logComponentInstallDryRun( /** * Handler for the `component install` command. * + * @returns summary shown on the closing line of the session. + * * @throws {CliError} if a component name is missing and all components were not requested. * @throws {CliError} if the current project does not have a usable system and variant configuration. * @throws {CliError} if the requested component cannot be found. @@ -119,7 +122,7 @@ function logComponentInstallDryRun( export default async function componentInstall( name: string | void, { force, all, dryRun, refresh }: InstallComponentHandlerOptions, -): Promise { +): Promise { let selectedName = name; let installAll = all === true; const needsSelection = !selectedName?.trim() && !installAll; @@ -140,16 +143,18 @@ export default async function componentInstall( const selection = await runPrompt({ prompt: () => select({ - message: 'Choose a component to install:', - choices: [ + message: 'Choose a component to install', + options: [ + // The structure reads as a hint so the list stays scannable when a + // system offers many components. ...variantConf.components.map((component) => ({ - name: component.name, value: component.name, - description: component.description, + label: component.name, + hint: component.structure, })), { - name: 'Install all available components', value: INSTALL_ALL_COMPONENTS, + label: 'Install all available components', }, ], }), @@ -177,7 +182,7 @@ export default async function componentInstall( true, ); logComponentInstallDryRun('all components', [], plan); - return; + return 'Dry run complete'; } components.push( @@ -220,7 +225,7 @@ export default async function componentInstall( Boolean(force), ); logComponentInstallDryRun(rootComponentName, dependencies, plan); - return; + return 'Dry run complete'; } const projectConfigPath = findFileInCurrentPath( @@ -239,11 +244,11 @@ export default async function componentInstall( ? ` (required by "${rootComponentName}")` : ''; const overwriteMessage = `The component "${componentName}"${dependencyNote} already exists.`; - const result = await runPrompt({ + const result = await runPrompt({ prompt: () => confirm({ message: `${overwriteMessage} Would you like to replace it?`, - default: false, + initialValue: false, }), nonInteractive: { error: `${overwriteMessage} Pass --force to replace existing components in non-interactive mode.`, @@ -273,6 +278,7 @@ export default async function componentInstall( } } + const installed: string[] = []; const installedDeps: string[] = []; const failedDeps: string[] = []; const failureMessages: string[] = []; @@ -280,6 +286,7 @@ export default async function componentInstall( for (const [cname, isDependency, promise] of components) { try { await promise; + installed.push(cname); if (isDependency) { installedDeps.push(cname); } else { @@ -299,7 +306,7 @@ export default async function componentInstall( if (installedDeps.length > 0) { const depList = installedDeps.map((d) => ` → ${d}`).join('\n'); - log('info', `The following dependencies were also installed:\n${depList}`); + note(depList, 'Also installed'); } if (failedDeps.length > 0) { @@ -313,4 +320,12 @@ export default async function componentInstall( if (failureMessages.length > 0) { throw new CliError(failureMessages.join('\n')); } + + if (installed.length === 0) { + return 'No components were installed'; + } + + return `Installed ${installed.length} component${ + installed.length === 1 ? '' : 's' + }`; } diff --git a/src/handlers/systemCreate.test.ts b/src/handlers/systemCreate.test.ts index 4c3b1ff..f9f93c3 100644 --- a/src/handlers/systemCreate.test.ts +++ b/src/handlers/systemCreate.test.ts @@ -5,11 +5,44 @@ jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('../util/system/validateSystemConfig', () => jest.fn()); -jest.mock('@inquirer/prompts'); +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value: unknown) => value === CANCEL_SYMBOL, + text: jest.fn(), + multiselect: jest.fn(), + confirm: jest.fn(), + note: jest.fn(), + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + spinner: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, + })), + log: { + message: jest.fn(), + info: jest.fn(), + success: jest.fn(), + step: jest.fn(), + warn: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, + }; +}); import type { CreateSystemHandlerOptions } from '@emulsify-cli/handlers'; -import { checkbox, confirm, input } from '@inquirer/prompts'; +import { confirm, multiselect, text } from '@clack/prompts'; import { existsSync, promises as fs } from 'fs'; import { dirname, join, resolve } from 'path'; import { simpleGit } from 'simple-git'; @@ -24,8 +57,8 @@ import buildSystemScaffold, { 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 textMock = text as jest.Mock; +const multiselectMock = multiselect as jest.Mock; const confirmMock = confirm as jest.Mock; const existsSyncMock = existsSync as jest.Mock; const mkdirMock = fs.mkdir as jest.Mock; @@ -121,8 +154,8 @@ describe('systemCreate', () => { git: true, }); - expect(inputMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); expect(existsSyncMock).toHaveBeenCalledTimes(1); expect(existsSyncMock).toHaveBeenCalledWith(target); @@ -162,39 +195,33 @@ describe('systemCreate', () => { expect(gitInitMock).toHaveBeenCalledWith(false, { '--initial-branch': 'main', }); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'success', - `Created the acme-system system in ${target}.`, - ); - expect(logMock).toHaveBeenNthCalledWith( - 2, + expect(logMock).toHaveBeenCalledWith( 'info', 'Git was initialized on branch main. Review the generated metadata, then commit the scaffold before installing it.', ); - expect(logMock).toHaveBeenCalledTimes(2); + expect(logMock).toHaveBeenCalledTimes(1); }); it('prompts for missing values in order and uses the selected values', async () => { setStdinIsTTY(true); - inputMock + textMock .mockResolvedValueOnce('Fancy_System') .mockResolvedValueOnce('/interactive-systems'); - checkboxMock.mockResolvedValueOnce(['drupal', 'wordpress']); + multiselectMock.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]; + const namePrompt = textMock.mock.calls[0][0]; + const directoryPrompt = textMock.mock.calls[1][0]; + const platformPrompt = multiselectMock.mock.calls[0][0]; - expect(inputMock).toHaveBeenCalledTimes(2); + expect(textMock).toHaveBeenCalledTimes(2); expect(namePrompt).toMatchObject({ - message: 'System name:', - default: 'custom-system', + message: 'System name', + defaultValue: 'custom-system', }); - expect(namePrompt.validate('ValidSystem')).toBe(true); + expect(namePrompt.validate('ValidSystem')).toBeUndefined(); 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.', ); @@ -206,43 +233,36 @@ describe('systemCreate', () => { }), ).toBe('unexpected validator failure'); expect(directoryPrompt).toMatchObject({ - message: 'Target directory:', - default: './', + message: 'Target directory', + defaultValue: './', }); - expect(directoryPrompt.validate('/tmp/systems')).toBe(true); + expect(directoryPrompt.validate('/tmp/systems')).toBeUndefined(); expect(directoryPrompt.validate(' ')).toBe( 'Target directory cannot be empty.', ); - expect(checkboxMock).toHaveBeenCalledTimes(1); + expect(multiselectMock).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' }, + message: 'Platform targets', + options: [ + { label: 'Generic / no platform', value: 'none' }, + { label: 'Drupal', value: 'drupal' }, + { label: 'WordPress', value: 'wordpress' }, ], + initialValues: ['none'], }); - 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, + initialValue: true, }); - expect(inputMock.mock.invocationCallOrder[0]).toBeLessThan( - inputMock.mock.invocationCallOrder[1], + expect(textMock.mock.invocationCallOrder[0]).toBeLessThan( + textMock.mock.invocationCallOrder[1], ); - expect(inputMock.mock.invocationCallOrder[1]).toBeLessThan( - checkboxMock.mock.invocationCallOrder[0], + expect(textMock.mock.invocationCallOrder[1]).toBeLessThan( + multiselectMock.mock.invocationCallOrder[0], ); - expect(checkboxMock.mock.invocationCallOrder[0]).toBeLessThan( + expect(multiselectMock.mock.invocationCallOrder[0]).toBeLessThan( confirmMock.mock.invocationCallOrder[0], ); @@ -274,8 +294,8 @@ describe('systemCreate', () => { await systemCreate(undefined, { yes: true }); - expect(inputMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); expect(existsSyncMock).toHaveBeenCalledWith(target); expect(writeToJsonFileMock).toHaveBeenCalledWith( @@ -356,8 +376,8 @@ describe('systemCreate', () => { exitCode: 1, }); - expect(inputMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); expect(existsSyncMock).not.toHaveBeenCalled(); expect(validateSystemConfigMock).not.toHaveBeenCalled(); @@ -619,15 +639,13 @@ describe('systemCreate', () => { it('skips Git and only logs creation when --no-git is selected', async () => { const target = join(parentDirectory, 'acme-system'); - await systemCreate('acme-system', explicitOptions); + await expect(systemCreate('acme-system', explicitOptions)).resolves.toBe( + `Created the acme-system system in ${target}.`, + ); expect(simpleGitMock).not.toHaveBeenCalled(); expect(gitInitMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledTimes(1); - expect(logMock).toHaveBeenCalledWith( - 'success', - `Created the acme-system system in ${target}.`, - ); + expect(logMock).not.toHaveBeenCalled(); }); it('wraps Git initialization failure and does not log success', async () => { diff --git a/src/handlers/systemCreate.ts b/src/handlers/systemCreate.ts index e898cd2..bc9e3f6 100644 --- a/src/handlers/systemCreate.ts +++ b/src/handlers/systemCreate.ts @@ -2,7 +2,7 @@ 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 { confirm, multiselect, text } from '@clack/prompts'; import { existsSync, promises as fs } from 'fs'; import { dirname, join, resolve } from 'path'; import { simpleGit } from 'simple-git'; @@ -22,16 +22,11 @@ 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' }, - ]; +const PLATFORM_OPTIONS: { value: Platform; label: string }[] = [ + { value: 'none', label: 'Generic / no platform' }, + { value: 'drupal', label: 'Drupal' }, + { value: 'wordpress', label: 'WordPress' }, +]; /** * Convert a human-readable system name into its repository/config identity. @@ -51,10 +46,14 @@ export function normalizeSystemName(name: string): string { return machineName; } -function validatePromptedSystemName(name: string): true | string { +function validatePromptedSystemName( + name: string | undefined, +): string | undefined { try { - normalizeSystemName(name); - return true; + // An empty submission resolves to the prompt's default value, so the + // validator judges the same name the prompt will return. + normalizeSystemName(name || DEFAULT_SYSTEM_NAME); + return undefined; } catch (error) { return error instanceof Error ? error.message : String(error); } @@ -157,21 +156,24 @@ async function writeExclusiveSystemFile({ /** * Handler for `emulsify system create [name]`. + * + * @returns summary shown on the closing line of the session. */ export default async function systemCreate( name: string | void, options: CreateSystemHandlerOptions = {}, -): Promise { +): Promise { const acceptDefaults = options.yes === true; const dryRun = options.dryRun === true; let requestedName = name?.trim(); if (!requestedName) { - requestedName = await runPrompt({ + requestedName = await runPrompt({ prompt: () => - input({ - message: 'System name:', - default: DEFAULT_SYSTEM_NAME, + text({ + message: 'System name', + placeholder: DEFAULT_SYSTEM_NAME, + defaultValue: DEFAULT_SYSTEM_NAME, validate: validatePromptedSystemName, }), nonInteractive: { @@ -185,13 +187,18 @@ export default async function systemCreate( let targetParent = options.directory?.trim(); if (!targetParent) { - targetParent = await runPrompt({ + targetParent = await runPrompt({ prompt: () => - input({ - message: 'Target directory:', - default: DEFAULT_TARGET_DIRECTORY, + text({ + message: 'Target directory', + placeholder: DEFAULT_TARGET_DIRECTORY, + defaultValue: DEFAULT_TARGET_DIRECTORY, + // An empty submission resolves to the default directory, so only a + // value the person typed can be rejected as blank. validate: (value) => - value.trim().length > 0 || 'Target directory cannot be empty.', + !value || value.trim().length > 0 + ? undefined + : 'Target directory cannot be empty.', }), nonInteractive: { error: @@ -207,11 +214,11 @@ export default async function systemCreate( 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.', + multiselect({ + message: 'Platform targets', + options: PLATFORM_OPTIONS, + initialValues: [DEFAULT_PLATFORM], + required: false, }), nonInteractive: { error: @@ -224,11 +231,11 @@ export default async function systemCreate( let initializeGit = options.git; if (initializeGit === undefined) { - initializeGit = await runPrompt({ + initializeGit = await runPrompt({ prompt: () => confirm({ message: 'Initialize a Git repository?', - default: true, + initialValue: true, }), nonInteractive: { error: @@ -281,7 +288,7 @@ export default async function systemCreate( initializeGit, plannedDestinations, ); - return; + return 'Dry run complete'; } try { @@ -318,11 +325,12 @@ export default async function systemCreate( ); } - 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.', ); } + + return `Created the ${systemName} system in ${target}.`; } diff --git a/src/handlers/systemDetach.test.ts b/src/handlers/systemDetach.test.ts index 4858db0..2c768ba 100644 --- a/src/handlers/systemDetach.test.ts +++ b/src/handlers/systemDetach.test.ts @@ -5,9 +5,20 @@ jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/project/unsetEmulsifyConfig', () => jest.fn()); -jest.mock('@inquirer/prompts'); +// The prompt library ships ESM only, so a factory keeps it out of the CommonJS +// test runtime while still exposing the cancellation helper prompts rely on. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + confirm: jest.fn(), + }; +}); -import { confirm } from '@inquirer/prompts'; +import { confirm } from '@clack/prompts'; import log from '../lib/log.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; @@ -72,7 +83,7 @@ describe('systemDetach', () => { setStdinIsTTY(true); confirmMock.mockResolvedValueOnce(true); - await systemDetach(); + const summary = await systemDetach(); expect(confirmMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledWith( @@ -82,8 +93,7 @@ describe('systemDetach', () => { ); expect(unsetEmulsifyConfigMock).toHaveBeenCalledTimes(1); expect(unsetEmulsifyConfigMock).toHaveBeenCalledWith('system', 'variant'); - expect(logMock).toHaveBeenCalledWith( - 'success', + expect(summary).toBe( 'Detached the fixture-system system at main. All component files were left in place.', ); expect(logMock).toHaveBeenCalledWith( @@ -95,12 +105,11 @@ describe('systemDetach', () => { it('uses --yes in a non-interactive terminal without opening a prompt', async () => { setStdinIsTTY(undefined); - await systemDetach({ yes: true }); + const summary = await systemDetach({ yes: true }); expect(confirmMock).not.toHaveBeenCalled(); expect(unsetEmulsifyConfigMock).toHaveBeenCalledWith('system', 'variant'); - expect(logMock).toHaveBeenCalledWith( - 'success', + expect(summary).toBe( 'Detached the fixture-system system at main. All component files were left in place.', ); }); @@ -139,15 +148,12 @@ describe('systemDetach', () => { setStdinIsTTY(true); confirmMock.mockResolvedValueOnce(false); - await systemDetach(); + await expect(systemDetach()).resolves.toBe( + 'System detach cancelled. No project files were changed.', + ); 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 () => { @@ -193,10 +199,7 @@ describe('systemDetach', () => { }, }); - await systemDetach({ yes: true }); - - expect(logMock).toHaveBeenCalledWith( - 'success', + await expect(systemDetach({ yes: true })).resolves.toBe( '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 index 01664ce..d814435 100644 --- a/src/handlers/systemDetach.ts +++ b/src/handlers/systemDetach.ts @@ -1,6 +1,6 @@ import type { DetachSystemHandlerOptions } from '@emulsify-cli/handlers'; -import { confirm } from '@inquirer/prompts'; +import { confirm } from '@clack/prompts'; import CliError from '../lib/CliError.js'; import log from '../lib/log.js'; @@ -10,6 +10,8 @@ import unsetEmulsifyConfig from '../util/project/unsetEmulsifyConfig.js'; import { runPrompt } from '../util/prompt/index.js'; const CONFIGURED_SYSTEM_LABEL = 'configured component system'; +const DECLINED_SUMMARY = + 'System detach cancelled. No project files were changed.'; function getSystemLabel(repository: string): string { try { @@ -22,10 +24,12 @@ function getSystemLabel(repository: string): string { /** * Detach the configured component system without changing component files. + * + * @returns summary shown on the closing line of the session. */ export default async function systemDetach({ yes = false, -}: DetachSystemHandlerOptions = {}): Promise { +}: DetachSystemHandlerOptions = {}): Promise { const projectConfig = await getEmulsifyConfig(); if (!projectConfig) { throw new CliError( @@ -41,11 +45,11 @@ export default async function systemDetach({ const systemLabel = getSystemLabel(projectConfig.system.repository); const systemReference = `${systemLabel} at ${projectConfig.system.checkout}`; - const confirmed = await runPrompt({ + const confirmed = await runPrompt({ prompt: () => confirm({ message: `Detach the ${systemReference} from this project? Component files will be left in place.`, - default: false, + initialValue: false, }), nonInteractive: { error: @@ -55,8 +59,7 @@ export default async function systemDetach({ }); if (!confirmed) { - log('info', 'System detach cancelled. No project files were changed.'); - return; + return DECLINED_SUMMARY; } try { @@ -67,12 +70,10 @@ export default async function systemDetach({ ); } - 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.', ); + + return `Detached the ${systemReference}. All component files were left in place.`; } diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index ef91307..3159a67 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -18,14 +18,27 @@ 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('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ + __esModule: true, + note: jest.fn(), +})); +// The prompt library ships ESM only, so a factory keeps it out of the CommonJS +// test runtime while still exposing the cancellation helper prompts rely on. +jest.mock('@clack/prompts', () => ({ + __esModule: true, + confirm: jest.fn(), + select: jest.fn(), + text: jest.fn(), + isCancel: (value: unknown) => typeof value === 'symbol', +})); import fs from 'fs'; import { join, resolve } from 'path'; import { pathToFileURL } from 'url'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; -import { confirm, input, select, Separator } from '@inquirer/prompts'; +import { confirm, select, text } from '@clack/prompts'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_PROJECT_HOOK_FOLDER, @@ -62,10 +75,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 noteMock = note as jest.Mock; +const confirmMock = confirm as unknown as jest.Mock; +const textMock = text as unknown as jest.Mock; +const selectMock = select as unknown as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; const projectRoot = resolve('/project'); const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); @@ -151,14 +164,8 @@ const builtInSource = { 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 wizardStep(step: number, total?: number): string { + return total ? `Step ${step} of ${total}` : `Step ${step}`; } function queueBuiltInWizard({ @@ -356,7 +363,7 @@ describe('systemInstall', () => { beforeEach(() => { jest.clearAllMocks(); confirmMock.mockReset(); - inputMock.mockReset(); + textMock.mockReset(); selectMock.mockReset(); setStdinIsTTY(false); // The handler clones systems through a higher-order cache helper. @@ -402,53 +409,44 @@ describe('systemInstall', () => { ); }); - it('renders the step-one catalog, separator, and Cancel choice', async () => { + it('renders the step-one catalog and Cancel choice', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce(cancelSource); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'System install cancelled.', + ); expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenCalledWith({ message: 'Which system?', - choices: [ + options: [ { - name: formatChoice( - 'Compound', - 'Accessible, tested components. Drupal, WordPress, plain.', - ), value: builtInSource, - short: 'Compound', + label: 'Compound', + hint: 'Accessible, tested components. Drupal, WordPress, plain.', }, { - name: formatChoice( - 'Emulsify UI Kit', - 'Broader design-system starter kit.', - ), value: { kind: 'built-in', reference: availableSystems[1], }, - short: 'Emulsify UI Kit', + label: 'Emulsify UI Kit', + hint: 'Broader design-system starter kit.', }, { - name: formatChoice( - 'Bring your own', - 'Install from a git repository you control.', - ), value: customSource, - short: 'Bring your own', + label: 'Bring your own', + hint: 'Install from a git repository you control.', }, - expect.any(Separator), { - name: 'Cancel', value: cancelSource, + label: 'Cancel', + hint: 'Leave this project unchanged.', }, ], }); - expect(separatorMock).toHaveBeenCalledWith('────────────'); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(1)); - expect(logMock).toHaveBeenCalledWith('info', 'System install cancelled.'); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(1)); expect(cloneIntoCacheMock).not.toHaveBeenCalled(); expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); }); @@ -478,28 +476,30 @@ describe('systemInstall', () => { setStdinIsTTY(true); queueBuiltInWizard(); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'Compound installed \u00b7 drupal component set', + ); expect(selectMock).toHaveBeenCalledTimes(3); expect(selectMock).toHaveBeenNthCalledWith(3, { message: 'How much do you want to install?', - choices: [ + options: [ { - name: formatChoice('Essentials only', '1 required component'), value: false, - short: 'Essentials only', + label: 'Essentials only', + hint: '1 required component', }, { - name: formatChoice('Everything', '2 components'), value: true, - short: 'Everything', + label: 'Everything', + hint: '2 components', }, ], - default: false, + initialValue: false, }); expect(confirmMock).toHaveBeenCalledWith({ message: 'Install now?', - default: true, + initialValue: true, }); expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { refresh: true, @@ -529,42 +529,35 @@ describe('systemInstall', () => { '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 + expect(logMock).toHaveBeenCalledWith('info', wizardStep(1)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(2, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(3, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(4, 4)); + expect(noteMock).toHaveBeenCalledWith( + ` 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`, + 1 asset file → styles/tokens.css`, + 'Review', ); expect(confirmMock.mock.invocationCallOrder[0]).toBeLessThan( setEmulsifyConfigMock.mock.invocationCallOrder[0], ); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the Compound system using the Drupal component set.', - ); }); it('declines the final review without mutating project files', async () => { setStdinIsTTY(true); queueBuiltInWizard({ confirmed: false }); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'System install cancelled. No project files were changed.', + ); 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(); @@ -578,7 +571,8 @@ describe('systemInstall', () => { await systemInstall(undefined, { yes: true }); expect(confirmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(4, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(4, 4)); + expect(noteMock).toHaveBeenCalledWith(expect.any(String), 'Review'); expect(setEmulsifyConfigMock).toHaveBeenCalled(); expect(installComponentFromCacheMock).toHaveBeenCalledWith( system, @@ -601,34 +595,36 @@ describe('systemInstall', () => { .mockResolvedValueOnce(customSource) .mockResolvedValueOnce(0) .mockResolvedValueOnce(true); - inputMock + textMock .mockResolvedValueOnce(` ${repository} `) .mockResolvedValueOnce(' release '); confirmMock.mockResolvedValueOnce(true); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'Custom System installed \u00b7 drupal component set', + ); - expect(inputMock).toHaveBeenNthCalledWith(1, { - message: 'Repository URL or local path:', + expect(textMock).toHaveBeenNthCalledWith(1, { + message: 'Repository URL or local path', validate: expect.any(Function), }); - expect(inputMock).toHaveBeenNthCalledWith(2, { - message: 'Checkout (branch, tag, or commit):', + expect(textMock).toHaveBeenNthCalledWith(2, { + message: 'Checkout (branch, tag, or commit)', validate: expect.any(Function), }); - const repositoryValidator = inputMock.mock.calls[0][0].validate; + const repositoryValidator = textMock.mock.calls[0][0].validate; expect( repositoryValidator('https://github.com/example/custom-system.git'), - ).toBe(true); + ).toBeUndefined(); 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; + const checkoutValidator = textMock.mock.calls[1][0].validate; expect(checkoutValidator(' ')).toBe('Enter a branch, tag, or commit.'); - expect(checkoutValidator('main')).toBe(true); + expect(checkoutValidator('main')).toBeUndefined(); expect(cloneIntoCacheMock).toHaveBeenCalledWith( 'systems', ['custom-system'], @@ -646,27 +642,23 @@ describe('systemInstall', () => { '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', wizardStep(1)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(2, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(3, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(4, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(5, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(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 + expect(noteMock).toHaveBeenCalledWith( + ` System Custom System · release Source github.com/example/custom-system Component set Drupal Scope Everything - Will install 2 components → components/00-base/\n`, + Will install 2 components → components/00-base/`, + 'Review', ); }); @@ -678,7 +670,7 @@ describe('systemInstall', () => { }); setStdinIsTTY(true); selectMock.mockResolvedValueOnce(customSource); - inputMock.mockResolvedValueOnce(repository).mockResolvedValueOnce('main'); + textMock.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.', @@ -725,29 +717,29 @@ describe('systemInstall', () => { expect(selectMock).toHaveBeenNthCalledWith(2, { message: 'Which component set?', - choices: [ + options: [ { - name: 'Drupal (drupal) — Recommended · 2 components, 1 required component', value: 2, - short: 'Drupal', + label: 'Drupal (drupal)', + hint: 'Recommended · 2 components, 1 required component', }, { - name: 'Drupal and WordPress (drupal || wordpress) · 2 components, 1 required component', value: 1, - short: 'Drupal and WordPress', + label: 'Drupal and WordPress (drupal || wordpress)', + hint: '2 components, 1 required component', }, { - name: 'Platform-neutral (none) · 2 components, 1 required component', value: 0, - short: 'Platform-neutral', + label: 'Platform-neutral (none)', + hint: '2 components, 1 required component', }, { - name: 'WordPress (wordpress) · 2 components, 1 required component', value: 3, - short: 'WordPress', + label: 'WordPress (wordpress)', + hint: '2 components, 1 required component', }, ], - default: 2, + initialValue: 2, }); expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); }); @@ -813,14 +805,14 @@ describe('systemInstall', () => { 'A custom repository is required in non-interactive mode. Pass --repository .', ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).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 () => { + textMock.mockImplementationOnce(async () => { setStdinIsTTY(false); return 'https://github.com/example/custom-system.git'; }); @@ -829,7 +821,7 @@ describe('systemInstall', () => { 'A custom checkout is required in non-interactive mode. Pass --checkout .', ); - expect(inputMock).toHaveBeenCalledTimes(1); + expect(textMock).toHaveBeenCalledTimes(1); expect(cloneIntoCacheMock).not.toHaveBeenCalled(); }); @@ -929,7 +921,7 @@ describe('systemInstall', () => { expect(selectMock).toHaveBeenCalledTimes(1); expect(confirmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(2, 2)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(2, 2)); expect(installComponentFromCacheMock).toHaveBeenCalledTimes(2); expect(setEmulsifyConfigMock).toHaveBeenCalled(); }); @@ -1046,7 +1038,9 @@ describe('systemInstall', () => { }); it('installs the required components and global assets on the happy path', async () => { - await systemInstall('compound', {}); + await expect(systemInstall('compound', {})).resolves.toBe( + 'Compound installed \u00b7 drupal component set', + ); expect(setEmulsifyConfigMock).toHaveBeenCalledWith({ system: { @@ -1069,10 +1063,6 @@ describe('systemInstall', () => { variant, ); expect(executeScriptMock).toHaveBeenCalledWith(systemInstallHookPath); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the compound system using the drupal variant.', - ); }); it('installs optional dependencies of required components', async () => { @@ -1125,11 +1115,17 @@ describe('systemInstall', () => { }); it('uses an explicit variant on the happy path', async () => { - await systemInstall('compound', { variant: 'drupal' }); + await expect( + systemInstall('compound', { variant: 'drupal' }), + ).resolves.toBe('Compound installed \u00b7 drupal component set'); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the compound system using the drupal variant.', + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'drupal', + structureImplementations: variant.structureImplementations, + }, + }), ); }); @@ -1209,7 +1205,9 @@ describe('systemInstall', () => { variants: [expressionVariant], }); - await systemInstall('compound', {}); + await expect(systemInstall('compound', {})).resolves.toBe( + 'Compound installed \u00b7 drupal || wordpress component set', + ); expect(setEmulsifyConfigMock).toHaveBeenCalledWith({ system: { @@ -1221,10 +1219,6 @@ describe('systemInstall', () => { structureImplementations: variant.structureImplementations, }, }); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the compound system using the drupal || wordpress variant.', - ); }); it('installs a shared system variant for a wordpress project', async () => { @@ -1401,19 +1395,19 @@ describe('systemInstall', () => { expect(selectMock).toHaveBeenCalledWith({ message: 'Which Compound component set should be used?', - choices: [ + options: [ { - name: 'Drupal (drupal) — Recommended · 2 components, 1 required component', value: 0, - short: 'Drupal', + label: 'Drupal (drupal)', + hint: 'Recommended · 2 components, 1 required component', }, { - name: 'WordPress (wordpress) — Recommended · 2 components, 1 required component', value: 1, - short: 'WordPress', + label: 'WordPress (wordpress)', + hint: 'Recommended · 2 components, 1 required component', }, ], - default: 0, + initialValue: 0, }); expect(setEmulsifyConfigMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 29ac0f7..827a2d4 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -9,7 +9,7 @@ import type { EmulsifySystemReference } from '@emulsify-cli/internal'; import { dirname, join } from 'path'; import { existsSync } from 'fs'; -import { confirm, input, select, Separator } from '@inquirer/prompts'; +import { confirm, select, text } from '@clack/prompts'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_SYSTEM_CONFIG_FILE, @@ -18,6 +18,7 @@ import { } from '../lib/constants.js'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; +import { note } from '../lib/session.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; import getGitRepoNameFromUrl from '../util/getGitRepoNameFromUrl.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; @@ -49,7 +50,9 @@ 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'; +const CANCELLED_SUMMARY = 'System install cancelled.'; +const DECLINED_SUMMARY = + 'System install cancelled. No project files were changed.'; type BuiltInSourceChoice = { kind: 'built-in'; @@ -67,17 +70,12 @@ type CancelSourceChoice = { 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 formatWizardStep(step: number, total?: number): string { + return total ? `Step ${step} of ${total}` : `Step ${step}`; } function showWizardStep(step: number, total?: number): void { - log('info', formatWizardHeader(step, total)); -} - -function formatChoice(label: string, description: string): string { - return `${label.padEnd(22)}${description}`; + log('info', formatWizardStep(step, total)); } function pluralize(count: number, singular: string, plural = `${singular}s`) { @@ -131,10 +129,12 @@ function formatRepositorySource(repository: string): string { return repository.replace(/\.git\/?$/, ''); } -function validateRepositoryInput(repository: string): true | string { +function validateRepositoryInput( + repository: string | undefined, +): string | undefined { try { - return getGitRepoNameFromUrl(repository.trim()) - ? true + return getGitRepoNameFromUrl((repository || '').trim()) + ? undefined : 'Enter a Git repository with a recognizable name.'; } catch (error) { return error instanceof Error ? error.message : String(error); @@ -210,24 +210,21 @@ async function promptForSystemInstallChoice(): Promise< showWizardStep(1); return select({ message: 'Which system?', - choices: [ + options: [ ...availableSystems.map((reference) => ({ - name: formatChoice(reference.label, reference.description), value: { kind: 'built-in', reference } as BuiltInSourceChoice, - short: reference.label, + label: reference.label, + hint: reference.description, })), { - name: formatChoice( - 'Bring your own', - 'Install from a git repository you control.', - ), value: { kind: 'custom' } as CustomSourceChoice, - short: 'Bring your own', + label: 'Bring your own', + hint: 'Install from a git repository you control.', }, - new Separator('────────────'), { - name: 'Cancel', value: { kind: 'cancel' } as CancelSourceChoice, + label: 'Cancel', + hint: 'Leave this project unchanged.', }, ], }); @@ -236,7 +233,6 @@ async function promptForSystemInstallChoice(): Promise< }); if (selectedSource.kind === 'cancel') { - log('info', 'System install cancelled.'); return; } @@ -247,11 +243,11 @@ async function promptForCustomRepository( step: number, total: number, ): Promise { - return runPrompt({ + return runPrompt({ prompt: () => { showWizardStep(step, total); - return input({ - message: 'Repository URL or local path:', + return text({ + message: 'Repository URL or local path', validate: validateRepositoryInput, }); }, @@ -266,13 +262,15 @@ async function promptForCustomCheckout( step: number, total: number, ): Promise { - return runPrompt({ + return runPrompt({ prompt: () => { showWizardStep(step, total); - return input({ - message: 'Checkout (branch, tag, or commit):', + return text({ + message: 'Checkout (branch, tag, or commit)', validate: (value) => - value.trim().length > 0 || 'Enter a branch, tag, or commit.', + (value || '').trim().length > 0 + ? undefined + : 'Enter a branch, tag, or commit.', }); }, nonInteractive: { @@ -336,7 +334,7 @@ async function promptForVariantChoice( recommendedVariants.has(variant), )?.index; - const selectedIndex = await runPrompt({ + const selectedIndex = await runPrompt({ prompt: () => { if (wizardStep) { showWizardStep(wizardStep.step, wizardStep.total); @@ -345,23 +343,23 @@ async function promptForVariantChoice( message: wizardStep ? 'Which component set?' : `Which ${formatSystemLabel(systemName)} component set should be used?`, - choices: rankedVariants.map(({ variant, index }) => { + options: 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 counts = `${pluralize(componentCount, 'component')}, ${pluralize(requiredCount, 'required component')}`; const recommended = recommendedVariants.has(variant) - ? ' — Recommended' + ? 'Recommended · ' : ''; return { - name: `${formatPlatformLabel(variant.platform)} (${variant.platform})${recommended}${counts}`, value: index, - short: formatPlatformLabel(variant.platform), + label: `${formatPlatformLabel(variant.platform)} (${variant.platform})`, + hint: `${recommended}${counts}`, }; }), - default: defaultChoice, + initialValue: defaultChoice, }); }, nonInteractive: { error: nonInteractiveError }, @@ -432,30 +430,24 @@ async function promptForInstallScope( ({ required }) => required === true, ).length; - return runPrompt({ + return runPrompt({ prompt: () => { showWizardStep(step, total); return select({ message: 'How much do you want to install?', - choices: [ + options: [ { - name: formatChoice( - 'Essentials only', - pluralize(requiredComponentCount, 'required component'), - ), value: false, - short: 'Essentials only', + label: 'Essentials only', + hint: pluralize(requiredComponentCount, 'required component'), }, { - name: formatChoice( - 'Everything', - pluralize(variant.components.length, 'component'), - ), value: true, - short: 'Everything', + label: 'Everything', + hint: pluralize(variant.components.length, 'component'), }, ], - default: false, + initialValue: false, }); }, nonInteractive: { @@ -521,13 +513,13 @@ async function promptForInstallConfirmation( accept: boolean, ): Promise { showWizardStep(step, total); - log('info', `\n${review}\n`); + note(review, 'Review'); - return runPrompt({ + return runPrompt({ prompt: () => confirm({ message: 'Install now?', - default: true, + initialValue: true, }), nonInteractive: { error: @@ -548,12 +540,14 @@ async function promptForInstallConfirmation( * @param options.repository optional string containing a git URL to a repository containing the system that should be installed. * @param options.checkout optional string containing the commit/branch/tag of the system that should be used. * + * @returns string summarizing the outcome for the session's closing line. + * * @throws {CliError} if the project cannot install the requested system. */ export default async function systemInstall( name: string | void, options: InstallSystemHandlerOptions, -): Promise { +): Promise { // Attempt to load emulsify config. If none is found, this is not an Emulsify project. const projectConfig = await getEmulsifyConfig(); if (!projectConfig) { @@ -577,7 +571,7 @@ export default async function systemInstall( if (guidedInstall) { const source = await promptForSystemInstallChoice(); if (!source) { - return; + return CANCELLED_SUMMARY; } if (source.kind === 'built-in') { @@ -681,9 +675,6 @@ export default async function systemInstall( } systemLabel ||= formatSystemLabel(systemConf.name); - if (guidedInstall) { - log('info', `Loaded ${systemLabel} · ${repo.checkout}.`); - } const projectPlatform = projectConfig.project.platform; if (!isPlatform(projectPlatform)) { @@ -757,8 +748,7 @@ export default async function systemInstall( options.yes === true, ); if (!confirmed) { - log('info', 'System install cancelled. No project files were changed.'); - return; + return DECLINED_SUMMARY; } } @@ -814,10 +804,7 @@ export default async function systemInstall( ); } - return log( - 'success', - guidedInstall - ? `Successfully installed the ${systemLabel} system using the ${formatPlatformLabel(variantConf.platform)} component set.` - : `Successfully installed the ${systemConf.name} system using the ${variantConf.platform} variant.`, - ); + // Name the component set that was installed: a system can offer several, and + // which one landed is the thing a person needs to know afterwards. + return `${systemLabel} installed · ${variantConf.platform} component set`; } diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index cbe5788..9792cf3 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -11,16 +11,31 @@ jest.mock('fs-extra', () => ({ emptyDir: jest.fn(), remove: jest.fn(), })); -jest.mock('@inquirer/prompts'); +// The prompt library is ESM-only, so the mock is built from a factory rather +// than automocked from the real module. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + text: jest.fn(), + select: jest.fn(), + confirm: jest.fn(), + }; +}); +jest.mock('../../lib/session', () => ({ note: jest.fn() })); jest.mock('../../lib/log.js'); jest.mock('../fs/findFileInCurrentPath.js'); jest.mock('../fs/loadJsonFile.js'); -import { confirm, input, select } from '@inquirer/prompts'; +import { CANCEL_SYMBOL, confirm, select, text } from '@clack/prompts'; import { promises as fs } from 'fs'; import { join, normalize, resolve, sep } from 'path'; import { pathExists, remove } from 'fs-extra'; import log from '../../lib/log.js'; +import { note } from '../../lib/session.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_PROJECT_TEMPLATES_FOLDER, @@ -32,6 +47,7 @@ import type { } from '@emulsify-cli/config'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import loadJsonFile from '../fs/loadJsonFile.js'; +import { PromptCancelledError } from '../prompt/index.js'; const projectRoot = resolve( '/home/uname/Projects/cornflake/web/themes/custom/themename', @@ -83,9 +99,10 @@ 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 textMock = text as jest.Mock; const selectMock = select as jest.Mock; const confirmMock = confirm as jest.Mock; +const noteMock = note as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; function setStdinIsTTY(value: boolean | undefined) { @@ -137,7 +154,7 @@ describe('generateComponent', () => { loadJsonFileMock.mockResolvedValue({ dependencies: { '@emulsify/core': '^4.4.0' }, }); - inputMock.mockResolvedValue('cornflake-button'); + textMock.mockResolvedValue('cornflake-button'); }); afterAll(() => { @@ -197,25 +214,40 @@ describe('generateComponent', () => { expect(selectMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - 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' }), + message: 'Choose the component type', + options: expect.arrayContaining([ + expect.objectContaining({ + value: 'twig', + label: 'Twig', + hint: expect.any(String), + }), + expect.objectContaining({ + value: 'twig-sdc', + label: 'Twig SDC', + hint: expect.any(String), + }), + expect.objectContaining({ + value: 'react', + label: 'React', + hint: expect.any(String), + }), + expect.objectContaining({ + value: 'web-component', + label: 'Web Component', + hint: expect.any(String), + }), ]), }), ); - expect(selectMock.mock.calls[0][0].choices).toHaveLength(4); + expect(selectMock.mock.calls[0][0].options).toHaveLength(4); expect(selectMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - message: expect.stringContaining( - 'Choose a directory for the new component:', - ), + message: 'Choose a directory for the new component', + options: [{ value: 'base', label: 'base' }], }), ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); }); it('offers Twig and Twig SDC in a Drupal project without Core and explains the omission', async () => { @@ -228,7 +260,7 @@ describe('generateComponent', () => { }); expect( - selectMock.mock.calls[0][0].choices.map( + selectMock.mock.calls[0][0].options.map( ({ value }: { value: string }) => value, ), ).toEqual(['twig', 'twig-sdc']); @@ -238,7 +270,7 @@ describe('generateComponent', () => { 'React and Web Component are not shown because @emulsify/core is not declared', ), ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); }); it('offers Twig, React, and Web Component in a non-Drupal project with Core and explains the omission', async () => { @@ -254,7 +286,7 @@ describe('generateComponent', () => { }); expect( - selectMock.mock.calls[0][0].choices.map( + selectMock.mock.calls[0][0].options.map( ({ value }: { value: string }) => value, ), ).toEqual(['twig', 'react', 'web-component']); @@ -369,14 +401,14 @@ describe('generateComponent', () => { }); it('previews a Twig component without writing files in dry-run mode', async () => { - expect.assertions(6); + expect.assertions(7); setStdinIsTTY(false); pathExistsMock.mockImplementation((path) => { const value = String(path); return !isTemplatePath(value) && !value.endsWith(componentPath('card')); }); - await generateComponent(variant, projectConfig, 'card', { + const result = await generateComponent(variant, projectConfig, 'card', { directory: 'base', type: 'twig', dryRun: true, @@ -386,14 +418,15 @@ describe('generateComponent', () => { expect(removeMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith( - 'info', - expect.stringContaining('Dry run: component create "card"'), + expect(noteMock).toHaveBeenCalledWith( + expect.any(String), + 'Dry run: component create "card"', ); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining(componentPath('card', 'card.stories.js')), + expect.any(String), ); + expect(result).toBe('Dry run complete; no files were changed'); }); it('previews a Twig SDC component without writing files in dry-run mode', async () => { @@ -413,13 +446,13 @@ describe('generateComponent', () => { expect(removeMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining('Type: twig-sdc'), + expect.any(String), ); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining(componentPath('teaser', 'teaser.component.yml')), + expect.any(String), ); }); @@ -437,15 +470,15 @@ describe('generateComponent', () => { expect(confirm).not.toHaveBeenCalled(); expect(removeMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining('Destination exists: yes'), + expect.any(String), ); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining( 'Real run would: prompt before replacing the existing component directory', ), + expect.any(String), ); }); @@ -558,7 +591,7 @@ describe('generateComponent', () => { }); it('should cancel component creation if user declines overwrite', async () => { - expect.assertions(2); + expect.assertions(3); confirmMock.mockResolvedValueOnce(false); const result = await generateComponent(variant, projectConfig, 'link', { @@ -568,9 +601,12 @@ describe('generateComponent', () => { expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('already exists'), + initialValue: false, }), ); - expect(result).toBeUndefined(); // Returns early after logging cancellation + expect(writeFileMock).not.toHaveBeenCalled(); + // Returns early after logging cancellation, reporting the declined overwrite. + expect(result).toBe('Canceled; the existing link component was kept'); }); it('skips the overwrite confirm and replaces the component when yes is set', async () => { @@ -610,10 +646,10 @@ describe('generateComponent', () => { }); it('should continue creation if user confirms overwrite', async () => { - expect.assertions(2); + expect.assertions(3); confirmMock.mockResolvedValueOnce(true); - await generateComponent(variant, projectConfig, 'link', { + const result = await generateComponent(variant, projectConfig, 'link', { directory: 'base', type: 'twig', }); @@ -622,6 +658,20 @@ describe('generateComponent', () => { 'success', expect.stringContaining('Success!'), ); + expect(result).toBe('Created the link component in base'); + }); + + it('reports a cancelled type prompt as a prompt cancellation', async () => { + expect.assertions(2); + selectMock.mockResolvedValueOnce(CANCEL_SYMBOL); + + await expect( + generateComponent(variant, projectConfig, 'button', { + directory: 'base', + }), + ).rejects.toThrow(PromptCancelledError); + + expect(writeFileMock).not.toHaveBeenCalled(); }); it.each([ @@ -698,24 +748,24 @@ describe('generateComponent', () => { 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 '; - }, - ); + textMock.mockImplementationOnce(async ({ defaultValue, validate }) => { + expect(defaultValue).toBe('cornflake-button'); + expect(validate('button')).toContain('contain a hyphen'); + // Clack treats an undefined validation result as a valid value. + expect(validate('custom-button')).toBeUndefined(); + return ' custom-button '; + }); await generateComponent(variant, projectConfig, 'button', { directory: 'base', type: 'web-component', }); - expect(inputMock).toHaveBeenCalledWith( + expect(textMock).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining('Custom element tag name:'), - default: 'cornflake-button', + message: 'Custom element tag name', + placeholder: 'cornflake-button', + defaultValue: 'cornflake-button', validate: expect.any(Function), }), ); @@ -737,7 +787,7 @@ describe('generateComponent', () => { type: 'web-component', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).toHaveBeenCalledWith( componentPath('featured-item', 'featured-item.js'), expect.stringContaining( @@ -761,7 +811,7 @@ describe('generateComponent', () => { tagName: ' valid-button ', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).toHaveBeenCalledWith( componentPath('button', 'button.js'), expect.stringContaining( @@ -789,7 +839,7 @@ describe('generateComponent', () => { 'Invalid custom element tag name "button". Names must start with an ASCII lowercase letter', ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); @@ -831,7 +881,7 @@ describe('generateComponent', () => { '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(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index f9450a9..45b3a84 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -4,13 +4,13 @@ import type { } from '@emulsify-cli/config'; import type { CreateComponentHandlerOptions } from '@emulsify-cli/handlers'; -import { confirm, input, select } from '@inquirer/prompts'; +import { confirm, select, text } from '@clack/prompts'; import { promises as fs } from 'fs'; import { dirname } from 'path'; import { pathExists, remove } from 'fs-extra'; import log from '../../lib/log.js'; -import getTerminalColors from '../../lib/terminalColors.js'; +import { note } from '../../lib/session.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import safeResolveWithin from '../fs/safeResolveWithin.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; @@ -19,7 +19,11 @@ import { assertValidCustomElementTagName, deriveCustomElementTagName, } from '../deriveCustomElementTagName.js'; -import { isInteractiveTerminal, runPrompt } from '../prompt/index.js'; +import { + isInteractiveTerminal, + runPrompt, + type PromptResult, +} from '../prompt/index.js'; import { componentTypeFromLegacyFormat, getAvailableComponentTypes, @@ -66,9 +70,7 @@ function resolveProvidedComponentType( async function promptForComponentType( projectRoot: string, platform: EmulsifyProjectConfiguration['project']['platform'], - bold: (value: string) => string, - cyan: (value: string) => string, -): Promise { +): Promise> { const hasEmulsifyCore = await projectDeclaresEmulsifyCore(projectRoot); const availableTypes = getAvailableComponentTypes(platform, hasEmulsifyCore); @@ -98,19 +100,21 @@ async function promptForComponentType( }; return select({ - message: cyan('Choose the component type:'), - choices: availableTypes.map((type) => ({ - name: bold(TYPE_LABELS[type]), + message: 'Choose the component type', + options: availableTypes.map((type) => ({ value: type, - description: descriptions[type], + label: TYPE_LABELS[type], + hint: descriptions[type], })), }); } -function validateCustomElementTagName(value: string): true | string { +function validateCustomElementTagName( + value: string | undefined, +): string | undefined { try { - assertValidCustomElementTagName(value.trim()); - return true; + assertValidCustomElementTagName((value ?? '').trim()); + return undefined; } catch (error) { return (error as Error).message; } @@ -130,7 +134,7 @@ function validateCustomElementTagName(value: string): true | string { * @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 + * @returns summary of what happened, suitable for the closing line of the session. Distinguishes a created component from a declined overwrite and from a dry run. * @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. */ export default async function generateComponent( @@ -138,8 +142,7 @@ export default async function generateComponent( projectConfig: EmulsifyProjectConfiguration, componentName: string, options: CreateComponentHandlerOptions = {}, -): Promise { - const { bold, cyan, green, yellow } = getTerminalColors(); +): Promise { const { filename, className, camelName, pascalName, snakeName, humanName } = deriveComponentNames(componentName); const providedType = resolveProvidedComponentType(options); @@ -160,14 +163,9 @@ export default async function generateComponent( // the command never waits for input it cannot receive. const type = providedType ? providedType - : await runPrompt({ + : await runPrompt({ prompt: () => - promptForComponentType( - projectRoot, - projectConfig.project.platform, - bold, - cyan, - ), + promptForComponentType(projectRoot, projectConfig.project.platform), nonInteractive: { error: MISSING_COMPONENT_TYPE_ERROR }, }); @@ -198,11 +196,12 @@ export default async function generateComponent( projectConfig.project.machineName, ); tagName = ( - await runPrompt({ + await runPrompt({ prompt: () => - input({ - message: cyan('Custom element tag name:'), - default: derivedTagName, + text({ + message: 'Custom element tag name', + placeholder: derivedTagName, + defaultValue: derivedTagName, validate: validateCustomElementTagName, }), nonInteractive: { value: derivedTagName }, @@ -223,13 +222,13 @@ export default async function generateComponent( // Choose the component's parent structure within the given variant configuration. if (!directory) { - directory = await runPrompt({ + directory = await runPrompt({ prompt: () => select({ - message: cyan('Choose a directory for the new component:'), - choices: variant.structureImplementations.map((structure) => ({ - name: structure.name, + message: 'Choose a directory for the new component', + options: variant.structureImplementations.map((structure) => ({ value: structure.name, + label: structure.name, })), }), nonInteractive: { @@ -304,10 +303,8 @@ export default async function generateComponent( .map((filePath) => ` - ${filePath}`) .join('\n'); - return log( - 'info', + note( [ - `Dry run: component create "${filename}"`, `Type: ${type}`, `Directory: ${directory}`, `Structure path: ${structure.directory}`, @@ -319,7 +316,10 @@ export default async function generateComponent( generatedFiles, 'No files were written, removed, or created.', ].join('\n'), + `Dry run: component create "${filename}"`, ); + + return 'Dry run complete; no files were changed'; } if (!parentExists) { @@ -328,20 +328,20 @@ export default async function generateComponent( } if (componentExists) { - const shouldReplace = await runPrompt({ + const shouldReplace = await runPrompt({ prompt: () => confirm({ - message: yellow( - `The component "${humanName}" already exists in ${structure.directory}. Would you like to replace it?`, - ), - default: false, + message: `The component "${humanName}" already exists in ${structure.directory}. Would you like to replace it?`, + initialValue: false, }), nonInteractive: { value: false }, accept: { when: force, value: true }, }); if (!shouldReplace) { - return log('info', `Component creation canceled.`); + log('info', `Component creation canceled.`); + + return `Canceled; the existing ${componentName} component was kept`; } // Remove the existing component directory to ensure a clean start. @@ -366,8 +366,10 @@ export default async function generateComponent( await fs.writeFile(artifactDestination, templateFile); } - return log( + log( 'success', - `${bold(green('Success!'))} The ${bold(cyan(componentName))} component (${yellow(TYPE_LABELS[type].toUpperCase())}) has been created in ${bold(directory)}.`, + `Success! The ${componentName} component (${TYPE_LABELS[type].toUpperCase()}) has been created in ${directory}.`, ); + + return `Created the ${componentName} component in ${directory}`; } From b4130a65dd87134f77ab5cc6f89cb943b29b01f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:51:19 +0000 Subject: [PATCH 5/7] feat(deps): retire consola, inquirer, progress, and boxen The four libraries that produced the CLI's competing output styles are no longer imported anywhere. @clack/prompts replaces all four, and the `dim` used for debug output comes from colorette, which the CLI already depends on, rather than adding a second colour library. Runtime dependencies drop from ten to seven. The e2e expectations follow the message changes from the commits above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Utf5UMza9HgyUvKHWjYmpY --- package-lock.json | 550 ++++-------------------------------------- package.json | 6 +- test/e2e/cli.test.mjs | 13 +- 3 files changed, 57 insertions(+), 512 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6db14c9..403f142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,12 @@ "version": "2.4.1", "license": "GPL-2.0", "dependencies": { - "@inquirer/prompts": "^8.7.0", + "@clack/prompts": "^1.8.1", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "boxen": "^8.0.1", "colorette": "^2.0.20", "commander": "^15.0.0", - "consola": "^3.4.2", "fs-extra": "^11.4.0", - "progress": "^2.0.3", "simple-git": "^3.36.0" }, "bin": { @@ -33,7 +30,6 @@ "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", "@types/node": "^26.4.0", - "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.5.0", "json-schema-to-typescript": "^16.0.0", @@ -632,6 +628,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -989,334 +1013,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@inquirer/ansi": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", - "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.3.tgz", - "integrity": "sha512-XEYX2WA8SBkLPczL6/yXPHLPCvDoptmh9v56Cy05BSV1Smk1vWy19bTC4qJBuIffw7+6l4CcaYYzGqG60RfW1g==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.0.tgz", - "integrity": "sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.1.tgz", - "integrity": "sha512-y43COoyVUjPWIobn2Qep/uI1drPS78aaZZZ9kVi94Tyu/GuW2N8d8Q4rifJXGAXCEAXCPTTMjD8gC1HyvM5ukA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/external-editor": "^3.0.4", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.3.tgz", - "integrity": "sha512-3NQJiXNJ/aj9wiAsr7pECdp5Qe9J0X9YUJCKsaFXS+ddOxfL6J4AIl3w3T4Gq3kK0WsQY5GMoDokK5X94m6lHw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.4.tgz", - "integrity": "sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", - "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.4.tgz", - "integrity": "sha512-3xQkQrOvgOzpSN2ciTVdRDlg1FWMCA8l+0KfB6SNlILoTCGzJTzO/gc0Rwjcb3usuGyKdaGtI6OiyMdeMeLWkg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.1.tgz", - "integrity": "sha512-5KaqwZNLRpUuWcoCrYghPP9TMaXL5v2Sk4xqePM7RCVegcJStoXdWibio60YIC1bec+z1fCyb67N6XPJIkZtGA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.0.tgz", - "integrity": "sha512-CvVcW09emkBESEOW+4R8CjLNkP3fB3XrjeL8CDvfpjgrJN+V9oerXmJAXXM3l+4xqYPD5Yaujzy/Ph0PLOdDuA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.0.tgz", - "integrity": "sha512-yQwBMYvpJ6jqrXtKiOwRD5XezjJoyt3VQvIyjsr5Arqb519nfIohOQymWVJ8/vEgg8xtZerrCsqlSaqt/LPC9A==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.2.3", - "@inquirer/confirm": "^6.3.0", - "@inquirer/editor": "^5.3.1", - "@inquirer/expand": "^5.1.3", - "@inquirer/input": "^5.1.4", - "@inquirer/number": "^4.2.1", - "@inquirer/password": "^5.2.0", - "@inquirer/rawlist": "^5.3.3", - "@inquirer/search": "^4.3.1", - "@inquirer/select": "^5.2.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.3.tgz", - "integrity": "sha512-Mu7WrtmDLaXBDEyrRLS70SZgX9ZSm4Up1w0ZxiH8C1OOp9oaVCn2k8q3QGgmlnhsKYUhuaU3zFWhAP6wxkVIMA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.1.tgz", - "integrity": "sha512-0VWOvsHWI0rPj6CG70MoP4oXNCB6adcyN8bVFZXnh11eLDdPIK2f2XCmva78acPPDfJisfab7qNakwBh7hdBXw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.3.tgz", - "integrity": "sha512-KuRTodDa6xBXX2noIpjuitpX/QT7Sfav7dIZ/OfUY54Hxg95nrGoshSzxx6Ey7qqLbImKdiGkSDt7KjPXjQgmA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.0.tgz", - "integrity": "sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3154,7 +2850,7 @@ "version": "26.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -3167,16 +2863,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/progress": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/progress/-/progress-2.0.7.tgz", - "integrity": "sha512-iadjw02vte8qWx7U0YM++EybBha2CQLPGu9iJ97whVgJUT5Zq9MjAPYUnbfRI2Kpehimf1QjFJYxD0t8nqzu5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3607,35 +3293,6 @@ } } }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3669,6 +3326,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3947,28 +3605,6 @@ "dev": true, "license": "MIT" }, - "node_modules/boxen": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -4062,18 +3698,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -4099,6 +3723,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -4117,12 +3742,6 @@ "node": ">=10" } }, - "node_modules/chardet": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", - "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", - "license": "MIT" - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4187,18 +3806,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -4373,15 +3980,6 @@ "node": ">=8" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -4495,15 +4093,6 @@ "dev": true, "license": "ISC" }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/content-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.0.0.tgz", @@ -4876,6 +4465,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, "license": "MIT" }, "node_modules/emojilib": { @@ -5383,6 +4973,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5627,22 +5218,6 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -5809,6 +5384,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7758,15 +7334,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -10376,15 +9943,6 @@ "dev": true, "license": "MIT" }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -10755,12 +10313,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semantic-release": { "version": "25.0.9", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz", @@ -10904,6 +10456,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -11048,6 +10601,12 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -11213,6 +10772,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11253,6 +10813,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -11268,6 +10829,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11304,6 +10866,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11813,6 +11376,7 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -11870,7 +11434,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -12070,21 +11634,6 @@ "node": ">= 8" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -12096,6 +11645,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -12170,6 +11720,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12182,6 +11733,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" diff --git a/package.json b/package.json index 3edf643..7476f80 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", "@types/node": "^26.4.0", - "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.5.0", "json-schema-to-typescript": "^16.0.0", @@ -71,15 +70,12 @@ "typescript": "^6.0.3" }, "dependencies": { - "@inquirer/prompts": "^8.7.0", + "@clack/prompts": "^1.8.1", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "boxen": "^8.0.1", "colorette": "^2.0.20", "commander": "^15.0.0", - "consola": "^3.4.2", "fs-extra": "^11.4.0", - "progress": "^2.0.3", "simple-git": "^3.36.0" } } diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 2b19720..5259f2d 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -367,11 +367,8 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(result.status, 0, commandFailure('--version', result)); assert.equal(result.stderr, ''); - assert.equal(result.stdout.includes('Emulsify CLI'), true); - assert.equal( - result.stdout.includes(`Version: ${packageInfo.version}`), - true, - ); + // The version is a value scripts read, so it prints as one plain line. + assert.equal(result.stdout.trim(), packageInfo.version); }); test('fails fast when component create has no name outside a TTY', () => { @@ -592,7 +589,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); assert.match( installResult.stdout, - /Successfully installed the round-trip-system system using the drupal \|\| wordpress variant/, + /Round Trip System installed\s+·\s+drupal \|\| wordpress component set/, ); const installedComponentRoot = join( @@ -812,7 +809,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(result.status, 0, commandFailure('init', result)); assert.equal(result.stderr.trim(), ''); - assert.match(result.stdout, /Created an Emulsify project/); + assert.match(result.stdout, /Created fixture-project in /); assert.deepEqual( JSON.parse( readFileSync(join(projectRoot, 'project.emulsify.json'), 'utf8'), @@ -855,7 +852,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(result.stderr, ''); assert.match( result.stdout, - /Successfully installed the fixture-system system using the wordpress variant/, + /Fixture System installed\s+·\s+wordpress component set/, ); const projectConfig = JSON.parse( From b70e05d097a41cc73aac1904ae120b06e5056912 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:51:47 +0000 Subject: [PATCH 6/7] chore(release): set the develop version to 2.5.0 Computed by `npm run version:develop` from the unreleased history: a minor release from v2.4.1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Utf5UMza9HgyUvKHWjYmpY --- 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 403f142..d9d456a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@emulsify/cli", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.4.1", + "version": "2.5.0", "license": "GPL-2.0", "dependencies": { "@clack/prompts": "^1.8.1", diff --git a/package.json b/package.json index 7476f80..5dcb9e9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.4.1", + "version": "2.5.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 ab3776339e3c6cf854a193f765ee420d3f87f972 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:54:56 +0000 Subject: [PATCH 7/7] feat(cli): assert the plain version line in the packed smoke test The packed install smoke test still expected the boxed "Version: x.y.z" format. It now asserts the plain line the CLI prints. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Utf5UMza9HgyUvKHWjYmpY --- scripts/smoke-pack.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-pack.mjs b/scripts/smoke-pack.mjs index 4cd0e90..a2a6b05 100644 --- a/scripts/smoke-pack.mjs +++ b/scripts/smoke-pack.mjs @@ -169,10 +169,11 @@ function smokeTest(tempRoot) { 'emulsify --help', ); + // The version is a value scripts read, so it prints as one plain line. const versionResult = runCommand(localBin, ['--version'], commandOptions); assertCommandOutput( versionResult, - `Version: ${packageManifest.version}`, + packageManifest.version, 'emulsify --version', );