Skip to content

Commit

Permalink
feat: added minimist-based CLI arg + opt parsing
Browse files Browse the repository at this point in the history
This is used for parsing the arguments and options passed to workflow
step commands.
  • Loading branch information
thsig authored and edvald committed Jun 24, 2020
1 parent d9d903e commit b6e950b
Show file tree
Hide file tree
Showing 2 changed files with 204 additions and 0 deletions.
57 changes: 57 additions & 0 deletions garden-service/src/commands/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import chalk from "chalk"
import dedent = require("dedent")
import inquirer = require("inquirer")
import stripAnsi from "strip-ansi"
import { range } from "lodash"
import minimist from "minimist"

import { GlobalOptions } from "../cli/cli"
import { joi } from "../config/common"
Expand Down Expand Up @@ -495,3 +497,58 @@ export function describeParameters(args?: Parameters) {
help: stripAnsi(arg.help),
}))
}

export type ParamSpec = {
[key: string]: Parameter<string | string[] | number | boolean | undefined>
}

/**
* Parses the arguments and options for a command invocation using its command class' arguments
* and options specs.
*
* Returns args and opts ready to pass to that command's action method.
*
* @param args The arguments + options to the command (everything after the command name)
* @param argSpec The arguments spec for the command in question.
* @param optSpec The options spec for the command in question.
*/
export function parseCliArgs(args: string[], argSpec: ParamSpec, optSpec: ParamSpec): { args: any; opts: any } {
const parsed = minimist(args)
const argKeys = Object.keys(argSpec)
const parsedArgs = {}
for (const idx of range(argKeys.length)) {
// Commands expect unused arguments to be explicitly set to undefined.
parsedArgs[argKeys[idx]] = undefined
}
for (const idx of range(parsed._.length)) {
const argKey = argKeys[idx]
const argVal = parsed._[idx]
const spec = argSpec[argKey]
parsedArgs[argKey] = spec.coerce(spec.parseString(argVal))
}
const parsedOpts = {}
for (const optKey of Object.keys(optSpec)) {
const spec = optSpec[optKey]
let optVal = parsed[optKey]
if (Array.isArray(optVal)) {
optVal = optVal[0] // Use the first value if the option is used multiple times
}
// Need special handling for string-ish boolean values
optVal = optVal === "false" ? false : optVal
if (!optVal && optVal !== false) {
optVal = parsed[spec.alias] === "false" ? false : parsed[spec.alias]
}
if (optVal || optVal === false) {
if (optVal === true && spec.type !== "boolean") {
// minimist sets the value of options like --hot (with no value) to true, so we need
// to convert to a string here.
optVal = ""
}
parsedOpts[optKey] = spec.coerce(spec.parseString(optVal))
}
}
return {
args: parsedArgs,
opts: parsedOpts,
}
}
147 changes: 147 additions & 0 deletions garden-service/test/unit/src/cli/parseCliArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright (C) 2018-2020 Garden Technologies, Inc. <[email protected]>
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { expect } from "chai"
import { TestGarden, makeTestGardenA, withDefaultGlobalOpts } from "../../../helpers"
import { deployOpts, deployArgs, DeployCommand } from "../../../../src/commands/deploy"
import { parseCliArgs } from "../../../../src/commands/base"
import { LogEntry } from "../../../../src/logger/log-entry"
import { DeleteServiceCommand, deleteServiceArgs } from "../../../../src/commands/delete"
import { GetOutputsCommand } from "../../../../src/commands/get/get-outputs"
import { TestCommand, testArgs, testOpts } from "../../../../src/commands/test"
import { RunTaskCommand, runTaskArgs, runTaskOpts } from "../../../../src/commands/run/task"
import { RunTestCommand, runTestArgs, runTestOpts } from "../../../../src/commands/run/test"
import { publishArgs, publishOpts, PublishCommand } from "../../../../src/commands/publish"

describe("parseCliArgs", () => {
let garden: TestGarden
let log: LogEntry
let defaultActionParams: any

before(async () => {
garden = await makeTestGardenA()
log = garden.log
defaultActionParams = {
garden,
log,
headerLog: log,
footerLog: log,
}
})

it("correctly falls back to a blank string value for non-boolean options with blank values", () => {
const { args, opts } = parseCliArgs(
["service-a,service-b", "--hot-reload", "--force-build=true"],
deployArgs,
deployOpts
)
expect(args).to.eql({ services: ["service-a", "service-b"] })
expect(opts).to.eql({ "force-build": true, "hot-reload": undefined })
})

it("correctly handles blank arguments", () => {
const { args, opts } = parseCliArgs([], deployArgs, deployOpts)
expect(args).to.eql({ services: undefined })
expect(opts).to.eql({})
})

it("correctly handles option aliases", () => {
const { args, opts } = parseCliArgs(["-w", "--force-build=false"], deployArgs, deployOpts)
expect(args).to.eql({ services: undefined })
expect(opts).to.eql({ "watch": true, "force-build": false })
})

// Note: If an option alias appears before the option (e.g. -w before --watch),
// the option's value takes precedence over the alias' value (e.g. --watch=false
// takes precedence over -w).
it("uses value of first option when option is erroneously repeated", () => {
const { args, opts } = parseCliArgs(["--force-build=false", "--force-build=true"], deployArgs, deployOpts)
expect(args).to.eql({ services: undefined })
expect(opts).to.eql({ "force-build": false })
})

it("parses args and opts for a DeployCommand", async () => {
const cmd = new DeployCommand()

const { args, opts } = parseCliArgs(["service-a,service-b", "--force-build=true"], deployArgs, deployOpts)

await cmd.action({
...defaultActionParams,
args,
opts: withDefaultGlobalOpts(opts),
})

const { args: args2, opts: opts2 } = parseCliArgs(["service-a", "--hot=service-a"], deployArgs, deployOpts)

await cmd.action({
...defaultActionParams,
args: args2,
opts: withDefaultGlobalOpts(opts2),
})
})

it("parses args and opts for a DeleteServiceCommand", async () => {
const cmd = new DeleteServiceCommand()
const { args, opts } = parseCliArgs(["service-a"], deleteServiceArgs, {})
await cmd.action({
...defaultActionParams,
args,
opts: withDefaultGlobalOpts(opts),
})
})

it("parses args and opts for a GetOutputsCommand", async () => {
const cmd = new GetOutputsCommand()
const { args, opts } = parseCliArgs([], {}, {})
await cmd.action({
...defaultActionParams,
args,
opts: withDefaultGlobalOpts(opts),
})
})

it("parses args and opts for a TestCommand", async () => {
const cmd = new TestCommand()
const { args, opts } = parseCliArgs(["module-a,module-b", "-n unit"], testArgs, testOpts)
await cmd.action({
...defaultActionParams,
args,
opts: withDefaultGlobalOpts(opts),
})
})

it("parses args and opts for a RunTaskCommand", async () => {
const cmd = new RunTaskCommand()
const { args, opts } = parseCliArgs(["task-b"], runTaskArgs, runTaskOpts)
await cmd.action({
...defaultActionParams,
args,
opts: withDefaultGlobalOpts(opts),
})
})

it("parses args and opts for a RunTestCommand", async () => {
const cmd = new RunTestCommand()
const { args, opts } = parseCliArgs(["module-b", "unit", "--interactive"], runTestArgs, runTestOpts)
await cmd.action({
...defaultActionParams,
args,
opts: withDefaultGlobalOpts(opts),
})
})

it("parses args and opts for a PublishCommand", async () => {
const cmd = new PublishCommand()
const { args, opts } = parseCliArgs(["module-a,module-b", "--allow-dirty"], publishArgs, publishOpts)
await cmd.action({
...defaultActionParams,
args,
opts: withDefaultGlobalOpts(opts),
})
})
})

0 comments on commit b6e950b

Please sign in to comment.