Skip to content

Commit 237b211

Browse files
committedJan 26, 2018
feat: initial implementation
1 parent 86a46ee commit 237b211

File tree

10 files changed

+395
-45
lines changed

10 files changed

+395
-45
lines changed
 

‎package.json

+16-6
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,33 @@
55
"author": "Jeff Dickey @jdxcode",
66
"bugs": "https://github.com/jdxcode/plugins/issues",
77
"dependencies": {
8-
"@dxcli/command": "^0.1.16",
9-
"cli-ux": "^3.1.5"
8+
"@dxcli/command": "^0.1.17",
9+
"@dxcli/loader": "^0.2.4",
10+
"@dxcli/manifest-file": "^0.0.4",
11+
"@heroku-cli/color": "^1.1.1",
12+
"cli-ux": "^3.1.6",
13+
"fs-extra": "^5.0.0",
14+
"npm-run-path": "^2.0.2",
15+
"tslib": "^1.9.0",
16+
"yarn": "^1.3.2"
1017
},
1118
"devDependencies": {
12-
"@dxcli/config": "^0.1.24",
19+
"@dxcli/config": "^0.1.26",
1320
"@dxcli/dev-nyc-config": "^0.0.3",
1421
"@dxcli/dev-semantic-release": "^0.1.0",
1522
"@dxcli/dev-test": "^0.9.4",
16-
"@dxcli/dev-tslint": "^0.0.15",
17-
"@dxcli/engine": "^0.1.7",
23+
"@dxcli/dev-tslint": "^0.0.16",
24+
"@dxcli/engine": "^0.1.10",
1825
"@types/ansi-styles": "^2.0.30",
1926
"@types/chai": "^4.1.2",
27+
"@types/fs-extra": "^5.0.0",
2028
"@types/lodash": "^4.14.96",
2129
"@types/mocha": "^2.2.47",
2230
"@types/nock": "^9.1.2",
2331
"@types/node": "^9.3.0",
2432
"@types/read-pkg": "^3.0.0",
2533
"@types/strip-ansi": "^3.0.0",
34+
"@types/supports-color": "^3.1.0",
2635
"chai": "^4.1.2",
2736
"eslint": "^4.16.0",
2837
"eslint-config-dxcli": "^1.1.4",
@@ -36,7 +45,8 @@
3645
"typescript": "^2.6.2"
3746
},
3847
"dxcli": {
39-
"commands": "./lib/commands"
48+
"commands": "./lib/commands",
49+
"plugins": "./lib/load"
4050
},
4151
"engines": {
4252
"node": ">=8.0.0"

‎src/commands/hello.ts

-13
This file was deleted.

‎src/commands/plugins/index.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Command, {flags} from '@dxcli/command'
2+
import color from '@heroku-cli/color'
3+
import cli from 'cli-ux'
4+
import * as _ from 'lodash'
5+
6+
let examplePlugins = {
7+
'heroku-ci': {version: '1.8.0'},
8+
'heroku-cli-status': {version: '3.0.10', type: 'link'},
9+
'heroku-fork': {version: '4.1.22'},
10+
}
11+
let bin = 'heroku'
12+
const g = global as any
13+
if (g.config) {
14+
bin = g.config.bin
15+
let pjson = g.config.pjson['cli-engine']
16+
if (pjson.help && pjson.help.plugins) {
17+
examplePlugins = pjson.help.plugins
18+
}
19+
}
20+
const examplePluginsHelp = Object.entries(examplePlugins).map(([name, p]: [string, any]) => ` ${name} ${p.version}`)
21+
22+
export default class Plugins extends Command {
23+
static flags: flags.Input = {
24+
core: flags.boolean({description: 'show core plugins'})
25+
}
26+
static description = 'list installed plugins'
27+
static help = `Example:
28+
$ ${bin} plugins
29+
${examplePluginsHelp.join('\n')}
30+
`
31+
32+
async run() {
33+
let plugins = this.config.engine!.plugins
34+
plugins = plugins.filter(p => p.type !== 'builtin' && p.type !== 'main')
35+
_.sortBy(plugins, 'name')
36+
if (!this.flags.core) plugins = plugins.filter(p => p.type !== 'core')
37+
if (!plugins.length) cli.warn('no plugins installed')
38+
for (let plugin of plugins) {
39+
let output = `${plugin.name} ${color.dim(plugin.version)}`
40+
if (plugin.type !== 'user') output += color.dim(` (${plugin.type})`)
41+
if (plugin.type === 'link') output += ` ${plugin.root}`
42+
else if (plugin.tag !== 'latest') output += color.dim(` (${String(plugin.tag)})`)
43+
cli.log(output)
44+
}
45+
}
46+
}

‎src/commands/plugins/install.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {Command} from '@dxcli/command'
2+
3+
import Plugins from '../../plugins'
4+
5+
let examplePlugin = 'heroku-production-status'
6+
let bin = 'heroku'
7+
const g = global as any
8+
if (g.config) {
9+
bin = g.config.bin
10+
let pjson = g.config.pjson.dxcli
11+
if (pjson.help && pjson.help.plugins) {
12+
examplePlugin = Object.keys(pjson.help.plugins)[0]
13+
}
14+
}
15+
16+
export default class PluginsInstall extends Command {
17+
static description = 'installs a plugin into the CLI'
18+
static usage = 'plugins:install PLUGIN...'
19+
static help = `
20+
Example:
21+
$ ${bin} plugins:install ${examplePlugin}
22+
`
23+
static variableArgs = true
24+
static args = [{name: 'plugin', description: 'plugin to install', required: true}]
25+
26+
plugins: Plugins
27+
28+
async run() {
29+
this.plugins = new Plugins(this.config)
30+
for (let plugin of this.argv) {
31+
let scoped = plugin[0] === '@'
32+
if (scoped) plugin = plugin.slice(1)
33+
let [name, tag = 'latest'] = plugin.split('@')
34+
if (scoped) name = `@${name}`
35+
await this.plugins.install(name, tag)
36+
}
37+
}
38+
}

‎src/index.ts

-3
This file was deleted.

‎src/load.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {IConfig, IPlugin} from '@dxcli/config'
2+
3+
import Plugins from './plugins'
4+
5+
export default async function (config: IConfig) {
6+
try {
7+
const plugins = new Plugins(config)
8+
return await plugins.load()
9+
} catch (err) {
10+
const cli = require('cli-ux').scope('loading plugins')
11+
cli.warn(err)
12+
}
13+
}
14+
15+
export {IPlugin}

‎src/manifest.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import ManifestFile from '@dxcli/manifest-file'
2+
3+
export interface File {
4+
manifest: {
5+
plugins: {
6+
[name: string]: {
7+
tag: string
8+
}
9+
}
10+
}
11+
}
12+
13+
export default class Manifest extends ManifestFile {
14+
constructor(file: string) {
15+
super(['@dxcli/plugins', file].join(':'), file)
16+
}
17+
18+
async list(): Promise<File['manifest']['plugins']> {
19+
return (await this.get('plugins')) || {} as any
20+
}
21+
22+
async add(name: string, tag: string) {
23+
this.debug(`adding ${name}@${tag}`)
24+
const plugins = await this.list()
25+
plugins[name] = {tag}
26+
await this.set(['plugins', plugins])
27+
}
28+
29+
async remove(name: string) {
30+
this.debug(`removing ${name}`)
31+
const plugins = await this.list()
32+
if (!plugins[name]) return this.debug('not found in manifest')
33+
delete plugins[name]
34+
await this.set(['plugins', plugins])
35+
}
36+
}

‎src/plugins.ts

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {IConfig, IPlugin} from '@dxcli/config'
2+
import {load} from '@dxcli/loader'
3+
import {cli} from 'cli-ux'
4+
import * as fs from 'fs-extra'
5+
import * as _ from 'lodash'
6+
import * as path from 'path'
7+
8+
import Manifest from './manifest'
9+
import Yarn from './yarn'
10+
11+
export default class Plugins {
12+
private manifest: Manifest
13+
private yarn: Yarn
14+
private debug: any
15+
16+
constructor(public config: IConfig) {
17+
this.manifest = new Manifest(path.join(this.config.dataDir, 'plugins', 'user.json'))
18+
this.yarn = new Yarn({config, cwd: this.userPluginsDir})
19+
this.debug = require('debug')('@dxcli/plugins')
20+
}
21+
22+
async list() {
23+
const plugins = await this.manifest.list()
24+
return Object.entries(plugins)
25+
}
26+
27+
async install(name: string, tag = 'latest') {
28+
try {
29+
cli.info(`Installing plugin ${name}${tag === 'latest' ? '' : '@' + tag}`)
30+
await this.createPJSON()
31+
await this.yarn.exec(['add', `${name}@${tag}`])
32+
let plugin = await this.loadPlugin(name)
33+
if (!plugin.commands.length) throw new Error('no commands found in plugin')
34+
await this.manifest.add(name, tag)
35+
} catch (err) {
36+
await this.uninstall(name).catch(err => this.debug(err))
37+
throw err
38+
}
39+
}
40+
41+
async load(): Promise<IPlugin[]> {
42+
const plugins = await this.list()
43+
return _.compact(await Promise.all(plugins.map(async ([p]) => {
44+
try {
45+
return await this.loadPlugin(p)
46+
} catch (err) {
47+
cli.warn(err)
48+
}
49+
})))
50+
}
51+
52+
public async uninstall(name: string) {
53+
const plugins = await this.manifest.list()
54+
if (!plugins[name]) return
55+
await this.manifest.remove(name)
56+
await this.yarn.exec(['remove', name])
57+
}
58+
59+
private async loadPlugin(name: string) {
60+
return load({root: this.userPluginPath(name), type: 'user', resetCache: true})
61+
}
62+
63+
private async createPJSON() {
64+
if (!await fs.pathExists(this.pjsonPath)) {
65+
await fs.outputJSON(this.pjsonPath, {private: true, 'cli-engine': {schema: 1}}, {spaces: 2})
66+
}
67+
}
68+
69+
private userPluginPath(name: string): string {
70+
return path.join(this.userPluginsDir, 'node_modules', name)
71+
}
72+
private get userPluginsDir() {
73+
return path.join(this.config.dataDir, 'plugins')
74+
}
75+
private get pjsonPath() {
76+
return path.join(this.userPluginsDir, 'package.json')
77+
}
78+
}

‎src/yarn.ts

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {IConfig} from '@dxcli/config'
2+
import * as fs from 'fs-extra'
3+
import * as path from 'path'
4+
5+
const debug = require('debug')('cli:yarn')
6+
7+
export default class Yarn {
8+
config: IConfig
9+
cwd: string
10+
11+
constructor({config, cwd}: { config: IConfig; cwd: string }) {
12+
this.config = config
13+
this.cwd = cwd
14+
}
15+
16+
get bin(): string {
17+
return require.resolve('yarn/bin/yarn.js')
18+
}
19+
20+
fork(modulePath: string, args: string[] = [], options: any = {}): Promise<void> {
21+
return new Promise((resolve, reject) => {
22+
const {fork} = require('child_process')
23+
let forked = fork(modulePath, args, options)
24+
25+
forked.on('error', reject)
26+
forked.on('exit', (code: number) => {
27+
if (code === 0) {
28+
resolve()
29+
} else {
30+
reject(new Error(`yarn ${args.join(' ')} exited with code ${code}`))
31+
}
32+
})
33+
34+
// Fix windows bug with node-gyp hanging for input forever
35+
if (this.config.windows) {
36+
forked.stdin.write('\n')
37+
}
38+
})
39+
}
40+
41+
async exec(args: string[] = []): Promise<void> {
42+
if (args.length !== 0) await this.checkForYarnLock()
43+
if (args[0] !== 'run') {
44+
const cacheDir = path.join(this.config.cacheDir, 'yarn')
45+
args = [
46+
...args,
47+
'--non-interactive',
48+
`--mutex=file:${path.join(this.cwd, 'yarn.lock')}`,
49+
`--preferred-cache-folder=${cacheDir}`,
50+
...this.proxyArgs(),
51+
]
52+
if (this.config.npmRegistry) {
53+
args.push(`--registry=${this.config.npmRegistry}`)
54+
}
55+
}
56+
57+
const npmRunPath = require('npm-run-path')
58+
let options = {
59+
cwd: this.cwd,
60+
stdio: [0, 1, 2, 'ipc'],
61+
env: npmRunPath.env({cwd: this.cwd, env: process.env}),
62+
}
63+
64+
debug(`${this.cwd}: ${this.bin} ${args.join(' ')}`)
65+
try {
66+
await this.fork(this.bin, args, options)
67+
debug('done')
68+
} catch (err) {
69+
// TODO: https://github.com/yarnpkg/yarn/issues/2191
70+
let networkConcurrency = '--network-concurrency=1'
71+
if (err.message.includes('EAI_AGAIN') && !args.includes(networkConcurrency)) {
72+
debug('EAI_AGAIN')
73+
return this.exec([...args, networkConcurrency])
74+
}
75+
throw err
76+
}
77+
}
78+
79+
async checkForYarnLock() {
80+
// add yarn lockfile if it does not exist
81+
if (this.cwd && !await fs.pathExists(path.join(this.cwd, 'yarn.lock'))) {
82+
await this.exec()
83+
}
84+
}
85+
86+
proxyArgs(): string[] {
87+
let args = []
88+
let http = process.env.http_proxy || process.env.HTTP_PROXY
89+
let https = process.env.https_proxy || process.env.HTTPS_PROXY
90+
if (http) args.push(`--proxy=${http}`)
91+
if (https) args.push(`--https-proxy=${https}`)
92+
return args
93+
}
94+
}

0 commit comments

Comments
 (0)
Please sign in to comment.