Repository: oclif/core
Pull request #181 (2021-06-04 20:23:18)

Add `url` flag
This PR adds two new flag types:
- `url`

@W-9320561@


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b92303..66f4b8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
 
+### [0.5.13](https://github.com/oclif/core/compare/v0.5.12...v0.5.13) (2021-06-09)
+
 ### [0.5.12](https://github.com/oclif/core/compare/v0.5.11...v0.5.12) (2021-06-07)
 
 ### [0.5.11](https://github.com/oclif/core/compare/v0.5.10...v0.5.11) (2021-06-07)
diff --git a/package.json b/package.json
index 617fce9..5806862 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@oclif/core",
   "description": "base library for oclif CLIs",
-  "version": "0.5.12",
+  "version": "0.5.13",
   "author": "Jeff Dickey @jdxcode",
   "bugs": "https://github.com/oclif/core/issues",
   "dependencies": {
diff --git a/src/command.ts b/src/command.ts
index 5feb477..81406fd 100644
--- a/src/command.ts
+++ b/src/command.ts
@@ -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')
 
@@ -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
 
@@ -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
@@ -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
@@ -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) {
@@ -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')
   }
 
   /**
@@ -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> {
@@ -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}
+  }
 }
diff --git a/src/config/config.ts b/src/config/config.ts
index 1823d2b..e409358 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -8,7 +8,7 @@ import {format} from 'util'
 import {Options, Plugin as IPlugin} from '../interfaces/plugin'
 import {Config as IConfig, ArchTypes, PlatformTypes, LoadOptions} from '../interfaces/config'
 import {Command} from '../interfaces/command'
-import {Debug, mapValues} from './util'
+import {Debug} from './util'
 import {Hook} from '../interfaces/hooks'
 import {PJSON} from '../interfaces/pjson'
 import * as Plugin from './plugin'
@@ -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)
@@ -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) {
@@ -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) {
@@ -479,30 +480,23 @@ export class Config implements IConfig {
   }
 }
 
-export function toCached(c: Command.Class, plugin?: IPlugin): Command {
-  return {
-    id: c.id,
-    description: c.description,
-    usage: c.usage,
-    pluginName: plugin && plugin.name,
-    pluginType: plugin && plugin.type,
-    hidden: c.hidden,
-    aliases: c.aliases || [],
-    examples: c.examples || (c as any).example,
-    flags: mapValues(c.flags || {}, (flag, name) => {
-      if (flag.type === 'boolean') {
-        return {
-          name,
-          type: flag.type,
-          char: flag.char,
-          description: flag.description,
-          hidden: flag.hidden,
-          required: flag.required,
-          helpLabel: flag.helpLabel,
-          allowNo: flag.allowNo,
-        }
+export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Command> {
+  const flags = {} as {[k: string]: Command.Flag}
+
+  for (const [name, flag] of Object.entries(c.flags || {})) {
+    if (flag.type === 'boolean') {
+      flags[name] = {
+        name,
+        type: flag.type,
+        char: flag.char,
+        description: flag.description,
+        hidden: flag.hidden,
+        required: flag.required,
+        helpLabel: flag.helpLabel,
+        allowNo: flag.allowNo,
       }
-      return {
+    } else {
+      flags[name] = {
         name,
         type: flag.type,
         char: flag.char,
@@ -512,16 +506,33 @@ export function toCached(c: Command.Class, plugin?: IPlugin): Command {
         helpLabel: flag.helpLabel,
         helpValue: flag.helpValue,
         options: flag.options,
-        default: typeof flag.default === 'function' ? flag.default({options: {}, flags: {}}) : flag.default,
+        // eslint-disable-next-line no-await-in-loop
+        default: typeof flag.default === 'function' ? await flag.default({options: {}, flags: {}}) : flag.default,
       }
-    }) as {[k: string]: Command.Flag},
-    args: c.args ? c.args.map(a => ({
-      name: a.name,
-      description: a.description,
-      required: a.required,
-      options: a.options,
-      default: typeof a.default === 'function' ? a.default({}) : a.default,
-      hidden: a.hidden,
-    })) : [],
+    }
+  }
+
+  const argsPromise = (c.args || []).map(async a => ({
+    name: a.name,
+    description: a.description,
+    required: a.required,
+    options: a.options,
+    default: typeof a.default === 'function' ? await a.default({}) : a.default,
+    hidden: a.hidden,
+  }))
+
+  const args = await Promise.all(argsPromise)
+
+  return {
+    id: c.id,
+    description: c.description,
+    usage: c.usage,
+    pluginName: plugin && plugin.name,
+    pluginType: plugin && plugin.type,
+    hidden: c.hidden,
+    aliases: c.aliases || [],
+    examples: c.examples || (c as any).example,
+    flags,
+    args,
   }
 }
diff --git a/src/config/plugin.ts b/src/config/plugin.ts
index 429d01b..b4c38a4 100644
--- a/src/config/plugin.ts
+++ b/src/config/plugin.ts
@@ -232,7 +232,7 @@ export class Plugin implements IPlugin {
       // eslint-disable-next-line array-callback-return
       commands: (await Promise.all(this.commandIDs.map(async id => {
         try {
-          return [id, toCached(await this.findCommand(id, {must: true}), this)]
+          return [id, await toCached(await this.findCommand(id, {must: true}), this)]
         } catch (error) {
           const scope = 'toCached'
           if (Boolean(errorOnManifestCreate) === false) this.warn(error, scope)
diff --git a/src/flags.ts b/src/flags.ts
index 6983a42..b72198b 100644
--- a/src/flags.ts
+++ b/src/flags.ts
@@ -26,7 +26,7 @@ export {_enum as enum}
 
 const stringFlag = build({})
 export {stringFlag as string}
-export {boolean, integer} from './parser'
+export {boolean, integer, url} from './parser'
 
 export const version = (opts: Partial<BooleanFlag<boolean>> = {}) => {
   return Parser.flags.boolean({
diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts
index 9e33b26..e5d0099 100644
--- a/src/interfaces/config.ts
+++ b/src/interfaces/config.ts
@@ -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;
diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts
index db8b3a9..d50ddb3 100644
--- a/src/interfaces/parser.ts
+++ b/src/interfaces/parser.ts
@@ -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[];
diff --git a/src/parser/flags.ts b/src/parser/flags.ts
index 75758bc..c83b998 100644
--- a/src/parser/flags.ts
+++ b/src/parser/flags.ts
@@ -1,5 +1,7 @@
 // tslint:disable interface-over-type-literal
 
+import {URL} from 'url'
+
 import {Definition, OptionFlag, BooleanFlag} from '../interfaces'
 
 export function build<T>(
@@ -40,6 +42,20 @@ export const integer = build({
   },
 })
 
+/**
+ * Initializes a string as a URL. Throws an error
+ * if the string is not a valid URL.
+ */
+export const url = build({
+  parse: async input => {
+    try {
+      return new URL(input)
+    } catch {
+      throw new Error(`Expected a valid url but received: ${input}`)
+    }
+  },
+})
+
 export function option<T>(
   options: {parse: OptionFlag<T>['parse']} & Partial<OptionFlag<T>>,
 ) {
diff --git a/src/parser/index.ts b/src/parser/index.ts
index 2850f49..b63a924 100644
--- a/src/parser/index.ts
+++ b/src/parser/index.ts
@@ -33,6 +33,6 @@ export async function parse<TFlags, TArgs extends { [name: string]: string }>(ar
   return output as any
 }
 
-const boolean = flags.boolean
-const integer = flags.integer
-export {boolean, integer}
+const {boolean, integer, url} = flags
+
+export {boolean, integer, url}
diff --git a/src/parser/parse.ts b/src/parser/parse.ts
index 71c359d..08a3115 100644
--- a/src/parser/parse.ts
+++ b/src/parser/parse.ts
@@ -198,12 +198,13 @@ export class Parser<T extends ParserInput, TFlags extends OutputFlags<T['flags']
       }
       if (!(k in flags) && flag.default !== undefined) {
         this.metaData.flags[k] = {setFromDefault: true}
-        if (typeof flag.default === 'function') {
+        // eslint-disable-next-line no-await-in-loop
+        const defaultValue = (typeof flag.default === 'function' ? await flag.default({options: flag, flags, ...this.context}) : flag.default) as string
+        const parsedValue = flag.type === 'option' && flag.parse ?
           // eslint-disable-next-line no-await-in-loop
-          flags[k] = await flag.default({options: flag, flags, ...this.context})
-        } else {
-          flags[k] = flag.default
-        }
+          await flag.parse(defaultValue, this.context) :
+          defaultValue
+        flags[k] = parsedValue
       }
     }
     return flags
diff --git a/src/settings.ts b/src/settings.ts
index 8f098c1..236b2d5 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -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


Test patch:
diff --git a/test/help/fixtures/fixtures.ts b/test/help/fixtures/fixtures.ts
index cc5a1b8..f76160a 100644
--- a/test/help/fixtures/fixtures.ts
+++ b/test/help/fixtures/fixtures.ts
@@ -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 = [];
diff --git a/test/help/format-command.test.ts b/test/help/format-command.test.ts
index 98febb6..5bc5d4a 100644
--- a/test/help/format-command.test.ts
+++ b/test/help/format-command.test.ts
@@ -24,8 +24,8 @@ const test = base
 .loadConfig()
 .add('help', ctx => new TestHelp(ctx.config as any))
 .register('commandHelp', (command?: any) => ({
-  run(ctx: {help: TestHelp; commandHelp: string; expectation: string}) {
-    const cached = toCached(command!, {} as any)
+  async run(ctx: {help: TestHelp; commandHelp: string; expectation: string}) {
+    const cached = await toCached(command!, {} as any)
     const help = ctx.help.formatCommand(cached)
     if (process.env.TEST_OUTPUT === '1') {
       console.log(help)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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']}),
         }
@@ -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'}),
@@ -280,6 +294,8 @@ OPTIONS
     .commandHelp(class extends Command {
         static id = 'apps:create'
 
+        static disableJsonFlag = true
+
         static flags = {
           opt: flags.boolean({allowNo: true}),
         }
@@ -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']},
         ]
@@ -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`))
@@ -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`))
@@ -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
@@ -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
@@ -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
diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts
index fc14374..559a2d5 100644
--- a/test/parser/parse.test.ts
+++ b/test/parser/parse.test.ts
@@ -641,6 +641,28 @@ See more help with --help`)
     })
   })
 
+  describe('url flag', () => {
+    it('accepts valid url', async () => {
+      const out = await parse(['--foo', 'https://example.com'], {
+        flags: {foo: flags.url()},
+      })
+      expect(out.flags.foo).to.be.instanceOf(URL)
+      expect(out.flags.foo?.href).to.equal('https://example.com/')
+    })
+
+    it('fails when invalid', async () => {
+      let message = ''
+      try {
+        await parse(['--foo', 'example'], {
+          flags: {foo: flags.url()},
+        })
+      } catch (error) {
+        message = error.message
+      }
+      expect(message).to.equal('Expected a valid url but received: example')
+    })
+  })
+
   describe('arg options', () => {
     it('accepts valid option', async () => {
       const out = await parse(['myotheropt'], {