Skip to content

Commit

Permalink
feat: initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
jdx committed Jan 26, 2018
1 parent 86a46ee commit 237b211
Show file tree
Hide file tree
Showing 10 changed files with 395 additions and 45 deletions.
22 changes: 16 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,33 @@
"author": "Jeff Dickey @jdxcode",
"bugs": "https://github.com/jdxcode/plugins/issues",
"dependencies": {
"@dxcli/command": "^0.1.16",
"cli-ux": "^3.1.5"
"@dxcli/command": "^0.1.17",
"@dxcli/loader": "^0.2.4",
"@dxcli/manifest-file": "^0.0.4",
"@heroku-cli/color": "^1.1.1",
"cli-ux": "^3.1.6",
"fs-extra": "^5.0.0",
"npm-run-path": "^2.0.2",
"tslib": "^1.9.0",
"yarn": "^1.3.2"
},
"devDependencies": {
"@dxcli/config": "^0.1.24",
"@dxcli/config": "^0.1.26",
"@dxcli/dev-nyc-config": "^0.0.3",
"@dxcli/dev-semantic-release": "^0.1.0",
"@dxcli/dev-test": "^0.9.4",
"@dxcli/dev-tslint": "^0.0.15",
"@dxcli/engine": "^0.1.7",
"@dxcli/dev-tslint": "^0.0.16",
"@dxcli/engine": "^0.1.10",
"@types/ansi-styles": "^2.0.30",
"@types/chai": "^4.1.2",
"@types/fs-extra": "^5.0.0",
"@types/lodash": "^4.14.96",
"@types/mocha": "^2.2.47",
"@types/nock": "^9.1.2",
"@types/node": "^9.3.0",
"@types/read-pkg": "^3.0.0",
"@types/strip-ansi": "^3.0.0",
"@types/supports-color": "^3.1.0",
"chai": "^4.1.2",
"eslint": "^4.16.0",
"eslint-config-dxcli": "^1.1.4",
Expand All @@ -36,7 +45,8 @@
"typescript": "^2.6.2"
},
"dxcli": {
"commands": "./lib/commands"
"commands": "./lib/commands",
"plugins": "./lib/load"
},
"engines": {
"node": ">=8.0.0"
Expand Down
13 changes: 0 additions & 13 deletions src/commands/hello.ts

This file was deleted.

46 changes: 46 additions & 0 deletions src/commands/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Command, {flags} from '@dxcli/command'
import color from '@heroku-cli/color'
import cli from 'cli-ux'
import * as _ from 'lodash'

let examplePlugins = {
'heroku-ci': {version: '1.8.0'},
'heroku-cli-status': {version: '3.0.10', type: 'link'},
'heroku-fork': {version: '4.1.22'},
}
let bin = 'heroku'
const g = global as any
if (g.config) {
bin = g.config.bin
let pjson = g.config.pjson['cli-engine']
if (pjson.help && pjson.help.plugins) {
examplePlugins = pjson.help.plugins
}
}
const examplePluginsHelp = Object.entries(examplePlugins).map(([name, p]: [string, any]) => ` ${name} ${p.version}`)

export default class Plugins extends Command {
static flags: flags.Input = {
core: flags.boolean({description: 'show core plugins'})
}
static description = 'list installed plugins'
static help = `Example:
$ ${bin} plugins
${examplePluginsHelp.join('\n')}
`

async run() {
let plugins = this.config.engine!.plugins
plugins = plugins.filter(p => p.type !== 'builtin' && p.type !== 'main')
_.sortBy(plugins, 'name')
if (!this.flags.core) plugins = plugins.filter(p => p.type !== 'core')
if (!plugins.length) cli.warn('no plugins installed')
for (let plugin of plugins) {
let output = `${plugin.name} ${color.dim(plugin.version)}`
if (plugin.type !== 'user') output += color.dim(` (${plugin.type})`)
if (plugin.type === 'link') output += ` ${plugin.root}`
else if (plugin.tag !== 'latest') output += color.dim(` (${String(plugin.tag)})`)
cli.log(output)
}
}
}
38 changes: 38 additions & 0 deletions src/commands/plugins/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {Command} from '@dxcli/command'

import Plugins from '../../plugins'

let examplePlugin = 'heroku-production-status'
let bin = 'heroku'
const g = global as any
if (g.config) {
bin = g.config.bin
let pjson = g.config.pjson.dxcli
if (pjson.help && pjson.help.plugins) {
examplePlugin = Object.keys(pjson.help.plugins)[0]
}
}

export default class PluginsInstall extends Command {
static description = 'installs a plugin into the CLI'
static usage = 'plugins:install PLUGIN...'
static help = `
Example:
$ ${bin} plugins:install ${examplePlugin}
`
static variableArgs = true
static args = [{name: 'plugin', description: 'plugin to install', required: true}]

plugins: Plugins

async run() {
this.plugins = new Plugins(this.config)
for (let plugin of this.argv) {
let scoped = plugin[0] === '@'
if (scoped) plugin = plugin.slice(1)
let [name, tag = 'latest'] = plugin.split('@')
if (scoped) name = `@${name}`
await this.plugins.install(name, tag)
}
}
}
3 changes: 0 additions & 3 deletions src/index.ts

This file was deleted.

15 changes: 15 additions & 0 deletions src/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {IConfig, IPlugin} from '@dxcli/config'

import Plugins from './plugins'

export default async function (config: IConfig) {
try {
const plugins = new Plugins(config)
return await plugins.load()
} catch (err) {
const cli = require('cli-ux').scope('loading plugins')
cli.warn(err)
}
}

export {IPlugin}
36 changes: 36 additions & 0 deletions src/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import ManifestFile from '@dxcli/manifest-file'

export interface File {
manifest: {
plugins: {
[name: string]: {
tag: string
}
}
}
}

export default class Manifest extends ManifestFile {
constructor(file: string) {
super(['@dxcli/plugins', file].join(':'), file)
}

async list(): Promise<File['manifest']['plugins']> {
return (await this.get('plugins')) || {} as any
}

async add(name: string, tag: string) {
this.debug(`adding ${name}@${tag}`)
const plugins = await this.list()
plugins[name] = {tag}
await this.set(['plugins', plugins])
}

async remove(name: string) {
this.debug(`removing ${name}`)
const plugins = await this.list()
if (!plugins[name]) return this.debug('not found in manifest')
delete plugins[name]
await this.set(['plugins', plugins])
}
}
78 changes: 78 additions & 0 deletions src/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {IConfig, IPlugin} from '@dxcli/config'
import {load} from '@dxcli/loader'
import {cli} from 'cli-ux'
import * as fs from 'fs-extra'
import * as _ from 'lodash'
import * as path from 'path'

import Manifest from './manifest'
import Yarn from './yarn'

export default class Plugins {
private manifest: Manifest
private yarn: Yarn
private debug: any

constructor(public config: IConfig) {
this.manifest = new Manifest(path.join(this.config.dataDir, 'plugins', 'user.json'))
this.yarn = new Yarn({config, cwd: this.userPluginsDir})
this.debug = require('debug')('@dxcli/plugins')
}

async list() {
const plugins = await this.manifest.list()
return Object.entries(plugins)
}

async install(name: string, tag = 'latest') {
try {
cli.info(`Installing plugin ${name}${tag === 'latest' ? '' : '@' + tag}`)
await this.createPJSON()
await this.yarn.exec(['add', `${name}@${tag}`])
let plugin = await this.loadPlugin(name)
if (!plugin.commands.length) throw new Error('no commands found in plugin')
await this.manifest.add(name, tag)
} catch (err) {
await this.uninstall(name).catch(err => this.debug(err))
throw err
}
}

async load(): Promise<IPlugin[]> {
const plugins = await this.list()
return _.compact(await Promise.all(plugins.map(async ([p]) => {
try {
return await this.loadPlugin(p)
} catch (err) {
cli.warn(err)
}
})))
}

public async uninstall(name: string) {
const plugins = await this.manifest.list()
if (!plugins[name]) return
await this.manifest.remove(name)
await this.yarn.exec(['remove', name])
}

private async loadPlugin(name: string) {
return load({root: this.userPluginPath(name), type: 'user', resetCache: true})
}

private async createPJSON() {
if (!await fs.pathExists(this.pjsonPath)) {
await fs.outputJSON(this.pjsonPath, {private: true, 'cli-engine': {schema: 1}}, {spaces: 2})
}
}

private userPluginPath(name: string): string {
return path.join(this.userPluginsDir, 'node_modules', name)
}
private get userPluginsDir() {
return path.join(this.config.dataDir, 'plugins')
}
private get pjsonPath() {
return path.join(this.userPluginsDir, 'package.json')
}
}
94 changes: 94 additions & 0 deletions src/yarn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {IConfig} from '@dxcli/config'
import * as fs from 'fs-extra'
import * as path from 'path'

const debug = require('debug')('cli:yarn')

export default class Yarn {
config: IConfig
cwd: string

constructor({config, cwd}: { config: IConfig; cwd: string }) {
this.config = config
this.cwd = cwd
}

get bin(): string {
return require.resolve('yarn/bin/yarn.js')
}

fork(modulePath: string, args: string[] = [], options: any = {}): Promise<void> {
return new Promise((resolve, reject) => {
const {fork} = require('child_process')
let forked = fork(modulePath, args, options)

forked.on('error', reject)
forked.on('exit', (code: number) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`yarn ${args.join(' ')} exited with code ${code}`))
}
})

// Fix windows bug with node-gyp hanging for input forever
if (this.config.windows) {
forked.stdin.write('\n')
}
})
}

async exec(args: string[] = []): Promise<void> {
if (args.length !== 0) await this.checkForYarnLock()
if (args[0] !== 'run') {
const cacheDir = path.join(this.config.cacheDir, 'yarn')
args = [
...args,
'--non-interactive',
`--mutex=file:${path.join(this.cwd, 'yarn.lock')}`,
`--preferred-cache-folder=${cacheDir}`,
...this.proxyArgs(),
]
if (this.config.npmRegistry) {
args.push(`--registry=${this.config.npmRegistry}`)
}
}

const npmRunPath = require('npm-run-path')
let options = {
cwd: this.cwd,
stdio: [0, 1, 2, 'ipc'],
env: npmRunPath.env({cwd: this.cwd, env: process.env}),
}

debug(`${this.cwd}: ${this.bin} ${args.join(' ')}`)
try {
await this.fork(this.bin, args, options)
debug('done')
} catch (err) {
// TODO: https://github.com/yarnpkg/yarn/issues/2191
let networkConcurrency = '--network-concurrency=1'
if (err.message.includes('EAI_AGAIN') && !args.includes(networkConcurrency)) {
debug('EAI_AGAIN')
return this.exec([...args, networkConcurrency])
}
throw err
}
}

async checkForYarnLock() {
// add yarn lockfile if it does not exist
if (this.cwd && !await fs.pathExists(path.join(this.cwd, 'yarn.lock'))) {
await this.exec()
}
}

proxyArgs(): string[] {
let args = []
let http = process.env.http_proxy || process.env.HTTP_PROXY
let https = process.env.https_proxy || process.env.HTTPS_PROXY
if (http) args.push(`--proxy=${http}`)
if (https) args.push(`--https-proxy=${https}`)
return args
}
}
Loading

0 comments on commit 237b211

Please sign in to comment.