diff --git a/.changeset/thin-donuts-drive.md b/.changeset/thin-donuts-drive.md new file mode 100644 index 000000000..53bb48699 --- /dev/null +++ b/.changeset/thin-donuts-drive.md @@ -0,0 +1,5 @@ +--- +"@farmfe/cli": patch +--- + +Rewrite cli with citty diff --git a/cspell.json b/cspell.json index 7ba0ff88c..99b96bb6c 100644 --- a/cspell.json +++ b/cspell.json @@ -28,6 +28,7 @@ "canonicalize", "changset", "chpos", + "citty", "clippy", "clsx", "codeframe", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3675718e2..e380c0383 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,8 +41,8 @@ "node": ">= 16" }, "dependencies": { - "cac": "^6.7.14", "cross-spawn": "^7.0.3", + "citty": "^0.1.6", "inquirer": "^9.1.4", "walkdir": "^0.4.1" }, diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts new file mode 100644 index 000000000..bdc05fcdb --- /dev/null +++ b/packages/cli/src/commands/build.ts @@ -0,0 +1,73 @@ +import { defineCommand } from 'citty'; +import { getOptionFromBuildOption } from '../config.js'; +import { + FarmCLIBuildOptions, + FarmCLICommonOptions, + NormalizedFarmCLIBuildOptions +} from '../types.js'; +import { + handleAsyncOperationErrors, + resolveCliConfig, + resolveCore +} from '../utils.js'; + +export default defineCommand({ + meta: { + name: 'build', + description: 'compile the project in production mode' + }, + args: { + root: { + type: 'positional', + description: 'root path', + required: false, + valueHint: 'path' + }, + outDir: { + type: 'string', + alias: 'o', + description: 'output directory' + }, + input: { + type: 'string', + alias: 'i', + description: 'input file path' + }, + watch: { type: 'boolean', alias: 'w', description: 'watch file change' }, + target: { + type: 'string', + description: 'transpile targetEnv node, browser' + }, + format: { + type: 'string', + description: 'transpile format esm, commonjs' + }, + sourcemap: { + type: 'boolean', + description: 'output source maps for build' + }, + treeShaking: { + type: 'boolean', + description: 'Eliminate useless code without side effects' + }, + minify: { + type: 'boolean', + description: 'code compression at build time' + } + }, + async run({ args }: { args: FarmCLICommonOptions & FarmCLIBuildOptions }) { + const { root, configPath } = resolveCliConfig( + args.root, + args.config ?? args.c + ); + + const defaultOptions = { + root, + configPath, + ...getOptionFromBuildOption(args as NormalizedFarmCLIBuildOptions) + }; + + const { build } = await resolveCore(); + handleAsyncOperationErrors(build(defaultOptions), 'error during build'); + } +}); diff --git a/packages/cli/src/commands/clean.ts b/packages/cli/src/commands/clean.ts new file mode 100644 index 000000000..87d8893c4 --- /dev/null +++ b/packages/cli/src/commands/clean.ts @@ -0,0 +1,37 @@ +import { defineCommand } from 'citty'; +import { FarmCLICommonOptions, ICleanOptions } from '../types.js'; +import { resolveCliConfig, resolveCore } from '../utils.js'; + +export default defineCommand({ + meta: { + name: 'clean', + description: 'Clean up the cache built incrementally' + }, + args: { + root: { + type: 'positional', + description: 'root path', + required: false, + valueHint: 'path' + }, + recursive: { + type: 'boolean', + alias: 'r', + description: + 'Recursively search for node_modules directories and clean them' + } + }, + async run({ args }: { args: FarmCLICommonOptions & ICleanOptions }) { + const { root } = resolveCliConfig(args.root, args.config); + + const { clean } = await resolveCore(); + try { + await clean(root, args.recursive); + } catch (e) { + const { Logger } = await import('@farmfe/core'); + const logger = new Logger(); + logger.error(`Failed to clean cache: \n ${e.stack}`); + process.exit(1); + } + } +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts new file mode 100644 index 000000000..047060d9e --- /dev/null +++ b/packages/cli/src/commands/dev.ts @@ -0,0 +1,55 @@ +import { defineCommand } from 'citty'; +import { + FarmCLICommonOptions, + FarmCLIServerOptions, + GlobalFarmCLIOptions +} from '../types.js'; +import { + handleAsyncOperationErrors, + resolveCliConfig, + resolveCommandOptions, + resolveCore +} from '../utils.js'; + +export default defineCommand({ + meta: { + name: 'dev', + description: + 'Compile the project in dev mode and serve it with farm dev server' + }, + args: { + root: { + type: 'positional', + description: 'root path', + required: false, + valueHint: 'path' + }, + lazy: { type: 'boolean', alias: 'l', description: 'lazyCompilation' }, + host: { type: 'string', description: 'specify host' }, + port: { type: 'string', description: 'specify port' }, + open: { type: 'boolean', description: 'open browser on server start' }, + hmr: { type: 'boolean', description: 'enable hot module replacement' }, + cors: { type: 'boolean', description: 'enable cors' }, + strictPort: { + type: 'boolean', + description: 'specified port is already in use, exit with error' + } + }, + async run({ args }: { args: FarmCLICommonOptions & FarmCLIServerOptions }) { + const { root, configPath } = resolveCliConfig(args.root, args.config); + + const resolvedOptions = resolveCommandOptions(args as GlobalFarmCLIOptions); + const defaultOptions = { + root, + compilation: { + lazyCompilation: args.lazy + }, + server: resolvedOptions, + clearScreen: args.clearScreen, + configPath, + mode: args.mode + }; + const { start } = await resolveCore(); + handleAsyncOperationErrors(start(defaultOptions), 'Failed to start server'); + } +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts new file mode 100644 index 000000000..3852dc221 --- /dev/null +++ b/packages/cli/src/commands/preview.ts @@ -0,0 +1,50 @@ +import { defineCommand } from 'citty'; +import { + FarmCLICommonOptions, + FarmCLIPreviewOptions, + GlobalFarmCLIOptions +} from '../types.js'; +import { + handleAsyncOperationErrors, + resolveCliConfig, + resolveCommandOptions, + resolveCore +} from '../utils.js'; + +export default defineCommand({ + meta: { + name: 'preview', + description: 'compile the project in watch mode' + }, + args: { + root: { + type: 'positional', + description: 'root path', + required: false, + valueHint: 'path' + }, + port: { type: 'string', description: 'specify port' }, + open: { + type: 'boolean', + description: 'open browser on server preview start' + } + }, + async run({ args }: { args: FarmCLICommonOptions & FarmCLIPreviewOptions }) { + const { root, configPath } = resolveCliConfig(args.root, args.config); + + const resolvedOptions = resolveCommandOptions(args as GlobalFarmCLIOptions); + const defaultOptions = { + root, + mode: args.mode, + server: resolvedOptions, + configPath, + port: resolvedOptions.port + }; + + const { preview } = await resolveCore(); + handleAsyncOperationErrors( + preview(defaultOptions), + 'Failed to start preview server' + ); + } +}); diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts new file mode 100644 index 000000000..e378c196e --- /dev/null +++ b/packages/cli/src/commands/watch.ts @@ -0,0 +1,75 @@ +import { defineCommand } from 'citty'; +import { getOptionFromBuildOption } from '../config.js'; +import { + FarmCLIBuildOptions, + GlobalFarmCLIOptions, + NormalizedFarmCLIBuildOptions +} from '../types.js'; +import { + handleAsyncOperationErrors, + resolveCliConfig, + resolveCore +} from '../utils.js'; + +export default defineCommand({ + meta: { + name: 'watch', + description: 'watch file change' + }, + args: { + root: { + type: 'positional', + description: 'root path', + required: false, + valueHint: 'path' + }, + outDir: { + type: 'string', + alias: 'o', + description: 'output directory' + }, + input: { + type: 'string', + alias: 'i', + description: 'input file path' + }, + target: { + type: 'string', + description: 'transpile targetEnv node, browser' + }, + format: { + type: 'string', + description: 'transpile format esm, commonjs' + }, + sourcemap: { + type: 'boolean', + description: 'output source maps for build' + }, + treeShaking: { + type: 'boolean', + description: 'Eliminate useless code without side effects' + }, + minify: { + type: 'boolean', + description: 'code compression at build time' + } + }, + async run({ args }: { args: FarmCLIBuildOptions & GlobalFarmCLIOptions }) { + const { root, configPath } = resolveCliConfig( + args.root, + args.config ?? args.c + ); + + const defaultOptions = { + root, + configPath, + ...getOptionFromBuildOption(args as NormalizedFarmCLIBuildOptions) + }; + + const { watch } = await resolveCore(); + handleAsyncOperationErrors( + watch(defaultOptions), + 'error during watch project' + ); + } +}); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 467019af8..1ae76c4ba 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,5 +1,10 @@ import { FarmCLIOptions, UserConfig } from '@farmfe/core'; -import { FarmCLIBuildOptions, GlobalFarmCLIOptions } from './types.js'; +import { + FarmCLIBuildOptions, + GlobalFarmCLIOptions, + NormalizedFarmCLIBuildOptions +} from './types.js'; +import { resolveCommonOptions } from './utils.js'; export function getOptionFromBuildOption( options: FarmCLIBuildOptions & GlobalFarmCLIOptions @@ -14,7 +19,8 @@ export function getOptionFromBuildOption( sourcemap, treeShaking, mode - } = options; + } = resolveCommonOptions(options) as NormalizedFarmCLIBuildOptions & + GlobalFarmCLIOptions; const output: UserConfig['compilation']['output'] = { ...(outDir && { path: outDir }), diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e8409c5c3..4118c7432 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,204 +1,48 @@ import { readFileSync } from 'node:fs'; -import { cac } from 'cac'; -import { getOptionFromBuildOption } from './config.js'; -import { - handleAsyncOperationErrors, - preventExperimentalWarning, - resolveCliConfig, - resolveCommandOptions, - resolveCore -} from './utils.js'; +import { defineCommand, runMain } from 'citty'; -import type { - FarmCLIBuildOptions, - FarmCLIPreviewOptions, - FarmCLIServerOptions, - GlobalFarmCLIOptions, - ICleanOptions -} from './types.js'; +import buildCommand from './commands/build.js'; +import cleanCommand from './commands/clean.js'; +import devCommand from './commands/dev.js'; +import previewCommand from './commands/preview.js'; +import watchCommand from './commands/watch.js'; const { version } = JSON.parse( readFileSync(new URL('../package.json', import.meta.url)).toString() ); -const cli = cac('farm'); - -// common command -cli - .option('-c, --config ', 'use specified config file') - .option('-m, --mode ', 'set env mode') - .option('--base ', 'public base path') - .option('--clearScreen', 'allow/disable clear screen when logging', { - default: true - }); - -// dev command -cli - .command( - '[root]', - 'Compile the project in dev mode and serve it with farm dev server' - ) - .alias('start') - .alias('dev') - .option('-l, --lazy', 'lazyCompilation') - .option('--host ', 'specify host') - .option('--port ', 'specify port') - .option('--open', 'open browser on server start') - .option('--hmr', 'enable hot module replacement') - .option('--cors', 'enable cors') - .option('--strictPort', 'specified port is already in use, exit with error') - .action( - async ( - rootPath: string, - options: FarmCLIServerOptions & GlobalFarmCLIOptions - ) => { - const { root, configPath } = resolveCliConfig(rootPath, options); - const resolveOptions = resolveCommandOptions(options); - - const defaultOptions = { - root, - compilation: { - lazyCompilation: options.lazy - }, - server: resolveOptions, - clearScreen: options.clearScreen, - configPath, - mode: options.mode - }; - - const { start } = await resolveCore(); - handleAsyncOperationErrors( - start(defaultOptions), - 'Failed to start server' - ); - } - ); - -// build command -cli - .command('build [root]', 'compile the project in production mode') - .option('-o, --outDir ', 'output directory') - .option('-i, --input ', 'input file path') - .option('-w, --watch', 'watch file change') - .option('--target ', 'transpile targetEnv node, browser') - .option('--format ', 'transpile format esm, commonjs') - .option('--sourcemap', 'output source maps for build') - .option('--treeShaking', 'Eliminate useless code without side effects') - .option('--minify', 'code compression at build time') - .action( - async ( - rootPath: string, - options: FarmCLIBuildOptions & GlobalFarmCLIOptions - ) => { - const { root, configPath } = resolveCliConfig(rootPath, options); - - const defaultOptions = { - root, - configPath, - ...getOptionFromBuildOption(options) - }; - - const { build } = await resolveCore(); - handleAsyncOperationErrors(build(defaultOptions), 'error during build'); - } - ); - -cli - .command('watch [root]', 'watch file change') - .option('-o, --outDir ', 'output directory') - .option('-i, --input ', 'input file path') - .option('--target ', 'transpile targetEnv node, browser') - .option('--format ', 'transpile format esm, commonjs') - .option('--sourcemap', 'output source maps for build') - .option('--treeShaking', 'Eliminate useless code without side effects') - .option('--minify', 'code compression at build time') - .action( - async ( - rootPath: string, - options: FarmCLIBuildOptions & GlobalFarmCLIOptions - ) => { - const { root, configPath } = resolveCliConfig(rootPath, options); - - const defaultOptions = { - root, - configPath, - ...getOptionFromBuildOption(options) - }; - - const { watch } = await resolveCore(); - handleAsyncOperationErrors( - watch(defaultOptions), - 'error during watch project' - ); +const main = defineCommand({ + meta: { + name: 'farm', + version + }, + args: { + config: { + type: 'string', + alias: 'c', + description: 'use specified config file' + }, + mode: { type: 'string', alias: 'm', description: 'set env mode' }, + base: { type: 'string', description: 'public base path' }, + clearScreen: { + type: 'boolean', + default: true, + description: 'allow/disable clear screen when logging' } - ); - -cli - .command('preview [root]', 'compile the project in watch mode') - .option('--port ', 'specify port') - .option('--open', 'open browser on server preview start') - .action( - async ( - rootPath: string, - options: FarmCLIPreviewOptions & GlobalFarmCLIOptions - ) => { - const { root, configPath } = resolveCliConfig(rootPath, options); - - const resolveOptions = resolveCommandOptions(options); - const defaultOptions = { - root, - mode: options.mode, - server: resolveOptions, - configPath, - port: options.port - }; - - const { preview } = await resolveCore(); - handleAsyncOperationErrors( - preview(defaultOptions), - 'Failed to start preview server' - ); - } - ); - -cli - .command('clean [path]', 'Clean up the cache built incrementally') - .option( - '--recursive', - 'Recursively search for node_modules directories and clean them' - ) - .action(async (rootPath: string, options: ICleanOptions) => { - const { root } = resolveCliConfig(rootPath, options); - const { clean } = await resolveCore(); - - try { - await clean(root, options?.recursive); - } catch (e) { - const { Logger } = await import('@farmfe/core'); - const logger = new Logger(); - logger.error(`Failed to clean cache: \n ${e.stack}`); - process.exit(1); - } - }); - -// Listening for unknown command -cli.on('command:*', async () => { - const { Logger } = await import('@farmfe/core'); - const logger = new Logger(); - logger.error( - 'Unknown command place Run "farm --help" to see available commands' - ); + }, + subCommands: { + dev: devCommand, + // alias for dev + start: devCommand, + build: buildCommand, + watch: watchCommand, + preview: previewCommand, + clean: cleanCommand + } }); -// warning::: use mdn browser compatibility data with experimental warning in terminal so prevent experimental warning -// we don't use it in `@farmfe/core` package because -// we need to prevent it in cli package but we don't prevent it in core package -// We only keep the original code environment. -preventExperimentalWarning(); - -cli.help(); - -cli.version(version); - -cli.parse(); +// default to start a development server +if (process.argv.slice(2).length === 0) + runMain(main, { rawArgs: process.argv.slice(2).concat(['dev']) }); +else runMain(main); diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index afdd44364..d457f4976 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,41 +1,51 @@ -export interface GlobalFarmCLIOptions { - '--'?: string[]; - c?: boolean | string; +export interface FarmCLICommonOptions { + _?: string[]; + c?: string; config?: string; - configPath?: string; - m?: string; + m?: 'development' | 'production' | string; + mode?: 'development' | 'production' | string; base?: string; - mode?: 'development' | 'production'; - w?: boolean; - watch?: boolean; - watchPath?: string; - port?: number; - lazy?: boolean; - l?: boolean; clearScreen?: boolean; } -export interface ICleanOptions { - path?: string; - recursive?: boolean; -} - export interface FarmCLIServerOptions { - port?: string; + _?: string[]; + root?: string; + l?: boolean; + lazy?: boolean; + host?: string; + port?: string | number; open?: boolean; - https?: boolean; hmr?: boolean; + cors?: boolean; strictPort?: boolean; } export interface FarmCLIBuildOptions { + _?: string[]; + root?: string; input?: string; outDir?: string; sourcemap?: boolean; minify?: boolean; treeShaking?: boolean; - format?: 'cjs' | 'esm'; + format?: 'cjs' | 'esm' | string; target?: + | 'browser' + | 'node' + | 'node16' + | 'node-legacy' + | 'node-next' + | 'browser-legacy' + | 'browser-es2015' + | 'browser-es2017' + | 'browser-esnext' + | string; +} + +export interface NormalizedFarmCLIBuildOptions extends FarmCLIBuildOptions { + format: 'cjs' | 'esm'; + target: | 'browser' | 'node' | 'node16' @@ -47,7 +57,33 @@ export interface FarmCLIBuildOptions { | 'browser-esnext'; } +export interface GlobalFarmCLIOptions { + _?: string[]; + c?: string; + config?: string; + configPath?: string; + m?: 'development' | 'production'; + base?: string; + mode?: 'development' | 'production'; + w?: boolean; + watch?: boolean; + watchPath?: string; + port?: number; + lazy?: boolean; + l?: boolean; + clearScreen?: boolean; +} + +export interface ICleanOptions { + _?: string[]; + root?: string; + path?: string; + recursive?: boolean; +} + export interface FarmCLIPreviewOptions { + _?: string[]; + root?: string; open?: boolean; - port?: number; + port?: number | string; } diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 18717f8cb..a304a55a6 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -7,7 +7,11 @@ import { Logger } from '@farmfe/core'; import spawn from 'cross-spawn'; import walkdir from 'walkdir'; -import type { GlobalFarmCLIOptions, ICleanOptions } from './types.js'; +import type { + FarmCLIBuildOptions, + FarmCLICommonOptions, + GlobalFarmCLIOptions +} from './types.js'; const logger = new Logger(); interface installProps { @@ -94,7 +98,7 @@ export async function install(options: installProps): Promise { } /** * 用于规范化目标路径 - * @param {string |undefined} targetDir + * @param {string | undefined} targetDir * @returns */ export function formatTargetDir(targetDir: string | undefined) { @@ -126,7 +130,7 @@ export function clearScreen() { export function cleanOptions(options: GlobalFarmCLIOptions) { const resolveOptions = { ...options }; - delete resolveOptions['--']; + delete resolveOptions._; delete resolveOptions.m; delete resolveOptions.c; delete resolveOptions.w; @@ -143,7 +147,7 @@ export function cleanOptions(options: GlobalFarmCLIOptions) { export function resolveCommandOptions( options: GlobalFarmCLIOptions ): GlobalFarmCLIOptions { - const resolveOptions = { ...options }; + const resolveOptions = resolveCommonOptions({ ...options }); filterDuplicateOptions(resolveOptions); return cleanOptions(resolveOptions); } @@ -164,31 +168,33 @@ export async function handleAsyncOperationErrors( } } -// prevent node experimental warning -export function preventExperimentalWarning() { - const defaultEmit = process.emit; - process.emit = function (...args: any[]) { - if (args[1].name === 'ExperimentalWarning') { - return undefined; - } - return defaultEmit.call(this, ...args); - }; -} - export function resolveRootPath(rootPath = '') { return rootPath && path.isAbsolute(rootPath) ? rootPath : path.resolve(process.cwd(), rootPath); } -export function resolveCliConfig( - root: string, - options: GlobalFarmCLIOptions & ICleanOptions -) { +export function resolveCliConfig(root: string, config: string) { root = resolveRootPath(root); - const configPath = getConfigPath(root, options.config); + const configPath = getConfigPath(root, config); return { root, configPath }; } + +/** + * resolve common options to make sure they are consistent with the their alias + * @param {FarmCLICommonOptions & GlobalFarmCLIOptions} options + * @returns {GlobalFarmCLIOptions} + */ +export function resolveCommonOptions( + options: FarmCLICommonOptions & GlobalFarmCLIOptions +): FarmCLIBuildOptions & GlobalFarmCLIOptions { + const resolvedOptions = { ...options }; + resolvedOptions.c && (resolvedOptions.config = resolvedOptions.c); + resolvedOptions.config && (resolvedOptions.c = resolvedOptions.config); + resolvedOptions.m && (resolvedOptions.mode = resolvedOptions.m); + resolvedOptions.mode && (resolvedOptions.m = resolvedOptions.mode); + return resolvedOptions; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fee3e085..9b4db877f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2062,9 +2062,9 @@ importers: packages/cli: dependencies: - cac: - specifier: ^6.7.14 - version: 6.7.14 + citty: + specifier: ^0.1.6 + version: 0.1.6 cross-spawn: specifier: ^7.0.3 version: 7.0.3 @@ -5589,6 +5589,9 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + class-utils@0.3.6: resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} engines: {node: '>=0.10.0'} @@ -5820,6 +5823,10 @@ packages: resolution: {integrity: sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==} engines: {node: '>=8'} + consola@3.2.3: + resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} + engines: {node: ^14.18.0 || >=16.10.0} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} @@ -16458,6 +16465,10 @@ snapshots: ci-info@3.9.0: {} + citty@0.1.6: + dependencies: + consola: 3.2.3 + class-utils@0.3.6: dependencies: arr-union: 3.1.0 @@ -16698,6 +16709,8 @@ snapshots: write-file-atomic: 3.0.3 xdg-basedir: 4.0.0 + consola@3.2.3: {} + console-control-strings@1.1.0: {} consolidate@0.15.1(ejs@3.1.10)(lodash@4.17.21):