Skip to content

Commit

Permalink
feat: add new strategy for single command CLIs
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Feb 13, 2024
1 parent f825dc5 commit 994bd0a
Show file tree
Hide file tree
Showing 9 changed files with 144 additions and 49 deletions.
7 changes: 7 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
101 changes: 70 additions & 31 deletions src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
Expand All @@ -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}
}
Expand All @@ -71,9 +77,13 @@ function determineCommandDiscoveryOptions(
return commandDiscovery
}

type CommandExportModule = {
default: Record<string, Command.Class>
}
/**
* 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<string, Command.Class>

export class Plugin implements IPlugin {
alias!: string
Expand Down Expand Up @@ -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) {}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -327,44 +337,73 @@ export class Plugin implements IPlugin {

private async getCommandIDs(): Promise<string[]> {
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<string[]> {
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<string[] | undefined> {
const commandsFromExport = await this.loadCommandsFromTarget()
if (commandsFromExport) {
return Object.keys(commandsFromExport)
}
}

private async getCommandsDir(): Promise<string | undefined> {
if (this.commandsDir) return this.commandsDir

this.commandsDir = await tsPath(this.root, this.commandDiscoveryOpts?.target, this)
return this.commandsDir
}

private async loadCommandsFromExport(): Promise<Record<string, Command.Class> | undefined> {
private async loadCommandsFromTarget(): Promise<Record<string, Command.Class> | 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<CommandExportModule>(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
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/help/docopts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
12 changes: 10 additions & 2 deletions src/help/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 ?? {})) {
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/interfaces/pjson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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[]
Expand Down
20 changes: 9 additions & 11 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})

Expand All @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions test/command/fixtures/single-cmd-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"description": "Single Command CLI",
"private": true,
"oclif": {
"default": ".",
"commands": "./dist"
"commands": {
"strategy": "single",
"target": "./dist"
}
}
}
34 changes: 33 additions & 1 deletion test/command/single-command-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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')
})
})

0 comments on commit 994bd0a

Please sign in to comment.