Skip to content

Commit

Permalink
feat: add PluginLoader class (#774)
Browse files Browse the repository at this point in the history
* feat: add PluginLoader class

* chore: remove semver dep
  • Loading branch information
mdonnalley authored Sep 1, 2023
1 parent 19b280b commit b31665d
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 136 deletions.
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"natural-orderby": "^2.0.3",
"object-treeify": "^1.1.33",
"password-prompt": "^1.1.2",
"semver": "^7.5.4",
"slice-ansi": "^4.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
Expand Down Expand Up @@ -53,7 +52,6 @@
"@types/node": "^16",
"@types/node-notifier": "^8.0.2",
"@types/proxyquire": "^1.3.28",
"@types/semver": "^7.5.1",
"@types/shelljs": "^0.8.11",
"@types/slice-ansi": "^4.0.0",
"@types/strip-ansi": "^5.2.1",
Expand Down
145 changes: 27 additions & 118 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import {format} from 'util'
import {Options, Plugin as IPlugin} from '../interfaces/plugin'
import {Config as IConfig, ArchTypes, PlatformTypes, LoadOptions, VersionDetails} from '../interfaces/config'
import {Hook, Hooks, PJSON, Topic} from '../interfaces'
import * as Plugin from './plugin'
import {Debug, compact, loadJSON, collectUsableIds, getCommandIdPermutations} from './util'
import {Debug, compact, collectUsableIds, getCommandIdPermutations} from './util'
import {ensureArgObject, isProd, requireJson} from '../util'
import ModuleLoader from '../module-loader'
import {getHelpFlagAdditions} from '../help'
Expand All @@ -20,6 +19,7 @@ import {Performance} from '../performance'
import {settings} from '../settings'
import {userInfo as osUserInfo} from 'os'
import {sep} from 'path'
import PluginLoader from './plugin-loader'

// eslint-disable-next-line new-cap
const debug = Debug()
Expand Down Expand Up @@ -111,24 +111,10 @@ export class Config implements IConfig {
private _topics = new Map<string, Topic>()

private _commandIDs!: string[]
private pluginLoader!: PluginLoader
private rootPlugin!: IPlugin

constructor(public options: Options) {
if (options.config) {
if (Array.isArray(options.config.plugins) && Array.isArray(this.plugins)) {
// incoming config is v2 with plugins array and this config is v2 with plugins array
Object.assign(this, options.config)
} else if (Array.isArray(options.config.plugins) && !Array.isArray(this.plugins)) {
// incoming config is v2 with plugins array and this config is v3 with plugin Map
Object.assign(this, options.config, {plugins: new Map(options.config.plugins.map(p => [p.name, p]))})
} else if (!Array.isArray(options.config.plugins) && Array.isArray(this.plugins)) {
// incoming config is v3 with plugin Map and this config is v2 with plugins array
Object.assign(this, options.config, {plugins: options.config.getPluginsList()})
} else {
// incoming config is v3 with plugin Map and this config is v3 with plugin Map
Object.assign(this, options.config)
}
}
}
constructor(public options: Options) {}

static async load(opts: LoadOptions = module.filename || __dirname): Promise<Config> {
// Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path.
Expand All @@ -138,10 +124,6 @@ export class Config implements IConfig {

if (typeof opts === 'string') opts = {root: opts}
if (isConfig(opts)) {
const {lt} = await import('semver')

const currentConfigBase = BASE.replace('@oclif/core@', '')
const incomingConfigBase = opts._base.replace('@oclif/core@', '')
/**
* Reload the Config based on the version required by the command.
* This is needed because the command is given the Config instantiated
Expand All @@ -152,9 +134,11 @@ export class Config implements IConfig {
* exists in the version of Config required by the command but may not exist on the
* root's instance of Config.
*/
if (lt(incomingConfigBase, currentConfigBase)) {
if (BASE !== opts._base) {
debug(`reloading config from ${opts._base} to ${BASE}`)
return new Config({...opts.options, config: opts})
const config = new Config({...opts.options, plugins: opts.plugins})
await config.load()
return config
}

return opts
Expand All @@ -167,18 +151,15 @@ export class Config implements IConfig {

// eslint-disable-next-line complexity
public async load(): Promise<void> {
if (this.options.config) return

settings.performanceEnabled = (settings.performanceEnabled === undefined ? this.options.enablePerf : settings.performanceEnabled) ?? false
const plugin = new Plugin.Plugin({root: this.options.root})
await plugin.load()
this.plugins.push(plugin)
this.root = plugin.root
this.pjson = plugin.pjson
this.pluginLoader = new PluginLoader({root: this.options.root, plugins: this.options.plugins})
this.rootPlugin = await this.pluginLoader.loadRoot()
this.root = this.rootPlugin.root
this.pjson = this.rootPlugin.pjson
this.name = this.pjson.name
this.version = this.options.version || this.pjson.version || '0.0.0'
this.channel = this.options.channel || channelFromVersion(this.version)
this.valid = plugin.valid
this.valid = this.rootPlugin.valid

this.arch = (os.arch() === 'ia32' ? 'x86' : os.arch() as any)
this.platform = WSL ? 'wsl' : os.platform() as any
Expand Down Expand Up @@ -242,54 +223,27 @@ export class Config implements IConfig {
marker?.stop()
}

async loadPluginsAndCommands(): Promise<void> {
async loadPluginsAndCommands(opts?: {force: boolean}): Promise<void> {
const marker = Performance.mark('config.loadPluginsAndCommands')
await this.loadUserPlugins()
await this.loadDevPlugins()
await this.loadCorePlugins()
const {plugins, errors} = await this.pluginLoader.loadChildren({
devPlugins: this.options.devPlugins,
userPlugins: this.options.userPlugins,
dataDir: this.dataDir,
rootPlugin: this.rootPlugin,
force: opts?.force ?? false,
})
this.plugins = [...plugins.values()]

for (const plugin of this.plugins) {
this.loadCommands(plugin)
this.loadTopics(plugin)
}

marker?.stop()
}

public async loadCorePlugins(): Promise<void> {
if (this.pjson.oclif.plugins) {
await this.loadPlugins(this.root, 'core', this.pjson.oclif.plugins)
}
}

public async loadDevPlugins(): Promise<void> {
if (this.options.devPlugins !== false) {
// do not load oclif.devPlugins in production
if (this.isProd) return
try {
const devPlugins = this.pjson.oclif.devPlugins
if (devPlugins) await this.loadPlugins(this.root, 'dev', devPlugins)
} catch (error: any) {
process.emitWarning(error)
}
for (const error of errors) {
this.warn(error)
}
}

public async loadUserPlugins(): Promise<void> {
if (this.options.userPlugins !== false) {
try {
const userPJSONPath = path.join(this.dataDir, 'package.json')
debug('reading user plugins pjson %s', userPJSONPath)
const pjson = await loadJSON(userPJSONPath)
this.userPJSON = pjson
if (!pjson.oclif) pjson.oclif = {schema: 1}
if (!pjson.oclif.plugins) pjson.oclif.plugins = []
await this.loadPlugins(userPJSONPath, 'user', pjson.oclif.plugins.filter((p: any) => p.type === 'user'))
await this.loadPlugins(userPJSONPath, 'link', pjson.oclif.plugins.filter((p: any) => p.type === 'link'))
} catch (error: any) {
if (error.code !== 'ENOENT') process.emitWarning(error)
}
}
marker?.stop()
}

public async runHook<T extends keyof Hooks>(
Expand Down Expand Up @@ -413,7 +367,7 @@ export class Config implements IConfig {
})
if (jitResult.failures[0]) throw jitResult.failures[0].error
if (jitResult.successes[0]) {
await this.loadPluginsAndCommands()
await this.loadPluginsAndCommands({force: true})
c = this.findCommand(id) ?? c
} else {
// this means that no jit_plugin_not_installed hook exists, so we should run the default behavior
Expand Down Expand Up @@ -636,51 +590,6 @@ export class Config implements IConfig {
return 0
}

protected async loadPlugins(root: string, type: string, plugins: (string | { root?: string; name?: string; tag?: string })[], parent?: Plugin.Plugin): Promise<void> {
if (!plugins || plugins.length === 0) return
const mark = Performance.mark(`config.loadPlugins#${type}`)
debug('loading plugins', plugins)
await Promise.all((plugins || []).map(async plugin => {
try {
const opts: Options = {type, root}
if (typeof plugin === 'string') {
opts.name = plugin
} else {
opts.name = plugin.name || opts.name
opts.tag = plugin.tag || opts.tag
opts.root = plugin.root || opts.root
}

const pluginMarker = Performance.mark(`plugin.load#${opts.name!}`)
const instance = new Plugin.Plugin(opts)
await instance.load()
pluginMarker?.addDetails({
hasManifest: instance.hasManifest,
commandCount: instance.commands.length,
topicCount: instance.topics.length,
type: instance.type,
usesMain: Boolean(instance.pjson.main),
name: instance.name,
})
pluginMarker?.stop()
if (this.plugins.find(p => p.name === instance.name)) return
this.plugins.push(instance)
if (parent) {
instance.parent = parent
if (!parent.children) parent.children = []
parent.children.push(instance)
}

await this.loadPlugins(instance.root, type, instance.pjson.oclif.plugins || [], instance)
} catch (error: any) {
this.warn(error, 'loadPlugins')
}
}))

mark?.addDetails({pluginCount: plugins.length})
mark?.stop()
}

protected warn(err: string | Error | { name: string; detail: string }, scope?: string): void {
if (this.warned) return

Expand Down
145 changes: 145 additions & 0 deletions src/config/plugin-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import * as path from 'path'

import {Options, Plugin as IPlugin} from '../interfaces/plugin'
import * as Plugin from './plugin'
import {loadJSON, Debug} from './util'
import {isProd} from '../util'
import {Performance} from '../performance'

// eslint-disable-next-line new-cap
const debug = Debug()

type PluginLoaderOptions = {
root: string;
plugins?: IPlugin[] | PluginsMap;
}

type LoadOpts = {
devPlugins?: boolean;
userPlugins?: boolean;
dataDir: string;
rootPlugin: IPlugin;
force?: boolean;
}

type PluginsMap = Map<string, IPlugin>

export default class PluginLoader {
public plugins: PluginsMap = new Map()
public errors: (string | Error)[] = []
private pluginsProvided = false

constructor(public options: PluginLoaderOptions) {
if (options.plugins) {
this.pluginsProvided = true
this.plugins = Array.isArray(options.plugins) ? new Map(options.plugins.map(p => [p.name, p])) : options.plugins
}
}

public async loadRoot(): Promise<IPlugin> {
let rootPlugin: IPlugin
if (this.pluginsProvided) {
const plugins = [...this.plugins.values()]
rootPlugin = plugins.find(p => p.root === this.options.root) ?? plugins[0]
} else {
rootPlugin = new Plugin.Plugin({root: this.options.root})
await rootPlugin.load()
}

this.plugins.set(rootPlugin.name, rootPlugin)
return rootPlugin
}

public async loadChildren(opts: LoadOpts): Promise<{plugins: PluginsMap; errors: (string | Error)[]}> {
if (!this.pluginsProvided || opts.force) {
await this.loadUserPlugins(opts)
await this.loadDevPlugins(opts)
await this.loadCorePlugins(opts)
}

return {plugins: this.plugins, errors: this.errors}
}

private async loadCorePlugins(opts: LoadOpts): Promise<void> {
if (opts.rootPlugin.pjson.oclif.plugins) {
await this.loadPlugins(opts.rootPlugin.root, 'core', opts.rootPlugin.pjson.oclif.plugins)
}
}

private async loadDevPlugins(opts: LoadOpts): Promise<void> {
if (opts.devPlugins !== false) {
// do not load oclif.devPlugins in production
if (isProd()) return
try {
const devPlugins = opts.rootPlugin.pjson.oclif.devPlugins
if (devPlugins) await this.loadPlugins(opts.rootPlugin.root, 'dev', devPlugins)
} catch (error: any) {
process.emitWarning(error)
}
}
}

private async loadUserPlugins(opts: LoadOpts): Promise<void> {
if (opts.userPlugins !== false) {
try {
const userPJSONPath = path.join(opts.dataDir, 'package.json')
debug('reading user plugins pjson %s', userPJSONPath)
const pjson = await loadJSON(userPJSONPath)
if (!pjson.oclif) pjson.oclif = {schema: 1}
if (!pjson.oclif.plugins) pjson.oclif.plugins = []
await this.loadPlugins(userPJSONPath, 'user', pjson.oclif.plugins.filter((p: any) => p.type === 'user'))
await this.loadPlugins(userPJSONPath, 'link', pjson.oclif.plugins.filter((p: any) => p.type === 'link'))
} catch (error: any) {
if (error.code !== 'ENOENT') process.emitWarning(error)
}
}
}

private async loadPlugins(root: string, type: string, plugins: (string | { root?: string; name?: string; tag?: string })[], parent?: Plugin.Plugin): Promise<void> {
if (!plugins || plugins.length === 0) return
const mark = Performance.mark(`config.loadPlugins#${type}`)
debug('loading plugins', plugins)
await Promise.all((plugins || []).map(async plugin => {
try {
const name = typeof plugin === 'string' ? plugin : plugin.name!
const opts: Options = {
name,
type,
root,
}
if (typeof plugin !== 'string') {
opts.tag = plugin.tag || opts.tag
opts.root = plugin.root || opts.root
}

if (this.plugins.has(name)) return
const pluginMarker = Performance.mark(`plugin.load#${name}`)
const instance = new Plugin.Plugin(opts)
await instance.load()
pluginMarker?.addDetails({
hasManifest: instance.hasManifest,
commandCount: instance.commands.length,
topicCount: instance.topics.length,
type: instance.type,
usesMain: Boolean(instance.pjson.main),
name: instance.name,
})
pluginMarker?.stop()

this.plugins.set(instance.name, instance)
if (parent) {
instance.parent = parent
if (!parent.children) parent.children = []
parent.children.push(instance)
}

await this.loadPlugins(instance.root, type, instance.pjson.oclif.plugins || [], instance)
} catch (error: any) {
this.errors.push(error)
}
}))

mark?.addDetails({pluginCount: plugins.length})
mark?.stop()
}
}
1 change: 0 additions & 1 deletion src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ export interface Config {
* npm registry to use for installing plugins
*/
npmRegistry?: string;
userPJSON?: PJSON.User;
plugins: Plugin[];
binPath?: string;
/**
Expand Down
Loading

0 comments on commit b31665d

Please sign in to comment.