diff --git a/src/cache.ts b/src/cache.ts index 92041ca88..3f5a5291a 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,17 +1,28 @@ +import {readFileSync} from 'node:fs' +import {join} from 'node:path' + import {PJSON, Plugin} from './interfaces' type CacheContents = { rootPlugin: Plugin exitCodes: PJSON.Plugin['oclif']['exitCodes'] + '@oclif/core': OclifCoreInfo } type ValueOf = T[keyof T] +type OclifCoreInfo = {name: string; version: string} + /** * A simple cache for storing values that need to be accessed globally. */ export default class Cache extends Map> { static instance: Cache + public constructor() { + super() + this.set('@oclif/core', this.getOclifCoreMeta()) + } + static getInstance(): Cache { if (!Cache.instance) { Cache.instance = new Cache() @@ -20,9 +31,26 @@ export default class Cache extends Map | undefined { return super.get(key) } + + private getOclifCoreMeta(): OclifCoreInfo { + try { + // eslint-disable-next-line node/no-extraneous-require + return {name: '@oclif/core', version: require('@oclif/core/package.json').version} + } catch { + try { + return { + name: '@oclif/core', + version: JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')), + } + } catch { + return {name: '@oclif/core', version: 'unknown'} + } + } + } } diff --git a/src/cli-ux/config.ts b/src/cli-ux/config.ts index 2d3abb900..b60d8f31c 100644 --- a/src/cli-ux/config.ts +++ b/src/cli-ux/config.ts @@ -1,5 +1,4 @@ -import {PJSON} from '../interfaces/pjson' -import {requireJson} from '../util/fs' +import Cache from '../cache' import {ActionBase} from './action/base' import simple from './action/simple' import spinner from './action/spinner' @@ -51,7 +50,8 @@ export class Config { } function fetch() { - const major = requireJson(__dirname, '..', '..', 'package.json').version.split('.')[0] + const core = Cache.getInstance().get('@oclif/core') + const major = core?.version.split('.')[0] || 'unknown' if (globals[major]) return globals[major] globals[major] = new Config() return globals[major] diff --git a/src/command.ts b/src/command.ts index 7d6c86a65..f66d33e1d 100644 --- a/src/command.ts +++ b/src/command.ts @@ -2,12 +2,12 @@ import chalk from 'chalk' import {fileURLToPath} from 'node:url' import {inspect} from 'node:util' +import Cache from './cache' import {ux} from './cli-ux' import {Config} from './config' import * as Errors from './errors' import {PrettyPrintableError} from './errors' import {formatCommandDeprecationWarning, formatFlagDeprecationWarning, normalizeArgv} from './help/util' -import {PJSON} from './interfaces' import {LoadOptions} from './interfaces/config' import {CommandError} from './interfaces/errors' import { @@ -27,11 +27,10 @@ import { import {Plugin} from './interfaces/plugin' import * as Parser from './parser' import {aggregateFlags} from './util/aggregate-flags' -import {requireJson} from './util/fs' import {toConfiguredId} from './util/ids' import {uniq} from './util/util' -const pjson = requireJson(__dirname, '..', 'package.json') +const pjson = Cache.getInstance().get('@oclif/core') /** * swallows stdout epipe errors diff --git a/src/config/config.ts b/src/config/config.ts index a6e2a73c8..f039ff35d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -17,7 +17,7 @@ import {Theme} from '../interfaces/theme' import {loadWithData} from '../module-loader' import {OCLIF_MARKER_OWNER, Performance} from '../performance' import {settings} from '../settings' -import {requireJson, safeReadJson} from '../util/fs' +import {safeReadJson} from '../util/fs' import {getHomeDir, getPlatform} from '../util/os' import {compact, isProd} from '../util/util' import PluginLoader from './plugin-loader' @@ -27,7 +27,7 @@ import {Debug, collectUsableIds, getCommandIdPermutations} from './util' // eslint-disable-next-line new-cap const debug = Debug() -const _pjson = requireJson(__dirname, '..', '..', 'package.json') +const _pjson = Cache.getInstance().get('@oclif/core') const BASE = `${_pjson.name}@${_pjson.version}` function channelFromVersion(version: string) { @@ -554,12 +554,13 @@ export class Config implements IConfig { const marker = Performance.mark(OCLIF_MARKER_OWNER, `config.runHook#${p.name}(${hook})`) try { /* eslint-disable no-await-in-loop */ - const {filePath, isESM, module} = await loadWithData(p, await tsPath(p.root, hook, p)) + const {filePath, isESM, module} = await loadWithData(p, await tsPath(p.root, hook.target, p)) debug('start', isESM ? '(import)' : '(require)', filePath) + const hookFn = module[hook.identifier] ?? search(module) const result = timeout - ? await withTimeout(timeout, search(module).call(context, {...(opts as any), config: this, context})) - : await search(module).call(context, {...(opts as any), config: this, context}) + ? await withTimeout(timeout, hookFn.call(context, {...(opts as any), config: this, context})) + : await hookFn.call(context, {...(opts as any), config: this, context}) final.successes.push({plugin: p, result}) if (p.name === '@oclif/plugin-legacy' && event === 'init') { @@ -585,7 +586,7 @@ export class Config implements IConfig { marker?.addDetails({ event, - hook, + hook: hook.target, plugin: p.name, }) marker?.stop() diff --git a/src/config/plugin.ts b/src/config/plugin.ts index 38ee4a3a3..ad90f10a9 100644 --- a/src/config/plugin.ts +++ b/src/config/plugin.ts @@ -2,10 +2,11 @@ import globby from 'globby' import {join, parse, relative, sep} from 'node:path' import {inspect} from 'node:util' +import Cache from '../cache' import {Command} from '../command' import {CLIError, error} from '../errors' import {Manifest} from '../interfaces/manifest' -import {CommandDiscovery, PJSON} from '../interfaces/pjson' +import {CommandDiscovery, HookOptions, PJSON} from '../interfaces/pjson' import {Plugin as IPlugin, PluginOptions} from '../interfaces/plugin' import {Topic} from '../interfaces/topic' import {load, loadWithData, loadWithDataFromManifest} from '../module-loader' @@ -13,12 +14,12 @@ import {OCLIF_MARKER_OWNER, Performance} from '../performance' import {SINGLE_COMMAND_CLI_SYMBOL} from '../symbols' import {cacheCommand} from '../util/cache-command' import {findRoot} from '../util/find-root' -import {readJson, requireJson} from '../util/fs' +import {readJson} from '../util/fs' import {castArray, compact} from '../util/util' import {tsPath} from './ts-node' import {Debug, getCommandIdPermutations} from './util' -const _pjson = requireJson(__dirname, '..', '..', 'package.json') +const _pjson = Cache.getInstance().get('@oclif/core') function topicsToArray(input: any, base?: string): Topic[] { if (!input) return [] @@ -74,9 +75,19 @@ function determineCommandDiscoveryOptions( if (!commandDiscovery.target) throw new CLIError('`oclif.commandDiscovery.target` is required.') if (!commandDiscovery.strategy) throw new CLIError('`oclif.commandDiscovery.strategy` is required.') + if (commandDiscovery.strategy === 'explicit' && !commandDiscovery.identifier) { + commandDiscovery.identifier = 'default' + } + return commandDiscovery } +function determineHookOptions(hook: string | HookOptions): HookOptions { + if (typeof hook === 'string') return {identifier: 'default', target: hook} + if (!hook.identifier) return {...hook, identifier: 'default'} + return hook +} + /** * Cached commands, where the key is the command ID and the value is the command class. * @@ -101,7 +112,7 @@ export class Plugin implements IPlugin { hasManifest = false - hooks!: {[k: string]: string[]} + hooks!: {[key: string]: HookOptions[]} isRoot = false @@ -224,7 +235,12 @@ export class Plugin implements IPlugin { this.pjson.oclif = this.pjson['cli-engine'] || {} } - this.hooks = Object.fromEntries(Object.entries(this.pjson.oclif.hooks ?? {}).map(([k, v]) => [k, castArray(v)])) + this.hooks = Object.fromEntries( + Object.entries(this.pjson.oclif.hooks ?? {}).map(([k, v]) => [ + k, + castArray(v).map((v) => determineHookOptions(v)), + ]), + ) this.commandDiscoveryOpts = determineCommandDiscoveryOptions(this.pjson.oclif?.commands, this.pjson.oclif?.default) @@ -393,8 +409,8 @@ export class Plugin implements IPlugin { if (this.commandDiscoveryOpts?.strategy === 'explicit' && this.commandDiscoveryOpts.target) { const filePath = await tsPath(this.root, this.commandDiscoveryOpts.target, this) - const module = await load<{default?: CommandCache}>(this, filePath) - this.commandCache = module.default ?? {} + const module = await load>(this, filePath) + this.commandCache = module[this.commandDiscoveryOpts?.identifier ?? 'default'] ?? {} return this.commandCache } diff --git a/src/interfaces/pjson.ts b/src/interfaces/pjson.ts index bd705a0e8..7a65c0d7c 100644 --- a/src/interfaces/pjson.ts +++ b/src/interfaces/pjson.ts @@ -18,9 +18,9 @@ export type CommandDiscovery = { /** * The strategy to use for loading commands. * - * - `pattern` will use glob patterns to find command files in the specified `directory`. + * - `pattern` will use glob patterns to find command files in the specified `target`. * - `explicit` will use `import` (or `require` for CJS) to load the commands from the - * specified `file`. + * specified `target`. * - `single` will use the `target` which should export a command class. This is for CLIs that * only have a single command. * @@ -30,9 +30,9 @@ export type CommandDiscovery = { /** * If the `strategy` is `pattern`, this is the **directory** to use to find command files. * - * If the `strategy` is `explicit`, this is the **file** that default exports the commands. - * - This export must be the default export and an object with keys that are the command names - * and values that are the command classes. + * If the `strategy` is `explicit`, this is the **file** that exports the commands. + * - This export must be an object with keys that are the command names and values that are the command classes. + * - Unless `identifier` is specified, the default export will be used. * * @example * ```typescript @@ -52,6 +52,46 @@ export type CommandDiscovery = { * This is only used when `strategy` is `pattern`. */ globPatterns?: string[] + /** + * The name of the export to used when loading the command object from the `target` file. Only + * used when `strategy` is `explicit`. Defaults to `default`. + * + * @example + * ```typescript + * // in src/commands.ts + * import Hello from './commands/hello/index.js' + * import HelloWorld from './commands/hello/world.js' + * + * export const MY_COMMANDS = { + * hello: Hello, + * 'hello:world': HelloWorld, + * } + * ``` + * + * In the package.json: + * ```json + * { + * "oclif": { + * "commands": { + * "strategy": "explicit", + * "target": "./dist/index.js", + * "identifier": "MY_COMMANDS" + * } + * } + * ``` + */ + identifier?: string +} + +export type HookOptions = { + /** + * The file path containing hook. + */ + target: string + /** + * The name of the export to use when loading the hook function from the `target` file. Defaults to `default`. + */ + identifier: string } export namespace PJSON { @@ -83,7 +123,7 @@ export namespace PJSON { flexibleTaxonomy?: boolean helpClass?: string helpOptions?: HelpOptions - hooks?: {[name: string]: string | string[]} + hooks?: {[name: string]: string | string[] | HookOptions | HookOptions[]} jitPlugins?: Record macos?: { identifier?: string diff --git a/src/interfaces/plugin.ts b/src/interfaces/plugin.ts index 28945e1a6..cc4be67e1 100644 --- a/src/interfaces/plugin.ts +++ b/src/interfaces/plugin.ts @@ -1,5 +1,5 @@ import {Command} from '../command' -import {PJSON} from './pjson' +import {HookOptions, PJSON} from './pjson' import {Topic} from './topic' export interface PluginOptions { @@ -42,7 +42,7 @@ export interface Plugin { findCommand(id: string, opts: {must: true}): Promise findCommand(id: string, opts?: {must: boolean}): Promise | undefined readonly hasManifest: boolean - hooks: {[k: string]: string[]} + hooks: {[key: string]: HookOptions[]} /** * True if the plugin is the root plugin. */ diff --git a/src/util/fs.ts b/src/util/fs.ts index 50e04a99f..2db8f3f32 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -1,10 +1,5 @@ import {Stats, existsSync as fsExistsSync, readFileSync} from 'node:fs' import {readFile, stat} from 'node:fs/promises' -import {join} from 'node:path' - -export function requireJson(...pathParts: string[]): T { - return JSON.parse(readFileSync(join(...pathParts), 'utf8')) -} /** * Parser for Args.directory and Flags.directory. Checks that the provided path diff --git a/test/command/explicit-command-strategy.test.ts b/test/command/explicit-command-strategy.test.ts index c8085f314..8de55ac1c 100644 --- a/test/command/explicit-command-strategy.test.ts +++ b/test/command/explicit-command-strategy.test.ts @@ -19,8 +19,10 @@ describe('explicit command discovery strategy', () => { }) it('should show help for commands', async () => { - await run(['--help', 'foo'], resolve(__dirname, 'fixtures/explicit-commands/package.json')) - expect(stdoutStub.args.map((a) => stripAnsi(a[0])).join('')).to.equal(`foo topic description + await run(['--help', 'foo'], resolve(__dirname, 'fixtures/bundled-cli/package.json')) + const [first, ...rest] = stdoutStub.args.map((a) => stripAnsi(a[0])) + expect(first).to.equal('example hook running --help\n') + expect(rest.join('')).to.equal(`foo topic description USAGE $ oclif foo COMMAND @@ -34,12 +36,16 @@ COMMANDS }) it('should run command', async () => { - await run(['foo:bar'], resolve(__dirname, 'fixtures/explicit-commands/package.json')) - expect(stdoutStub.firstCall.firstArg).to.equal('hello world!\n') + await run(['foo:bar'], resolve(__dirname, 'fixtures/bundled-cli/package.json')) + const [first, second] = stdoutStub.args.map((a) => stripAnsi(a[0])) + expect(first).to.equal('example hook running foo:bar\n') + expect(second).to.equal('hello world!\n') }) it('should run alias', async () => { - await run(['foo:alias'], resolve(__dirname, 'fixtures/explicit-commands/package.json')) - expect(stdoutStub.firstCall.firstArg).to.equal('hello world!\n') + await run(['foo:alias'], resolve(__dirname, 'fixtures/bundled-cli/package.json')) + const [first, second] = stdoutStub.args.map((a) => stripAnsi(a[0])) + expect(first).to.equal('example hook running foo:alias\n') + expect(second).to.equal('hello world!\n') }) }) diff --git a/test/command/fixtures/explicit-commands/package.json b/test/command/fixtures/bundled-cli/package.json similarity index 74% rename from test/command/fixtures/explicit-commands/package.json rename to test/command/fixtures/bundled-cli/package.json index c96e8e813..2855a978e 100644 --- a/test/command/fixtures/explicit-commands/package.json +++ b/test/command/fixtures/bundled-cli/package.json @@ -7,7 +7,14 @@ "oclif": { "commands": { "strategy": "explicit", - "target": "./lib/commands.js" + "target": "./lib/index.js", + "identifier": "commands" + }, + "hooks": { + "init": { + "target": "./lib/index.js", + "identifier": "initHook" + } }, "topicSeparator": " ", "plugins": [ diff --git a/test/command/fixtures/explicit-commands/src/commands/foo/bar.ts b/test/command/fixtures/bundled-cli/src/commands/foo/bar.ts similarity index 100% rename from test/command/fixtures/explicit-commands/src/commands/foo/bar.ts rename to test/command/fixtures/bundled-cli/src/commands/foo/bar.ts diff --git a/test/command/fixtures/explicit-commands/src/commands/foo/baz.ts b/test/command/fixtures/bundled-cli/src/commands/foo/baz.ts similarity index 100% rename from test/command/fixtures/explicit-commands/src/commands/foo/baz.ts rename to test/command/fixtures/bundled-cli/src/commands/foo/baz.ts diff --git a/test/command/fixtures/bundled-cli/src/hooks/init.ts b/test/command/fixtures/bundled-cli/src/hooks/init.ts new file mode 100644 index 000000000..16eede0e8 --- /dev/null +++ b/test/command/fixtures/bundled-cli/src/hooks/init.ts @@ -0,0 +1,7 @@ +import {Hook, ux} from '../../../../../../src/index' + +const hook: Hook<'init'> = async function (opts) { + ux.log(`example hook running ${opts.id}`) +} + +export default hook diff --git a/test/command/fixtures/explicit-commands/src/commands.ts b/test/command/fixtures/bundled-cli/src/index.ts similarity index 66% rename from test/command/fixtures/explicit-commands/src/commands.ts rename to test/command/fixtures/bundled-cli/src/index.ts index c80593378..cc7666242 100644 --- a/test/command/fixtures/explicit-commands/src/commands.ts +++ b/test/command/fixtures/bundled-cli/src/index.ts @@ -1,7 +1,8 @@ import FooBar from './commands/foo/bar' import FooBaz from './commands/foo/baz' +export {default as initHook} from './hooks/init' -export default { +export const commands = { 'foo:bar': FooBar, 'foo:baz': FooBaz, 'foo:alias': FooBar, diff --git a/test/command/fixtures/explicit-commands/tsconfig.json b/test/command/fixtures/bundled-cli/tsconfig.json similarity index 100% rename from test/command/fixtures/explicit-commands/tsconfig.json rename to test/command/fixtures/bundled-cli/tsconfig.json diff --git a/test/command/main.test.ts b/test/command/main.test.ts index 58c0de12e..1c6d6c19a 100644 --- a/test/command/main.test.ts +++ b/test/command/main.test.ts @@ -1,13 +1,13 @@ import {expect} from 'chai' -import {resolve} from 'node:path' +import {readFileSync} from 'node:fs' +import {join, resolve} from 'node:path' import {SinonSandbox, SinonStub, createSandbox} from 'sinon' import stripAnsi from 'strip-ansi' -import {Interfaces, ux} from '../../src/index' +import {ux} from '../../src/index' import {run} from '../../src/main' -import {requireJson} from '../../src/util/fs' -const pjson = requireJson(__dirname, '..', '..', 'package.json') +const pjson = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')) const version = `@oclif/core/${pjson.version} ${process.platform}-${process.arch} node-${process.version}` describe('main', () => {