Skip to content

Commit

Permalink
feat: bundle friendly
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Feb 15, 2024
1 parent 3f0da0f commit d0aa430
Show file tree
Hide file tree
Showing 16 changed files with 144 additions and 44 deletions.
28 changes: 28 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -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> = 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<keyof CacheContents, ValueOf<CacheContents>> {
static instance: Cache
public constructor() {
super()
this.set('@oclif/core', this.getOclifCoreMeta())
}

static getInstance(): Cache {
if (!Cache.instance) {
Cache.instance = new Cache()
Expand All @@ -20,9 +31,26 @@ export default class Cache extends Map<keyof CacheContents, ValueOf<CacheContent
return Cache.instance
}

public get(key: '@oclif/core'): OclifCoreInfo
public get(key: 'rootPlugin'): Plugin | undefined
public get(key: 'exitCodes'): PJSON.Plugin['oclif']['exitCodes'] | undefined
public get(key: keyof CacheContents): ValueOf<CacheContents> | 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'}
}
}
}
}
6 changes: 3 additions & 3 deletions src/cli-ux/config.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -51,7 +50,8 @@ export class Config {
}

function fetch() {
const major = requireJson<PJSON>(__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]
Expand Down
5 changes: 2 additions & 3 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<PJSON>(__dirname, '..', 'package.json')
const pjson = Cache.getInstance().get('@oclif/core')

/**
* swallows stdout epipe errors
Expand Down
13 changes: 7 additions & 6 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -27,7 +27,7 @@ import {Debug, collectUsableIds, getCommandIdPermutations} from './util'
// eslint-disable-next-line new-cap
const debug = Debug()

const _pjson = requireJson<PJSON>(__dirname, '..', '..', 'package.json')
const _pjson = Cache.getInstance().get('@oclif/core')
const BASE = `${_pjson.name}@${_pjson.version}`

function channelFromVersion(version: string) {
Expand Down Expand Up @@ -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') {
Expand All @@ -585,7 +586,7 @@ export class Config implements IConfig {

marker?.addDetails({
event,
hook,
hook: hook.target,
plugin: p.name,
})
marker?.stop()
Expand Down
30 changes: 23 additions & 7 deletions src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,24 @@ 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'
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<PJSON>(__dirname, '..', '..', 'package.json')
const _pjson = Cache.getInstance().get('@oclif/core')

function topicsToArray(input: any, base?: string): Topic[] {
if (!input) return []
Expand Down Expand Up @@ -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.
*
Expand All @@ -101,7 +112,7 @@ export class Plugin implements IPlugin {

hasManifest = false

hooks!: {[k: string]: string[]}
hooks!: {[key: string]: HookOptions[]}

isRoot = false

Expand Down Expand Up @@ -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<string | HookOptions>(v).map((v) => determineHookOptions(v)),
]),
)

this.commandDiscoveryOpts = determineCommandDiscoveryOptions(this.pjson.oclif?.commands, this.pjson.oclif?.default)

Expand Down Expand Up @@ -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<Record<string, CommandCache>>(this, filePath)
this.commandCache = module[this.commandDiscoveryOpts?.identifier ?? 'default'] ?? {}
return this.commandCache
}

Expand Down
52 changes: 46 additions & 6 deletions src/interfaces/pjson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, string>
macos?: {
identifier?: string
Expand Down
4 changes: 2 additions & 2 deletions src/interfaces/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Command} from '../command'
import {PJSON} from './pjson'
import {HookOptions, PJSON} from './pjson'
import {Topic} from './topic'

export interface PluginOptions {
Expand Down Expand Up @@ -42,7 +42,7 @@ export interface Plugin {
findCommand(id: string, opts: {must: true}): Promise<Command.Class>
findCommand(id: string, opts?: {must: boolean}): Promise<Command.Class> | undefined
readonly hasManifest: boolean
hooks: {[k: string]: string[]}
hooks: {[key: string]: HookOptions[]}
/**
* True if the plugin is the root plugin.
*/
Expand Down
5 changes: 0 additions & 5 deletions src/util/fs.ts
Original file line number Diff line number Diff line change
@@ -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<T>(...pathParts: string[]): T {
return JSON.parse(readFileSync(join(...pathParts), 'utf8'))
}

/**
* Parser for Args.directory and Flags.directory. Checks that the provided path
Expand Down
18 changes: 12 additions & 6 deletions test/command/explicit-command-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
7 changes: 7 additions & 0 deletions test/command/fixtures/bundled-cli/src/hooks/init.ts
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit d0aa430

Please sign in to comment.