Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,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",
Expand Down
7 changes: 7 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
19 changes: 13 additions & 6 deletions src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
})
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string[] | undefined> {
Expand Down Expand Up @@ -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
}
}
Expand Down
12 changes: 6 additions & 6 deletions src/help/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 = ''
}

Expand Down
161 changes: 136 additions & 25 deletions src/help/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string>,
): 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<string>,
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<string>,
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[] {
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface Config {
getAllCommandIDs(): string[]
getAllCommands(): Command.Loadable[]
getPluginsList(): Plugin[]
readonly hasRootCommand: boolean
/**
* path to home directory
*
Expand Down
11 changes: 9 additions & 2 deletions src/interfaces/pjson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -247,14 +254,14 @@ export type OclifConfiguration = {
}
/**
* Tar flags configuration for different platforms.
*
*
* {
* "tarFlags": {
* "win32": "--force-local",
* "darwin": "--no-xattrs"
* }
* }
*
*
*/
tarFlags?: {
[platform: string]: string
Expand Down
10 changes: 5 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,9 +54,9 @@ 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)
Expand Down
2 changes: 1 addition & 1 deletion src/symbols.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const SINGLE_COMMAND_CLI_SYMBOL = Symbol('SINGLE_COMMAND_CLI').toString()
export const ROOT_COMMAND_SYMBOL = Symbol('ROOT_COMMAND_ID').toString()
Loading
Loading