From a1c5b01fa50f913b792f1711f99d34703fa7b31a Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 31 Jul 2025 14:46:40 -0600 Subject: [PATCH 1/5] feat: support root command clis --- src/config/config.ts | 7 ++ src/config/plugin.ts | 19 +++-- src/help/index.ts | 12 +-- src/help/util.ts | 161 +++++++++++++++++++++++++++++++++------ src/interfaces/config.ts | 1 + src/interfaces/pjson.ts | 11 ++- src/main.ts | 13 ++-- src/symbols.ts | 2 +- test/help/util.test.ts | 90 ++++++++++++++++++++++ 9 files changed, 271 insertions(+), 45 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 17ef33321..1e20e61ed 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -92,6 +92,7 @@ export class Config implements IConfig { public dataDir!: string public dirname!: string public flexibleTaxonomy!: boolean + public hasRootCommand = false public home!: string public isSingleCommandCLI = false public name!: string @@ -366,6 +367,12 @@ export class Config implements IConfig { this.pjson.oclif.commands?.target, ) + this.hasRootCommand = Boolean( + typeof this.pjson.oclif.commands !== 'string' && + this.pjson.oclif.commands?.strategy === 'pattern' && + this.pjson.oclif.commands?.includeRoot, + ) + this.maybeAdjustDebugSetting() await this.loadPluginsAndCommands() diff --git a/src/config/plugin.ts b/src/config/plugin.ts index 351995197..f6b62cc8e 100644 --- a/src/config/plugin.ts +++ b/src/config/plugin.ts @@ -11,7 +11,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 {ROOT_COMMAND_SYMBOL} from '../symbols' import {cacheCommand} from '../util/cache-command' import {findRoot} from '../util/find-root' import {readJson} from '../util/fs' @@ -53,13 +53,14 @@ const GLOB_PATTERNS = [ '!**/*.+(d.ts|test.ts|test.js|spec.ts|spec.js|d.mts|d.cts)?(x)', ] -function processCommandIds(files: string[]): string[] { +function processCommandIds(files: string[], includeRoot = false): string[] { return files.map((file) => { const p = parse(file) const topics = p.dir.split('/') const command = p.name !== 'index' && p.name + if (includeRoot && !command && p.dir.length === 0 && p.name === 'index') return ROOT_COMMAND_SYMBOL const id = [...topics, command].filter(Boolean).join(':') - return id === '' ? SINGLE_COMMAND_CLI_SYMBOL : id + return id === '' ? ROOT_COMMAND_SYMBOL : id }) } @@ -149,7 +150,13 @@ export class Plugin implements IPlugin { try { ;({filePath, isESM, module} = cachedCommandCanBeUsed(this.manifest, id) ? await loadWithDataFromManifest(this.manifest.commands[id], this.root) - : await loadWithData(this, join(commandsDir ?? this.pjson.oclif.commands, ...id.split(':')))) + : await loadWithData( + this, + join( + commandsDir ?? this.pjson.oclif.commands, + ...(id === ROOT_COMMAND_SYMBOL ? ['index'] : id.split(':')), + ), + )) this._debug(isESM ? '(import)' : '(require)', filePath) } catch (error: any) { if (!opts.must && error.code === 'MODULE_NOT_FOUND') return @@ -369,7 +376,7 @@ export class Plugin implements IPlugin { this._debug(`loading IDs from ${commandsDir}`) const files = await glob(this.commandDiscoveryOpts?.globPatterns ?? GLOB_PATTERNS, {cwd: commandsDir}) - return processCommandIds(files) + return processCommandIds(files, this.commandDiscoveryOpts?.includeRoot) } private async getCommandIdsFromTarget(): Promise { @@ -401,7 +408,7 @@ export class Plugin implements IPlugin { 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)} + this.commandCache = {[ROOT_COMMAND_SYMBOL]: searchForCommandClass(module)} return this.commandCache } } diff --git a/src/help/index.ts b/src/help/index.ts index 4669ec099..197ca7eb6 100644 --- a/src/help/index.ts +++ b/src/help/index.ts @@ -6,7 +6,7 @@ import {error} from '../errors/error' import * as Interfaces from '../interfaces' import {HelpLocationOptions} from '../interfaces/pjson' import {load} from '../module-loader' -import {SINGLE_COMMAND_CLI_SYMBOL} from '../symbols' +import {ROOT_COMMAND_SYMBOL} from '../symbols' import {cacheDefaultValue} from '../util/cache-default-value' import {toConfiguredId} from '../util/ids' import {compact, sortBy, uniqBy} from '../util/util' @@ -251,11 +251,11 @@ 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.isSingleCommandCLI) { - const rootCmd = this.config.findCommand(SINGLE_COMMAND_CLI_SYMBOL) + if (this.config.isSingleCommandCLI || this.config.hasRootCommand) { + const rootCmd = this.config.findCommand(ROOT_COMMAND_SYMBOL) if (rootCmd) { // set the command id to an empty string to prevent the - // SINGLE_COMMAND_CLI_SYMBOL from being displayed in the help output + // ROOT_COMMAND_SYMBOL from being displayed in the help output rootCmd.id = '' await this.showCommandHelp(rootCmd) return @@ -268,10 +268,10 @@ export class Help extends HelpBase { const command = this.config.findCommand(subject) if (command) { - if (command.id === SINGLE_COMMAND_CLI_SYMBOL) { + if (command.id === ROOT_COMMAND_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. + // the ROOT_COMMAND_SYMBOL from being displayed in the help output. command.id = '' } diff --git a/src/help/util.ts b/src/help/util.ts index aba12102d..f37d3c617 100644 --- a/src/help/util.ts +++ b/src/help/util.ts @@ -2,6 +2,7 @@ import * as ejs from 'ejs' import {collectUsableIds} from '../config/util' import {Deprecation, Config as IConfig} from '../interfaces' +import {ROOT_COMMAND_SYMBOL} from '../symbols' import {toStandardizedId} from '../util/ids' export function template(context: any): (t: string) => string { @@ -15,42 +16,152 @@ export function template(context: any): (t: string) => string { const isFlag = (s: string) => s.startsWith('-') const isArgWithValue = (s: string) => s.includes('=') -function collateSpacedCmdIDFromArgs(argv: string[], config: IConfig): string[] { - if (argv.length === 1) return argv - - const findId = (argv: string[]): string | undefined => { - const ids = collectUsableIds(config.commandIDs) +/** + * Creates a function to check if a command has arguments defined. + * + * @param config - CLI configuration object + * @returns Function that checks if a command ID has arguments + */ +function createHasArgsFunction(config: IConfig): (id: string) => boolean { + return (id: string) => { + const cmd = config.findCommand(id) + return Boolean(cmd && (cmd.strict === false || Object.keys(cmd.args ?? {}).length > 0)) + } +} - const final: string[] = [] - const idPresent = (id: string) => ids.has(id) - const finalizeId = (s?: string) => (s ? [...final, s] : final).filter(Boolean).join(':') +/** + * Determines if command ID building should stop based on current argument and state. + * + * @param arg - Current argument being processed + * @param cmdParts - Current command parts built so far + * @param shouldStopForArgs - Function to check if building should stop for current command + * @returns True if building should stop, false otherwise + */ +function shouldStopBuilding( + arg: string, + cmdParts: string[], + shouldStopForArgs: (id: string) => boolean, + ids: Set, +): boolean { + // Stop if we hit flags or args with values + if (isFlag(arg) || isArgWithValue(arg)) return true + + // Can't stop if we haven't built a command yet + if (cmdParts.length === 0) return false + + const currentId = cmdParts.join(':') + // If the current ID isn't a command that can take args, we can't stop. + if (!shouldStopForArgs(currentId)) return false + + // At this point, we have a runnable command that takes args. + // We should stop, UNLESS the next part (`arg`) forms a more specific command. + const potentialNextId = [...cmdParts, arg].join(':') + const isNextPartOfLongerCommand = + ids.has(potentialNextId) || [...ids].some((id) => id.startsWith(potentialNextId + ':')) + + return !isNextPartOfLongerCommand +} - const hasArgs = () => { - const id = finalizeId() - if (!id) return false - const cmd = config.findCommand(id) - return Boolean(cmd && (cmd.strict === false || Object.keys(cmd.args ?? {}).length > 0)) +/** + * Core logic for building command IDs from arguments. + * Shared between root command and regular command processing. + * + * @param args - Array of arguments to process + * @param startingConsumedArgs - Number of arguments already consumed (e.g., ROOT_COMMAND_SYMBOL) + * @param ids - Set of available command IDs + * @param shouldStopForArgs - Function to check if building should stop for current command + * @returns Object with command ID and total consumed arguments, or undefined if no valid command found + */ +function buildCommandIdFromArgs( + args: string[], + startingConsumedArgs: number, + ids: Set, + shouldStopForArgs: (id: string) => boolean, +): {consumedArgs: number; id: string} | undefined { + const cmdParts: string[] = [] + let consumedArgs = startingConsumedArgs + + for (const arg of args) { + // Skip empty strings but count them + if (arg === '') { + consumedArgs++ + continue } - for (const arg of argv) { - if (idPresent(finalizeId(arg))) final.push(arg) - // If the parent topic has a command that expects positional arguments, then we cannot - // assume that any subsequent string could be part of the command name - else if (isArgWithValue(arg) || isFlag(arg) || hasArgs()) break - else final.push(arg) + if (shouldStopBuilding(arg, cmdParts, shouldStopForArgs, ids)) break + + const potentialId = [...cmdParts, arg].join(':') + + // Stop if this doesn't form a valid command and we haven't started building yet + if (cmdParts.length === 0 && !ids.has(potentialId) && ![...ids].some((id) => id.startsWith(potentialId + ':'))) { + break } - return finalizeId() + // Continue building the command ID + cmdParts.push(arg) + consumedArgs++ } - const id = findId(argv) + const id = cmdParts.join(':') + return id ? {consumedArgs, id} : undefined +} + +/** + * Handles command ID processing for both root and regular command scenarios. + * + * @param argv - Complete argument array + * @param ids - Set of available command IDs + * @param isRootCommand - Whether this is a root command scenario + * @param shouldStopForArgs - Function to check if building should stop for current command + * @returns Array with command ID as first element, remaining args as rest + */ +function resolveArgv( + argv: string[], + ids: Set, + isRootCommand: boolean, + shouldStopForArgs: (id: string) => boolean, +): string[] { + const startingConsumedArgs = isRootCommand ? 1 : 0 + const argsToProcess = isRootCommand ? argv.slice(1) : argv + + const result = buildCommandIdFromArgs(argsToProcess, startingConsumedArgs, ids, shouldStopForArgs) + + if (result) { + const remainingArgs = argv.slice(result.consumedArgs) + // Filter empty strings for root commands only + const filteredRemainingArgs = isRootCommand ? remainingArgs.filter((arg) => arg !== '') : remainingArgs + return [result.id, ...filteredRemainingArgs] + } - if (id) { - const argvSlice = argv.slice(id.split(':').length) - return [id, ...argvSlice] + // No valid command ID found + if (isRootCommand) { + // Return ROOT_COMMAND_SYMBOL with remaining args (filtered) + return [ROOT_COMMAND_SYMBOL, ...argsToProcess.filter((arg) => arg !== '')] } - return argv // ID is argv[0] + // Return original argv for regular commands + return argv +} + +/** + * Collates spaced command IDs from command line arguments. + * + * Processes argv to construct valid command IDs by matching argument sequences + * against available commands in the configuration. Handles both root commands + * (starting with ROOT_COMMAND_SYMBOL) and regular commands. + * + * @param argv - Array of command line arguments to process + * @param config - CLI configuration object containing available command IDs + * @returns Array where first element is the command ID, remaining are unconsumed args + */ +function collateSpacedCmdIDFromArgs(argv: string[], config: IConfig): string[] { + if (argv.length === 1) return argv + + const ids = collectUsableIds(config.commandIDs) + const isRootCommand = argv[0] === ROOT_COMMAND_SYMBOL + + const shouldStopForArgs = createHasArgsFunction(config) + return resolveArgv(argv, ids, isRootCommand, shouldStopForArgs) } export function standardizeIDFromArgv(argv: string[], config: IConfig): string[] { diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index 444b2d5a6..58e518f07 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -73,6 +73,7 @@ export interface Config { getAllCommandIDs(): string[] getAllCommands(): Command.Loadable[] getPluginsList(): Plugin[] + readonly hasRootCommand: boolean /** * path to home directory * diff --git a/src/interfaces/pjson.ts b/src/interfaces/pjson.ts index 09ea59f31..5011a5798 100644 --- a/src/interfaces/pjson.ts +++ b/src/interfaces/pjson.ts @@ -70,6 +70,13 @@ export type CommandDiscovery = { * ``` */ identifier?: string + /** + * If true, the root index file in the `target` directory will be considered a command file. + * This can be used to allow a multi-command CLI to have a root command instead of just showing help. + * + * This is only used when `strategy` is `pattern`. + */ + includeRoot?: boolean } export type HookOptions = { @@ -247,14 +254,14 @@ export type OclifConfiguration = { } /** * Tar flags configuration for different platforms. - * + * * { * "tarFlags": { * "win32": "--force-local", * "darwin": "--no-xattrs" * } * } - * + * */ tarFlags?: { [platform: string]: string diff --git a/src/main.ts b/src/main.ts index 477c10706..75a210d12 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,11 +7,11 @@ import {getHelpFlagAdditions, loadHelpClass, normalizeArgv} from './help' import * as Interfaces from './interfaces' import {getLogger, setLogger} from './logger' import {OCLIF_MARKER_OWNER, Performance} from './performance' -import {SINGLE_COMMAND_CLI_SYMBOL} from './symbols' +import {ROOT_COMMAND_SYMBOL} from './symbols' import {ux} from './ux' export const helpAddition = (argv: string[], config: Interfaces.Config): boolean => { - if (argv.length === 0 && !config.isSingleCommandCLI) return true + if (argv.length === 0 && !config.isSingleCommandCLI && !config.hasRootCommand) return true const mergedHelpFlags = getHelpFlagAdditions(config) for (const arg of argv) { if (mergedHelpFlags.includes(arg)) return true @@ -54,13 +54,15 @@ export async function run(argv?: string[], options?: Interfaces.LoadOptions): Pr const config = await Config.load(options ?? require.main?.filename ?? __dirname) Cache.getInstance().set('config', config) - // If this is a single command CLI, then insert the SINGLE_COMMAND_CLI_SYMBOL into the argv array to serve as the command id. - if (config.isSingleCommandCLI) { - argv = [SINGLE_COMMAND_CLI_SYMBOL, ...argv] + // If this is a single command CLI or a multi-command CLI with a root command, then insert the ROOT_COMMAND_SYMBOL into the argv array to serve as the command id. + if (config.isSingleCommandCLI || config.hasRootCommand) { + argv = [ROOT_COMMAND_SYMBOL, ...argv] } const [id, ...argvSlice] = normalizeArgv(config, argv) + console.error(`Running command: ${id} with args: ${argvSlice.join(' ')}`) + const runFinally = async (cmd?: Command.Loadable, error?: Error) => { marker?.stop() if (!initMarker?.stopped) initMarker?.stop() @@ -88,6 +90,7 @@ export async function run(argv?: string[], options?: Interfaces.LoadOptions): Pr // find & run command const cmd = config.findCommand(id) + console.error(`Found command: ${cmd?.id}`) if (!cmd) { const topic = config.flexibleTaxonomy ? null : config.findTopic(id) if (topic) { diff --git a/src/symbols.ts b/src/symbols.ts index 74f11877e..1b5c131dd 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -1 +1 @@ -export const SINGLE_COMMAND_CLI_SYMBOL = Symbol('SINGLE_COMMAND_CLI').toString() +export const ROOT_COMMAND_SYMBOL = Symbol('ROOT_COMMAND_ID').toString() diff --git a/test/help/util.test.ts b/test/help/util.test.ts index ba84daccd..276c98060 100644 --- a/test/help/util.test.ts +++ b/test/help/util.test.ts @@ -5,6 +5,7 @@ import sinon from 'sinon' import {Args, Command, Config} from '../../src' import * as util from '../../src/config/util' import {loadHelpClass, standardizeIDFromArgv} from '../../src/help' +import {ROOT_COMMAND_SYMBOL} from '../../src/symbols' import configuredHelpClass from './_test-help-class' import {MyHelp} from './_test-help-class-identifier' @@ -197,5 +198,94 @@ describe('util', () => { const actual = standardizeIDFromArgv(['foo', 'bar', 'my-arg', 'hello=world', '--baz'], config) expect(actual).to.deep.equal(['foo:bar', 'my-arg', 'hello=world', '--baz']) }) + + describe('root command scenarios', () => { + it('should return root command with args when ROOT_COMMAND_SYMBOL is present and no valid subcommand', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'codey'], config) + expect(actual).to.deep.equal([ROOT_COMMAND_SYMBOL, 'codey']) + }) + + it('should return subcommand when ROOT_COMMAND_SYMBOL is present and valid subcommand exists', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'bar'], config) + expect(actual).to.deep.equal(['foo:bar']) + }) + + it('should return subcommand with remaining args when ROOT_COMMAND_SYMBOL is present', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + stubCommands({ + id: 'foo:bar', + args: { + name: Args.string(), + }, + }) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'bar', 'baz'], config) + expect(actual).to.deep.equal(['foo:bar', 'baz']) + }) + + it('should return subcommand with flags when ROOT_COMMAND_SYMBOL is present', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'bar', '--flag'], config) + expect(actual).to.deep.equal(['foo:bar', '--flag']) + }) + + it('should handle misspelled subcommands when ROOT_COMMAND_SYMBOL is present', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'ba'], config) + expect(actual).to.deep.equal(['foo:ba']) + }) + + it('should filter empty strings when ROOT_COMMAND_SYMBOL is present', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', '', 'bar'], config) + expect(actual).to.deep.equal(['foo:bar']) + }) + + it('should return root command when ROOT_COMMAND_SYMBOL is present with only empty args', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, '', ''], config) + expect(actual).to.deep.equal([ROOT_COMMAND_SYMBOL]) + }) + + it('should handle partial command matches when ROOT_COMMAND_SYMBOL is present', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar', 'foo:bar:baz'])) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'bar', 'baz'], config) + expect(actual).to.deep.equal(['foo:bar:baz']) + }) + + it('should stop at args when command has strict args and ROOT_COMMAND_SYMBOL is present', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + stubCommands({ + id: 'foo:bar', + args: { + name: Args.string(), + }, + strict: true, + }) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'bar', 'baz', 'extra'], config) + expect(actual).to.deep.equal(['foo:bar', 'baz', 'extra']) + }) + + it('should stop building command when command has variable args and ROOT_COMMAND_SYMBOL is present', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar'])) + stubCommands({ + id: 'foo:bar', + strict: false, + }) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'bar', 'baz'], config) + expect(actual).to.deep.equal(['foo:bar', 'baz']) + }) + + it('should continue building command when command has variable args and a more specific command exists', () => { + sinon.stub(util, 'collectUsableIds').returns(new Set(['foo', 'foo:bar', 'foo:bar:baz'])) + stubCommands({ + id: 'foo:bar', + strict: false, + }) + const actual = standardizeIDFromArgv([ROOT_COMMAND_SYMBOL, 'foo', 'bar', 'baz'], config) + expect(actual).to.deep.equal(['foo:bar:baz']) + }) + }) }) }) From a52b6a6beff78687c8a13db9d6579b6be189f4f0 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 1 Aug 2025 10:39:13 -0600 Subject: [PATCH 2/5] test: add tests for hasRootCommand --- test/config/config.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test/config/config.test.ts b/test/config/config.test.ts index 33f7f7828..252d15f39 100644 --- a/test/config/config.test.ts +++ b/test/config/config.test.ts @@ -4,6 +4,7 @@ import sinon from 'sinon' import {Config, Interfaces} from '../../src' import {Command} from '../../src/command' +import PluginLoader from '../../src/config/plugin-loader' import {Plugin as IPlugin} from '../../src/interfaces' import * as fs from '../../src/util/fs' import * as os from '../../src/util/os' @@ -463,4 +464,94 @@ describe('Config', () => { expect(config).to.have.property('theme', undefined) }) }) + + describe('hasRootCommand', () => { + beforeEach(() => { + sinon.stub(Config.prototype, 'loadPluginsAndCommands').resolves() + }) + + afterEach(() => { + sinon.restore() + }) + + it('should be false when commands is a string', async () => { + const pjson = { + name: 'foo', + version: '1.0.0', + oclif: { + commands: './lib/commands', + }, + } + // @ts-expect-error mock + sinon.stub(PluginLoader.prototype, 'loadRoot').resolves({ + pjson, + }) + const config = await Config.load({root}) + expect(config.hasRootCommand).to.be.false + }) + + it('should be false when strategy is not pattern', async () => { + const pjson = { + name: 'foo', + version: '1.0.0', + oclif: { + commands: { + strategy: 'explicit', + target: './lib/commands', + includeRoot: true, + }, + }, + } + + sinon.stub(PluginLoader.prototype, 'loadRoot').resolves({ + // @ts-expect-error mock + pjson, + }) + const config = await Config.load({root}) + expect(config.hasRootCommand).to.be.false + }) + + it('should be false when includeRoot is false', async () => { + const pjson = { + name: 'foo', + version: '1.0.0', + oclif: { + commands: { + strategy: 'pattern', + target: './lib/commands', + includeRoot: false, + }, + }, + } + + sinon.stub(PluginLoader.prototype, 'loadRoot').resolves({ + // @ts-expect-error mock + pjson, + }) + + const config = await Config.load({root}) + expect(config.hasRootCommand).to.be.false + }) + + it('should be true when strategy is pattern and includeRoot is true', async () => { + const pjson = { + name: 'foo', + version: '1.0.0', + oclif: { + commands: { + strategy: 'pattern', + target: './lib/commands', + includeRoot: true, + }, + }, + } + + sinon.stub(PluginLoader.prototype, 'loadRoot').resolves({ + // @ts-expect-error mock + pjson, + }) + const config = await Config.load({root}) + expect(config.hasRootCommand).to.be.true + }) + }) }) From abfe6c8e638d1de9d5c56125f3b53c25f60f25c3 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 1 Aug 2025 10:53:35 -0600 Subject: [PATCH 3/5] chore: remove console logs --- src/main.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index 75a210d12..f387b68b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,8 +61,6 @@ export async function run(argv?: string[], options?: Interfaces.LoadOptions): Pr const [id, ...argvSlice] = normalizeArgv(config, argv) - console.error(`Running command: ${id} with args: ${argvSlice.join(' ')}`) - const runFinally = async (cmd?: Command.Loadable, error?: Error) => { marker?.stop() if (!initMarker?.stopped) initMarker?.stop() @@ -90,7 +88,6 @@ export async function run(argv?: string[], options?: Interfaces.LoadOptions): Pr // find & run command const cmd = config.findCommand(id) - console.error(`Found command: ${cmd?.id}`) if (!cmd) { const topic = config.flexibleTaxonomy ? null : config.findTopic(id) if (topic) { From 4ba18090e23a3cb59c11dd609303d36860803d21 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 1 Aug 2025 11:15:50 -0600 Subject: [PATCH 4/5] test: add fixture for root command cli --- package.json | 3 + .../fixtures/root-cmd-cli/package.json | 18 +++++ .../root-cmd-cli/src/commands/foo/bar.ts | 12 +++ .../root-cmd-cli/src/commands/hello.ts | 9 +++ .../root-cmd-cli/src/commands/index.ts | 18 +++++ .../fixtures/root-cmd-cli/tsconfig.json | 7 ++ test/command/root-command-cli.test.ts | 73 +++++++++++++++++++ yarn.lock | 37 ++-------- 8 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 test/command/fixtures/root-cmd-cli/package.json create mode 100644 test/command/fixtures/root-cmd-cli/src/commands/foo/bar.ts create mode 100644 test/command/fixtures/root-cmd-cli/src/commands/hello.ts create mode 100644 test/command/fixtures/root-cmd-cli/src/commands/index.ts create mode 100644 test/command/fixtures/root-cmd-cli/tsconfig.json create mode 100644 test/command/root-command-cli.test.ts diff --git a/package.json b/package.json index 649291b46..a678bed13 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" }, + "resolutions": { + "@oclif/core": "file:." + }, "devDependencies": { "@commitlint/config-conventional": "^19", "@eslint/compat": "^1.3.1", diff --git a/test/command/fixtures/root-cmd-cli/package.json b/test/command/fixtures/root-cmd-cli/package.json new file mode 100644 index 000000000..8e1bdfa08 --- /dev/null +++ b/test/command/fixtures/root-cmd-cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "root-cmd-cli", + "version": "0.0.0", + "description": "CLI with root command", + "private": true, + "files": [], + "oclif": { + "commands": { + "strategy": "pattern", + "target": "./src/commands", + "includeRoot": true + }, + "topicSeparator": " ", + "plugins": [ + "@oclif/plugin-help" + ] + } +} diff --git a/test/command/fixtures/root-cmd-cli/src/commands/foo/bar.ts b/test/command/fixtures/root-cmd-cli/src/commands/foo/bar.ts new file mode 100644 index 000000000..ce1a627e8 --- /dev/null +++ b/test/command/fixtures/root-cmd-cli/src/commands/foo/bar.ts @@ -0,0 +1,12 @@ +import {Command} from '../../../../../../../src/index' + +export default class FooBarCommand extends Command { + public static description = 'Nested foo bar command' + + public static strict = false + + public async run(): Promise { + const {argv} = await this.parse(FooBarCommand) + this.log(`hello from foo bar! ${argv.join(' ')}`.trim()) + } +} diff --git a/test/command/fixtures/root-cmd-cli/src/commands/hello.ts b/test/command/fixtures/root-cmd-cli/src/commands/hello.ts new file mode 100644 index 000000000..59f2e8ca5 --- /dev/null +++ b/test/command/fixtures/root-cmd-cli/src/commands/hello.ts @@ -0,0 +1,9 @@ +import {Command} from '../../../../../../src/index' + +export default class HelloCommand extends Command { + public static description = 'Hello subcommand' + + public async run(): Promise { + this.log('hello from subcommand!') + } +} diff --git a/test/command/fixtures/root-cmd-cli/src/commands/index.ts b/test/command/fixtures/root-cmd-cli/src/commands/index.ts new file mode 100644 index 000000000..84f1e6c87 --- /dev/null +++ b/test/command/fixtures/root-cmd-cli/src/commands/index.ts @@ -0,0 +1,18 @@ +import {Args, Command, Flags} from '../../../../../../src/index' + +export default class RootCommand extends Command { + public static description = 'This is the root command of a multi-command CLI' + + public static args = { + name: Args.string({description: 'name to print', required: false}), + } + + public static flags = { + version: Flags.version(), + } + + public async run(): Promise { + const {args} = await this.parse(RootCommand) + this.log(`hello from root command${args.name ? ` ${args.name}` : ''}!`) + } +} diff --git a/test/command/fixtures/root-cmd-cli/tsconfig.json b/test/command/fixtures/root-cmd-cli/tsconfig.json new file mode 100644 index 000000000..8d0083147 --- /dev/null +++ b/test/command/fixtures/root-cmd-cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "outDir": "./lib", + "rootDirs": ["./src"] + }, + "include": ["./src/**/*"] +} diff --git a/test/command/root-command-cli.test.ts b/test/command/root-command-cli.test.ts new file mode 100644 index 000000000..ad5834f8d --- /dev/null +++ b/test/command/root-command-cli.test.ts @@ -0,0 +1,73 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import {resolve} from 'node:path' + +const root = resolve(__dirname, 'fixtures/root-cmd-cli/package.json') + +describe('root command cli', () => { + it('should run root command when no args provided', async () => { + const {stdout} = await runCommand([], {root}) + expect(stdout).to.equal('hello from root command!\n') + }) + + it('should run root command with arguments', async () => { + const {stdout} = await runCommand(['world'], {root}) + expect(stdout).to.equal('hello from root command world!\n') + }) + + it('should show help for root command when --help flag is used', async () => { + const {stdout} = await runCommand(['--help'], {root}) + expect(stdout).to.equal(`This is the root command of a multi-command CLI + +USAGE + $ root-cmd-cli [NAME] [--version] + +ARGUMENTS + NAME name to print + +FLAGS + --version Show CLI version. + +DESCRIPTION + This is the root command of a multi-command CLI + +`) + }) + + it('should prioritize subcommands over root command', async () => { + const {stdout} = await runCommand(['hello'], {root}) + expect(stdout).to.equal('hello from subcommand!\n') + }) + + it('should run nested subcommands correctly', async () => { + const {stdout} = await runCommand(['foo', 'bar'], {root}) + expect(stdout).to.equal('hello from foo bar!\n') + }) + + it('should show help for subcommands', async () => { + const {stdout} = await runCommand(['hello', '--help'], {root}) + expect(stdout).to.include('Hello subcommand') + expect(stdout).to.include('USAGE') + expect(stdout).to.include('$ root-cmd-cli hello') + }) + + it('should pass arguments to root command when no subcommand matches', async () => { + const {stdout} = await runCommand(['nonexistent'], {root}) + expect(stdout).to.equal('hello from root command nonexistent!\n') + }) + + it('should handle flags with root command', async () => { + const {stdout} = await runCommand(['--version'], {root}) + expect(stdout).to.include('root-cmd-cli/0.0.0') + }) + + it('should handle root command with an argument', async () => { + const {stdout} = await runCommand(['arg1'], {root}, {print: true}) + expect(stdout).to.equal('hello from root command arg1!\n') + }) + + it('should handle nested subcommands with strict=false arguments', async () => { + const {stdout} = await runCommand(['foo', 'bar', 'arg1', 'arg2'], {root}, {print: true}) + expect(stdout).to.equal('hello from foo bar! arg1 arg2\n') + }) +}) diff --git a/yarn.lock b/yarn.lock index c5d5c105d..bb1ed1aa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -944,10 +944,8 @@ proc-log "^5.0.0" which "^5.0.0" -"@oclif/core@^4", "@oclif/core@^4.5.1": - version "4.5.1" - resolved "https://registry.yarnpkg.com/@oclif/core/-/core-4.5.1.tgz#7fa9041d13f624e4c00d89605d9f732cf8084748" - integrity sha512-JAuARvXOzf75L7rqLL3TIP3OmuTf7N/cjRejkGASfRJH+09180+EGbSkPWSMCns+AaYpDMI+fdaJ6QCoa3f15A== +"@oclif/core@^4", "@oclif/core@^4.5.1", "@oclif/core@file:.": + version "4.5.2" dependencies: ansi-escapes "^4.3.2" ansis "^3.17.0" @@ -7837,16 +7835,7 @@ string-argv@^0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7967,14 +7956,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8778,7 +8760,7 @@ workerpool@^9.2.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.3.tgz#e75281fe62e851afb21cdeef8fa85f6a62ec3583" integrity sha512-slxCaKbYjEdFT/o2rH9xS1hf4uRDch1w7Uo+apxhZ+sf/1d9e0ZVkn42kPNGP2dgjIx6YFvSevj0zHvbWe2jdw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8796,15 +8778,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From f171eff0ca47e27dab32bb0b432d2c6439cab0d3 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 1 Aug 2025 11:34:01 -0600 Subject: [PATCH 5/5] test: link local before tests --- package.json | 6 ++---- yarn.lock | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a678bed13..32c44f5e1 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,6 @@ "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" }, - "resolutions": { - "@oclif/core": "file:." - }, "devDependencies": { "@commitlint/config-conventional": "^19", "@eslint/compat": "^1.3.1", @@ -123,9 +120,10 @@ "compile": "tsc", "format": "prettier --write \"+(src|test)/**/*.+(ts|js|json)\"", "lint": "eslint", - "posttest": "yarn lint && yarn test:circular-deps", + "posttest": "(yarn unlink @oclif/core || true) && yarn lint && yarn test:circular-deps", "prepack": "yarn run build", "prepare": "husky", + "pretest": "yarn link && yarn link @oclif/core", "test:circular-deps": "yarn build && madge lib/ -c", "test:debug": "nyc mocha --debug-brk --inspect \"test/**/*.test.ts\"", "test:integration": "mocha --forbid-only \"test/**/*.integration.ts\" --parallel --timeout 1200000", diff --git a/yarn.lock b/yarn.lock index bb1ed1aa5..f9e52abf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -944,8 +944,10 @@ proc-log "^5.0.0" which "^5.0.0" -"@oclif/core@^4", "@oclif/core@^4.5.1", "@oclif/core@file:.": +"@oclif/core@^4", "@oclif/core@^4.5.1": version "4.5.2" + resolved "https://registry.yarnpkg.com/@oclif/core/-/core-4.5.2.tgz#4db8a365fa7e9e33af272294f710a7f3f25538e2" + integrity sha512-eQcKyrEcDYeZJKu4vUWiu0ii/1Gfev6GF4FsLSgNez5/+aQyAUCjg3ZWlurf491WiYZTXCWyKAxyPWk8DKv2MA== dependencies: ansi-escapes "^4.3.2" ansis "^3.17.0"