From 994bd0aeb9ba3a7ff950bd791de170f45e41401c Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 13 Feb 2024 14:18:54 -0700 Subject: [PATCH] feat: add new strategy for single command CLIs --- src/config/config.ts | 7 ++ src/config/plugin.ts | 101 ++++++++++++------ src/help/docopts.ts | 2 +- src/help/index.ts | 12 ++- src/interfaces/config.ts | 1 + src/interfaces/pjson.ts | 10 +- src/main.ts | 20 ++-- .../fixtures/single-cmd-cli/package.json | 6 +- test/command/single-command-cli.test.ts | 34 +++++- 9 files changed, 144 insertions(+), 49 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index fa005f84d..a6e2a73c8 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -85,6 +85,7 @@ export class Config implements IConfig { public errlog!: string public flexibleTaxonomy!: boolean public home!: string + public isSingleCommandCLI = false public name!: string public npmRegistry?: string public nsisCustomization?: string @@ -362,6 +363,12 @@ export class Config implements IConfig { ...(s3.templates && s3.templates.vanilla), }, } + this.isSingleCommandCLI = Boolean( + this.pjson.oclif.default || + (typeof this.pjson.oclif.commands !== 'string' && + this.pjson.oclif.commands?.strategy === 'single' && + this.pjson.oclif.commands?.target), + ) await this.loadPluginsAndCommands() diff --git a/src/config/plugin.ts b/src/config/plugin.ts index 8b352a569..34d968942 100644 --- a/src/config/plugin.ts +++ b/src/config/plugin.ts @@ -10,6 +10,7 @@ import {Plugin as IPlugin, PluginOptions} from '../interfaces/plugin' import {Topic} from '../interfaces/topic' import {load, loadWithData, loadWithDataFromManifest} from '../module-loader' 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' @@ -35,7 +36,7 @@ function topicsToArray(input: any, base?: string): Topic[] { const cachedCommandCanBeUsed = (manifest: Manifest | undefined, id: string): boolean => Boolean(manifest?.commands[id] && 'isESM' in manifest.commands[id] && 'relativePath' in manifest.commands[id]) -const search = (cmd: any) => { +const searchForCommandClass = (cmd: any) => { if (typeof cmd.run === 'function') return cmd if (cmd.default && cmd.default.run) return cmd.default return Object.values(cmd).find((cmd: any) => typeof cmd.run === 'function') @@ -52,15 +53,20 @@ function processCommandIds(files: string[]): string[] { const topics = p.dir.split('/') const command = p.name !== 'index' && p.name const id = [...topics, command].filter(Boolean).join(':') - return id === '' ? '.' : id + return id === '' ? SINGLE_COMMAND_CLI_SYMBOL : id }) } function determineCommandDiscoveryOptions( commandDiscovery: string | CommandDiscovery | undefined, + defaultCmdId?: string | undefined, ): CommandDiscovery | undefined { if (!commandDiscovery) return + if (typeof commandDiscovery === 'string' && defaultCmdId) { + return {globPatterns: GLOB_PATTERNS, strategy: 'single', target: commandDiscovery} + } + if (typeof commandDiscovery === 'string') { return {globPatterns: GLOB_PATTERNS, strategy: 'pattern', target: commandDiscovery} } @@ -71,9 +77,13 @@ function determineCommandDiscoveryOptions( return commandDiscovery } -type CommandExportModule = { - default: Record -} +/** + * Cached commands, where the key is the command ID and the value is the command class. + * + * This is only populated if the `strategy` is `explicit` and the `target` is a file that default exports the id-to-command-class object. + * Or if the strategy is `single` and the `target` is the file containing a command class. + */ +type CommandCache = Record export class Plugin implements IPlugin { alias!: string @@ -122,8 +132,8 @@ export class Plugin implements IPlugin { // eslint-disable-next-line new-cap protected _debug = Debug() + private commandCache: CommandCache | undefined private commandDiscoveryOpts: CommandDiscovery | undefined - private commandExportModule: CommandExportModule | undefined private flexibleTaxonomy!: boolean constructor(public options: PluginOptions) {} @@ -143,9 +153,9 @@ export class Plugin implements IPlugin { }) const fetch = async () => { - const commandsFromExport = await this.loadCommandsFromExport() - if (commandsFromExport) { - const cmd = commandsFromExport[id] + const commandCache = await this.loadCommandsFromTarget() + if (commandCache) { + const cmd = commandCache[id] if (!cmd) return cmd.id = id @@ -168,7 +178,7 @@ export class Plugin implements IPlugin { throw error } - const cmd = search(module) + const cmd = searchForCommandClass(module) if (!cmd) return cmd.id = id cmd.plugin = this @@ -214,7 +224,7 @@ export class Plugin implements IPlugin { this.hooks = Object.fromEntries(Object.entries(this.pjson.oclif.hooks ?? {}).map(([k, v]) => [k, castArray(v)])) - this.commandDiscoveryOpts = determineCommandDiscoveryOptions(this.pjson.oclif?.commands) + this.commandDiscoveryOpts = determineCommandDiscoveryOptions(this.pjson.oclif?.commands, this.pjson.oclif?.default) this._debug('command discovery options', this.commandDiscoveryOpts) @@ -327,31 +337,51 @@ export class Plugin implements IPlugin { private async getCommandIDs(): Promise { const marker = Performance.mark(OCLIF_MARKER_OWNER, `plugin.getCommandIDs#${this.name}`, {plugin: this.name}) - const commandsFromExport = await this.loadCommandsFromExport() - if (commandsFromExport) { - const ids = Object.keys(commandsFromExport) - this._debug('found commands', ids) - marker?.addDetails({count: ids.length}) - marker?.stop() - return ids - } - const commandsDir = await this.getCommandsDir() - if (!commandsDir) { - marker?.addDetails({count: 0}) - marker?.stop() - return [] + let ids: string[] + switch (this.commandDiscoveryOpts?.strategy) { + case 'explicit': { + ids = (await this.getCommandIdsFromTarget()) ?? [] + break + } + + case 'pattern': { + ids = await this.getCommandIdsFromPattern() + break + } + + case 'single': { + ids = (await this.getCommandIdsFromTarget()) ?? [] + break + } + + default: { + ids = [] + } } - this._debug(`loading IDs from ${commandsDir}`) - const files = await globby(this.commandDiscoveryOpts?.globPatterns ?? GLOB_PATTERNS, {cwd: commandsDir}) - const ids = processCommandIds(files) this._debug('found commands', ids) marker?.addDetails({count: ids.length}) marker?.stop() return ids } + private async getCommandIdsFromPattern(): Promise { + const commandsDir = await this.getCommandsDir() + if (!commandsDir) return [] + + this._debug(`loading IDs from ${commandsDir}`) + const files = await globby(this.commandDiscoveryOpts?.globPatterns ?? GLOB_PATTERNS, {cwd: commandsDir}) + return processCommandIds(files) + } + + private async getCommandIdsFromTarget(): Promise { + const commandsFromExport = await this.loadCommandsFromTarget() + if (commandsFromExport) { + return Object.keys(commandsFromExport) + } + } + private async getCommandsDir(): Promise { if (this.commandsDir) return this.commandsDir @@ -359,12 +389,21 @@ export class Plugin implements IPlugin { return this.commandsDir } - private async loadCommandsFromExport(): Promise | undefined> { + private async loadCommandsFromTarget(): Promise | undefined> { + if (this.commandCache) return this.commandCache + if (this.commandDiscoveryOpts?.strategy === 'explicit' && this.commandDiscoveryOpts.target) { - if (this.commandExportModule) return this.commandExportModule.default const filePath = await tsPath(this.root, this.commandDiscoveryOpts.target, this) - this.commandExportModule = await load(this, filePath) - return this.commandExportModule?.default + const module = await load<{default?: CommandCache}>(this, filePath) + this.commandCache = module.default ?? {} + return this.commandCache + } + + if (this.commandDiscoveryOpts?.strategy === 'single' && this.commandDiscoveryOpts.target) { + const filePath = await tsPath(this.root, this.commandDiscoveryOpts?.target ?? this.root, this) + const module = await load(this, filePath) + this.commandCache = {[SINGLE_COMMAND_CLI_SYMBOL]: searchForCommandClass(module)} + return this.commandCache } } diff --git a/src/help/docopts.ts b/src/help/docopts.ts index 19c1c0868..5ae005cac 100644 --- a/src/help/docopts.ts +++ b/src/help/docopts.ts @@ -77,7 +77,7 @@ export class DocOpts { } public toString(): string { - const opts = this.cmd.id === '.' || this.cmd.id === '' ? [] : ['<%= command.id %>'] + const opts = ['<%= command.id %>'] if (this.cmd.args) { const a = Object.values(ensureArgObject(this.cmd.args)).map((arg) => diff --git a/src/help/index.ts b/src/help/index.ts index e4c66167d..5be53f919 100644 --- a/src/help/index.ts +++ b/src/help/index.ts @@ -7,6 +7,7 @@ import {Command} from '../command' import {error} from '../errors' import * as Interfaces from '../interfaces' import {load} from '../module-loader' +import {SINGLE_COMMAND_CLI_SYMBOL} from '../symbols' import {cacheDefaultValue} from '../util/cache-default-value' import {toConfiguredId} from '../util/ids' import {compact, sortBy, uniqBy} from '../util/util' @@ -234,8 +235,8 @@ export class Help extends HelpBase { if (this.config.topicSeparator !== ':') argv = standardizeIDFromArgv(argv, this.config) const subject = getHelpSubject(argv, this.config) if (!subject) { - if (this.config.pjson.oclif.default) { - const rootCmd = this.config.findCommand(this.config.pjson.oclif.default) + if (this.config.isSingleCommandCLI) { + const rootCmd = this.config.findCommand(SINGLE_COMMAND_CLI_SYMBOL) if (rootCmd) { await this.showCommandHelp(rootCmd) return @@ -248,6 +249,13 @@ export class Help extends HelpBase { const command = this.config.findCommand(subject) if (command) { + if (command.id === SINGLE_COMMAND_CLI_SYMBOL) { + // If the command is the root command of a single command CLI, + // then set the command id to an empty string to prevent the + // the SINGLE_COMMAND_CLI_SYMBOL from being displayed in the help output. + command.id = '' + } + if (command.hasDynamicHelp && command.pluginType !== 'jit') { const loaded = await command.load() for (const [name, flag] of Object.entries(loaded.flags ?? {})) { diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index a5dcc58d1..3a0df636d 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -91,6 +91,7 @@ export interface Config { * example: /home/myuser */ readonly home: string + readonly isSingleCommandCLI: boolean readonly name: string /** * npm registry to use for installing plugins diff --git a/src/interfaces/pjson.ts b/src/interfaces/pjson.ts index a3bee7c20..bd705a0e8 100644 --- a/src/interfaces/pjson.ts +++ b/src/interfaces/pjson.ts @@ -21,10 +21,12 @@ export type CommandDiscovery = { * - `pattern` will use glob patterns to find command files in the specified `directory`. * - `explicit` will use `import` (or `require` for CJS) to load the commands from the * specified `file`. + * - `single` will use the `target` which should export a command class. This is for CLIs that + * only have a single command. * * In both cases, the `oclif.manifest.json` file will be used to find the commands if it exists. */ - strategy: 'pattern' | 'explicit' + strategy: 'pattern' | 'explicit' | 'single' /** * If the `strategy` is `pattern`, this is the **directory** to use to find command files. * @@ -60,6 +62,12 @@ export namespace PJSON { additionalVersionFlags?: string[] aliases?: {[name: string]: null | string} commands?: string | CommandDiscovery + /** + * Default command id when no command is found. This is used to support single command CLIs. + * Only supported value is "." + * + * @deprecated Use `commands.strategy: 'single'` instead. + */ default?: string description?: string devPlugins?: string[] diff --git a/src/main.ts b/src/main.ts index e4c6ce519..659e26186 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,11 +5,12 @@ import {Config} from './config' import {getHelpFlagAdditions, loadHelpClass, normalizeArgv} from './help' import * as Interfaces from './interfaces' import {OCLIF_MARKER_OWNER, Performance} from './performance' +import {SINGLE_COMMAND_CLI_SYMBOL} from './symbols' const debug = require('debug')('oclif:main') export const helpAddition = (argv: string[], config: Interfaces.Config): boolean => { - if (argv.length === 0 && !config.pjson.oclif.default) return true + if (argv.length === 0 && !config.isSingleCommandCLI) return true const mergedHelpFlags = getHelpFlagAdditions(config) for (const arg of argv) { if (mergedHelpFlags.includes(arg)) return true @@ -50,7 +51,13 @@ export async function run(argv?: string[], options?: Interfaces.LoadOptions): Pr const config = await Config.load(options ?? require.main?.filename ?? __dirname) - let [id, ...argvSlice] = normalizeArgv(config, argv) + // If this is a single command CLI, then insert the '.' command into the argv array to serve as the command id. + if (config.isSingleCommandCLI) { + argv = [SINGLE_COMMAND_CLI_SYMBOL, ...argv] + } + + const [id, ...argvSlice] = normalizeArgv(config, argv) + // run init hook await config.runHook('init', {argv: argvSlice, id}) @@ -75,19 +82,10 @@ export async function run(argv?: string[], options?: Interfaces.LoadOptions): Pr if (!cmd) { const topic = config.flexibleTaxonomy ? null : config.findTopic(id) if (topic) return config.runCommand('help', [id]) - if (config.pjson.oclif.default) { - id = config.pjson.oclif.default - argvSlice = argv - } } initMarker?.stop() - // If the the default command is '.' (signifying that the CLI is a single command CLI) and '.' is provided - // as an argument, we need to add back the '.' to argv since it was stripped out earlier as part of the - // command id. - if (config.pjson.oclif.default === '.' && id === '.' && argv[0] === '.') argvSlice = ['.', ...argvSlice] - try { return await config.runCommand(id, argvSlice, cmd) } finally { diff --git a/test/command/fixtures/single-cmd-cli/package.json b/test/command/fixtures/single-cmd-cli/package.json index aab6f1da7..02b9e13c2 100644 --- a/test/command/fixtures/single-cmd-cli/package.json +++ b/test/command/fixtures/single-cmd-cli/package.json @@ -4,7 +4,9 @@ "description": "Single Command CLI", "private": true, "oclif": { - "default": ".", - "commands": "./dist" + "commands": { + "strategy": "single", + "target": "./dist" + } } } diff --git a/test/command/single-command-cli.test.ts b/test/command/single-command-cli.test.ts index 564b67190..59eaf3286 100644 --- a/test/command/single-command-cli.test.ts +++ b/test/command/single-command-cli.test.ts @@ -23,7 +23,7 @@ describe('single command cli', () => { expect(stdoutStub.args.map((a) => stripAnsi(a[0])).join('')).to.equal(`Description of single command CLI. USAGE - $ single-cmd-cli . + $ single-cmd-cli DESCRIPTION Description of single command CLI. @@ -36,3 +36,35 @@ DESCRIPTION expect(stdoutStub.firstCall.firstArg).to.equal('hello world!\n') }) }) + +describe('single command cli (deprecated)', () => { + let sandbox: SinonSandbox + let stdoutStub: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + stdoutStub = sandbox.stub(ux.write, 'stdout') + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should show help for commands', async () => { + await run(['--help'], resolve(__dirname, 'fixtures/single-cmd-cli-deprecated/package.json')) + expect(stdoutStub.args.map((a) => stripAnsi(a[0])).join('')).to.equal(`Description of single command CLI. + +USAGE + $ single-cmd-cli + +DESCRIPTION + Description of single command CLI. + +`) + }) + + it('should run command', async () => { + await run([], resolve(__dirname, 'fixtures/single-cmd-cli-deprecated/package.json')) + expect(stdoutStub.firstCall.firstArg).to.equal('hello world!\n') + }) +})