Skip to content

Commit

Permalink
Add global --json flag (#180)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley authored Jun 9, 2021
1 parent 1886036 commit 23dc583
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 22 deletions.
78 changes: 61 additions & 17 deletions src/command.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {fileURLToPath} from 'url'

import {settings} from './settings'
import {format, inspect} from 'util'

import {cli} from 'cli-ux'
import {Config} from './config'
import * as Interfaces from './interfaces'
import * as Errors from './errors'
import {PrettyPrintableError} from './errors'
import * as Parser from './parser'
import * as Flags from './flags'

const pjson = require('../package.json')

Expand Down Expand Up @@ -56,9 +58,6 @@ export default abstract class Command {

static parse = true

/** A hash of flags for the command */
static flags?: Interfaces.FlagInput<any>

/** An order-dependent array of arguments for the command */
static args?: Interfaces.ArgInput

Expand All @@ -69,6 +68,8 @@ export default abstract class Command {

static parserOptions = {}

static disableJsonFlag: boolean | undefined

// eslint-disable-next-line valid-jsdoc
/**
* instantiate and run the command
Expand All @@ -89,6 +90,23 @@ export default abstract class Command {
return cmd._run(argv)
}

/** A hash of flags for the command */
private static _flags: Interfaces.FlagInput<any>

private static globalFlags = {
json: Flags.boolean({
description: 'format output as json',
}),
}

static get flags(): Interfaces.FlagInput<any> {
return this._flags
}

static set flags(flags: Interfaces.FlagInput<any>) {
this._flags = this.disableJsonFlag || settings.disableJsonFlag ? flags : Object.assign({}, Command.globalFlags, flags)
}

id: string | undefined

protected debug: (...args: any[]) => void
Expand All @@ -108,18 +126,23 @@ export default abstract class Command {

async _run<T>(): Promise<T | undefined> {
let err: Error | undefined
let result
try {
// remove redirected env var to allow subsessions to run autoupdated client
delete process.env[this.config.scopedEnvVarKey('REDIRECTED')]

await this.init()
return await this.run()
result = await this.run()
} catch (error) {
err = error
await this.catch(error)
} finally {
await this.finally(err)
}

if (result && this.jsonEnabled()) {
cli.styledJSON(this.toSuccessJson<T>(result))
}
return result
}

exit(code = 0) {
Expand All @@ -139,9 +162,15 @@ export default abstract class Command {
}

log(message = '', ...args: any[]) {
// tslint:disable-next-line strict-type-predicates
message = typeof message === 'string' ? message : inspect(message)
process.stdout.write(format(message, ...args) + '\n')
if (!this.jsonEnabled()) {
// tslint:disable-next-line strict-type-predicates
message = typeof message === 'string' ? message : inspect(message)
process.stdout.write(format(message, ...args) + '\n')
}
}

public jsonEnabled(): boolean {
return this.argv.includes('--json')
}

/**
Expand All @@ -164,17 +193,24 @@ export default abstract class Command {

protected async parse<F, A extends { [name: string]: any }>(options?: Interfaces.Input<F>, argv = this.argv): Promise<Interfaces.ParserOutput<F, A>> {
if (!options) options = this.constructor as any
return Parser.parse(argv, {context: this, ...options})
const opts = {context: this, ...options}
// the spread operator doesn't work with getters so we have to manually add it here
opts.flags = options?.flags
return Parser.parse(argv, opts)
}

protected async catch(err: any): Promise<any> {
if (!err.message) throw err
try {
const {cli} = require('cli-ux')
const chalk = require('chalk') // eslint-disable-line node/no-extraneous-require
cli.action.stop(chalk.bold.red('!'))
} catch {}
throw err
if (this.jsonEnabled()) {
cli.styledJSON(this.toErrorJson(err))
} else {
if (!err.message) throw err
try {
const {cli} = require('cli-ux')
const chalk = require('chalk') // eslint-disable-line node/no-extraneous-require
cli.action.stop(chalk.bold.red('!'))
} catch {}
throw err
}
}

protected async finally(_: Error | undefined): Promise<any> {
Expand All @@ -186,4 +222,12 @@ export default abstract class Command {
console.error(error)
}
}

protected toSuccessJson<T>(result: T): T {
return result
}

protected toErrorJson(err: any): any {
return {error: err}
}
}
7 changes: 4 additions & 3 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export class Config implements IConfig {
// eslint-disable-next-line no-useless-constructor
constructor(public options: Options) {}

static async load(opts: LoadOptions = (module.parent && module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) {
static async load(opts: LoadOptions = (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) {
// Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path.
if (typeof opts === 'string' && opts.startsWith('file://')) {
opts = fileURLToPath(opts)
Expand Down Expand Up @@ -256,7 +256,7 @@ export class Config implements IConfig {
debug('%s hook done', event)
}

async runCommand(id: string, argv: string[] = [], cachedCommand?: Command.Plugin) {
async runCommand<T = unknown>(id: string, argv: string[] = [], cachedCommand?: Command.Plugin): Promise<T> {
debug('runCommand %s %o', id, argv)
const c = cachedCommand || this.findCommand(id)
if (!c) {
Expand All @@ -265,8 +265,9 @@ export class Config implements IConfig {
}
const command = await c.load()
await this.runHook('prerun', {Command: command, argv})
const result = await command.run(argv, this)
const result = (await command.run(argv, this)) as T
await this.runHook('postrun', {Command: command, result: result, argv})
return result
}

scopedEnvVar(k: string) {
Expand Down
3 changes: 2 additions & 1 deletion src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ export interface Config {
readonly topics: Topic[];
readonly commandIDs: string[];

runCommand(id: string, argv?: string[]): Promise<void>;
runCommand<T = unknown>(id: string, argv?: string[]): Promise<T>;
runCommand<T = unknown>(id: string, argv?: string[], cachedCommand?: Command.Plugin): Promise<T>;
runHook<T extends Hooks, K extends Extract<keyof T, string>>(event: K, opts: T[K]): Promise<void>;
findCommand(id: string, opts: { must: true }): Command.Plugin;
findCommand(id: string, opts?: { must: boolean }): Command.Plugin | undefined;
Expand Down
5 changes: 4 additions & 1 deletion src/interfaces/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ export interface CLIParseErrorOptions {
export type OutputArgs<T extends ParserInput['args']> = { [P in keyof T]: any }
export type OutputFlags<T extends ParserInput['flags']> = { [P in keyof T]: any }
export type ParserOutput<TFlags extends OutputFlags<any>, TArgs extends OutputArgs<any>> = {
flags: TFlags;
// Add in global flags so that they show up in the types
// This is necessary because there's no easy way to optionally return
// the individual flags based on wether they're enabled or not
flags: TFlags & { json: boolean | undefined };
args: TArgs;
argv: string[];
raw: ParsingToken[];
Expand Down
4 changes: 4 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export type Settings = {
* NODE_ENV=development
*/
tsnodeEnabled?: boolean;
/**
* Disable the --json flag for all commands
*/
disableJsonFlag?: boolean;
};

// Set global.oclif to the new object if it wasn't set before
Expand Down
2 changes: 2 additions & 0 deletions test/help/fixtures/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export class AppsCreate extends Command {
static description = `Create an app
this only shows up in command help under DESCRIPTION`;

static disableJsonFlag = true;

static flags = {};

static args = [];
Expand Down
28 changes: 28 additions & 0 deletions test/help/format-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ OPTIONS
it force it force it force it force it force it
force it force it force it force it
--json format output as json
--ss newliney
newliney
newliney
Expand Down Expand Up @@ -126,6 +128,8 @@ OPTIONS
it force it force it force it force it force it
force it force it force it force it
--json format output as json
--ss newliney
newliney
newliney
Expand Down Expand Up @@ -177,6 +181,9 @@ OPTIONS
force it force it force it force it force it force it force it force
it force it force it force it force it force it force it
--json
format output as json
--ss
newliney
newliney
Expand Down Expand Up @@ -212,6 +219,7 @@ ARGUMENTS
OPTIONS
--force forces
--json format output as json
DESCRIPTION
these values are after and will show up in the command description
Expand All @@ -225,6 +233,8 @@ ALIASES
static id = 'apps:create'

static description = 'root part of the description\nThe <%= config.bin %> CLI has <%= command.id %>'

static disableJsonFlag = true
})
.it('renders template string from description', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE
$ oclif apps:create
Expand All @@ -238,6 +248,8 @@ DESCRIPTION
.commandHelp(class extends Command {
static id = 'apps:create'

static disableJsonFlag = true

static flags = {
myenum: flags.string({options: ['a', 'b', 'c']}),
}
Expand All @@ -258,6 +270,8 @@ OPTIONS
{name: 'arg3', description: 'arg3 desc'},
]

static disableJsonFlag = true

static flags = {
flag1: flags.string({default: '.'}),
flag2: flags.string({default: '.', description: 'flag2 desc'}),
Expand All @@ -280,6 +294,8 @@ OPTIONS
.commandHelp(class extends Command {
static id = 'apps:create'

static disableJsonFlag = true

static flags = {
opt: flags.boolean({allowNo: true}),
}
Expand All @@ -296,6 +312,8 @@ OPTIONS
.commandHelp(class extends Command {
static id = 'apps:create'

static disableJsonFlag = true

static args = [
{name: 'arg1', description: 'Show the options', options: ['option1', 'option2']},
]
Expand All @@ -313,6 +331,8 @@ ARGUMENTS
static id = 'apps:create'

static usage = '<%= config.bin %> <%= command.id %> usage'

static disableJsonFlag = true
})
.it('outputs usage with templates', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE
$ oclif oclif apps:create usage`))
Expand All @@ -332,6 +352,8 @@ ARGUMENTS
static id = 'apps:create'

static usage = undefined

static disableJsonFlag = true
})
.it('defaults usage when not specified', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE
$ oclif apps:create`))
Expand All @@ -341,6 +363,8 @@ ARGUMENTS
test
.commandHelp(class extends Command {
static examples = ['it handles a list of examples', 'more example text']

static disableJsonFlag = true
})
.it('outputs multiple examples', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE
$ oclif
Expand All @@ -352,6 +376,8 @@ EXAMPLES
test
.commandHelp(class extends Command {
static examples = ['it handles a single example']

static disableJsonFlag = true
})
.it('outputs a single example', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE
$ oclif
Expand All @@ -364,6 +390,8 @@ EXAMPLE
static id = 'oclif:command'

static examples = ['the bin is <%= config.bin %>', 'the command id is <%= command.id %>']

static disableJsonFlag = true
})
.it('outputs examples using templates', (ctx: any) => expect(ctx.commandHelp).to.equal(`USAGE
$ oclif oclif:command
Expand Down

0 comments on commit 23dc583

Please sign in to comment.