diff --git a/docs/reference/commands.md b/docs/reference/commands.md index e22c16f5a9..15474ee9d1 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -20,7 +20,7 @@ The following option flags can be used with any of the CLI commands: | Argument | Alias | Type | Description | | -------- | ----- | ---- | ----------- | - | `--root` | `-r` | string | Override project root directory (defaults to working directory). + | `--root` | `-r` | path | Override project root directory (defaults to working directory). Can be absolute or relative to current directory. | `--silent` | `-s` | boolean | Suppress log output. Same as setting --logger-type=quiet. | `--env` | `-e` | string | The environment (and optionally namespace) to work against. | `--logger-type` | | `quiet` `basic` `fancy` `fullscreen` `json` | Set logger type. fancy updates log lines in-place when their status changes (e.g. when tasks complete), basic appends a new log line when a log line's status changes, json same as basic, but renders log lines as JSON, quiet suppresses all log output, same as --silent. @@ -30,6 +30,8 @@ The following option flags can be used with any of the CLI commands: | `--yes` | `-y` | boolean | Automatically approve any yes/no prompts during execution. | `--force-refresh` | | boolean | Force refresh of any caches, e.g. cached provider statuses. | `--var` | | array:string | Set a specific variable value, using the format <key>=<value>, e.g. `--var some-key=custom-value`. This will override any value set in your project configuration. You can specify multiple variables by separating with a comma, e.g. `--var key-a=foo,key-b="value with quotes"`. + | `--version` | `-v` | boolean | Show the current CLI version. + | `--help` | `-h` | boolean | Show help ### garden build @@ -63,7 +65,7 @@ Examples: | Argument | Alias | Type | Description | | -------- | ----- | ---- | ----------- | - | `--force` | | boolean | Force rebuild of module(s). + | `--force` | `-f` | boolean | Force rebuild of module(s). | `--watch` | `-w` | boolean | Watch for changes in module(s) and auto-build. #### Outputs diff --git a/garden-service/bin/garden b/garden-service/bin/garden index 96dd9492b7..d1f6c3242f 100755 --- a/garden-service/bin/garden +++ b/garden-service/bin/garden @@ -4,4 +4,5 @@ require("source-map-support").install() const cli = require("../build/src/cli/cli") -cli.run() +// tslint:disable-next-line: no-floating-promises +cli.runCli() diff --git a/garden-service/bin/garden-debug b/garden-service/bin/garden-debug index 06661d6de0..9c2a9ecba8 100755 --- a/garden-service/bin/garden-debug +++ b/garden-service/bin/garden-debug @@ -4,4 +4,5 @@ require("source-map-support").install() const cli = require("../build/src/cli/cli") -cli.run() +// tslint:disable-next-line: no-floating-promises +cli.runCli() diff --git a/garden-service/package-lock.json b/garden-service/package-lock.json index b600097bd0..626155d1b2 100644 --- a/garden-service/package-lock.json +++ b/garden-service/package-lock.json @@ -12815,11 +12815,6 @@ "es6-symbol": "^3.1.1" } }, - "sywac": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sywac/-/sywac-1.3.0.tgz", - "integrity": "sha512-LDt2stNTp4bVPMgd70Jj9PWrSa4batl+bv+Ea5NLNGT7ufc4oQPtRfQ73wbddNV6RilaPqnEt6y1Wkm5FVTNEg==" - }, "tar": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tar/-/tar-6.0.2.tgz", diff --git a/garden-service/package.json b/garden-service/package.json index 448ab42b81..b396aac1d1 100644 --- a/garden-service/package.json +++ b/garden-service/package.json @@ -117,7 +117,6 @@ "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "supertest": "^4.0.2", - "sywac": "^1.3.0", "tar": "^6.0.2", "terminal-link": "^2.1.1", "titleize": "^2.1.0", diff --git a/garden-service/src/analytics/analytics.ts b/garden-service/src/analytics/analytics.ts index df769216c5..4ee925b210 100644 --- a/garden-service/src/analytics/analytics.ts +++ b/garden-service/src/analytics/analytics.ts @@ -20,6 +20,7 @@ import { AnalyticsType } from "./analytics-types" import dedent from "dedent" import { getGitHubUrl } from "../docs/common" import { InternalError } from "../exceptions" +import { Profile } from "../util/profiling" const API_KEY = process.env.ANALYTICS_DEV ? SEGMENT_DEV_API_KEY : SEGMENT_PROD_API_KEY @@ -115,6 +116,7 @@ export interface SegmentEvent { * @export * @class AnalyticsHandler */ +@Profile() export class AnalyticsHandler { private static instance?: AnalyticsHandler private segment: any @@ -443,6 +445,10 @@ export class AnalyticsHandler { * @memberof AnalyticsHandler */ async flush() { + if (!this.analyticsEnabled()) { + return + } + // This is to handle an edge case where Segment flushes the events (e.g. at the interval) and // Garden exits at roughly the same time. When that happens, `segment.flush()` will return immediately since // the event queue is already empty. However, the network request might not have fired and the events are diff --git a/garden-service/src/cli/cli.ts b/garden-service/src/cli/cli.ts index 8b3d6a7667..c92bf2e247 100644 --- a/garden-service/src/cli/cli.ts +++ b/garden-service/src/cli/cli.ts @@ -7,48 +7,28 @@ */ import dotenv = require("dotenv") -import chalk from "chalk" -import sywac from "sywac" -import { intersection, merge, sortBy } from "lodash" +import { intersection, sortBy } from "lodash" import { resolve, join } from "path" -import { coreCommands } from "../commands/commands" -import { DeepPrimitiveMap } from "../config/common" -import { shutdown, sleep, getPackageVersion, uuidv4, safeDumpYaml } from "../util/util" -import { deline } from "../util/string" -import { - BooleanParameter, - ChoicesParameter, - Command, - CommandResult, - EnvironmentOption, - Parameter, - StringParameter, - StringsParameter, -} from "../commands/base" -import { GardenError, PluginError, toGardenError } from "../exceptions" +import { getAllCommands } from "../commands/commands" +import { shutdown, sleep, getPackageVersion, uuidv4 } from "../util/util" +import { Command, CommandResult, CommandGroup } from "../commands/base" +import { GardenError, PluginError, toGardenError, GardenBaseError } from "../exceptions" import { Garden, GardenOpts, DummyGarden } from "../garden" -import { getLogger, Logger, LoggerType, LOGGER_TYPES, getWriterInstance } from "../logger/logger" +import { getLogger, Logger, LoggerType, getWriterInstance, parseLogLevel } from "../logger/logger" import { LogLevel } from "../logger/log-node" import { BasicTerminalWriter } from "../logger/writers/basic-terminal-writer" import { FileWriter, FileWriterConfig } from "../logger/writers/file-writer" import { - envSupportsEmoji, - failOnInvalidOptions, - negateConflictingParams, - filterByKeys, - getArgSynopsis, - getKeys, - getOptionSynopsis, - prepareArgConfig, - prepareOptionConfig, - styleConfig, - getLogLevelChoices, - parseLogLevel, - helpTextMaxWidth, + cliStyles, checkForUpdates, checkForStaticDir, + renderCommands, + processCliArgs, + pickCommand, + parseCliArgs, } from "./helpers" +import { Parameters, globalOptions, OUTPUT_RENDERERS, GlobalOptions, ParameterValues } from "./params" import { defaultEnvironments, ProjectConfig, defaultNamespace } from "../config/project" import { ERROR_LOG_FILENAME, @@ -57,7 +37,6 @@ import { LOGS_DIR_NAME, gardenEnv, } from "../constants" -import stringify = require("json-stringify-safe") import { generateBasicDebugInfoReport } from "../commands/get/get-debug-info" import { AnalyticsHandler } from "../analytics/analytics" import { defaultDotIgnoreFiles } from "../util/fs" @@ -65,17 +44,6 @@ import { renderError } from "../logger/renderers" import { getDefaultProfiler } from "../util/profiling" import { BufferedEventStream } from "../enterprise/buffered-event-stream" -const OUTPUT_RENDERERS = { - json: (data: DeepPrimitiveMap) => { - return stringify(data, null, 2) - }, - yaml: (data: DeepPrimitiveMap) => { - return safeDumpYaml(data, { noRefs: true }) - }, -} - -const GLOBAL_OPTIONS_GROUP_NAME = "Global options" - export async function makeDummyGarden(root: string, gardenOpts: GardenOpts = {}) { const environments = gardenOpts.environmentName ? [{ name: gardenOpts.environmentName, defaultNamespace, variables: {} }] @@ -97,126 +65,45 @@ export async function makeDummyGarden(root: string, gardenOpts: GardenOpts = {}) return DummyGarden.factory(root, { ...gardenOpts, noEnterprise: true }) } -// The help text for these commands is only displayed when calling `garden options`. -// However, we can't include them with the global options since that causes the CLI -// to exit with code 1 when they're called. -export const HIDDEN_OPTIONS = { - version: new StringParameter({ - alias: "v", - help: "Show the current CLI version.", - }), - help: new StringParameter({ - alias: "h", - help: "Show help", - }), -} - -export const GLOBAL_OPTIONS = { - "root": new StringParameter({ - alias: "r", - help: "Override project root directory (defaults to working directory).", - defaultValue: process.cwd(), - }), - "silent": new BooleanParameter({ - alias: "s", - help: "Suppress log output. Same as setting --logger-type=quiet.", - defaultValue: false, - }), - "env": new EnvironmentOption(), - "logger-type": new ChoicesParameter({ - choices: [...LOGGER_TYPES], - help: deline` - Set logger type. - ${chalk.bold("fancy")} updates log lines in-place when their status changes (e.g. when tasks complete), - ${chalk.bold("basic")} appends a new log line when a log line's status changes, - ${chalk.bold("json")} same as basic, but renders log lines as JSON, - ${chalk.bold("quiet")} suppresses all log output, same as --silent. - `, - }), - "log-level": new ChoicesParameter({ - alias: "l", - choices: getLogLevelChoices(), - help: deline` - Set logger level. Values can be either string or numeric and are prioritized from 0 to 5 - (highest to lowest) as follows: error: 0, warn: 1, info: 2, verbose: 3, debug: 4, silly: 5.`, - hints: "[choice] [default: info] [error || 0, warn || 1, info || 2, verbose || 3, debug || 4, silly || 5]", - defaultValue: LogLevel[LogLevel.info], - }), - "output": new ChoicesParameter({ - alias: "o", - choices: Object.keys(OUTPUT_RENDERERS), - help: "Output command result in specified format (note: disables progress logging and interactive functionality).", - }), - "emoji": new BooleanParameter({ - help: "Enable emoji in output (defaults to true if the environment supports it).", - defaultValue: envSupportsEmoji(), - }), - "yes": new BooleanParameter({ - alias: "y", - help: "Automatically approve any yes/no prompts during execution.", - defaultValue: false, - }), - "force-refresh": new BooleanParameter({ - help: "Force refresh of any caches, e.g. cached provider statuses.", - defaultValue: false, - }), - "var": new StringsParameter({ - help: - 'Set a specific variable value, using the format =, e.g. `--var some-key=custom-value`. This will override any value set in your project configuration. You can specify multiple variables by separating with a comma, e.g. `--var key-a=foo,key-b="value with quotes"`.', - }), -} - -export type GlobalOptions = typeof GLOBAL_OPTIONS - function initLogger({ level, loggerType, emoji }: { level: LogLevel; loggerType: LoggerType; emoji: boolean }) { const writer = getWriterInstance(loggerType, level) const writers = writer ? [writer] : undefined return Logger.initialize({ level, writers, useEmoji: emoji }) } -export interface ParseResults { +export interface RunOutput { argv: any code: number errors: (GardenError | Error)[] result: any -} - -interface SywacParseResults extends ParseResults { - output: string - details: { logger: Logger; result?: CommandResult; analytics?: AnalyticsHandler } + // Mainly used for testing + consoleOutput?: string } export class GardenCli { - private program: any private commands: { [key: string]: Command } = {} private fileWritersInitialized: boolean = false constructor() { - const version = getPackageVersion() - this.program = sywac - .help("-h, --help", { - hidden: true, - }) - .version("-v, --version", { - version, - hidden: true, - }) - .showHelpByDefault() - .check((argv, _ctx) => { - // NOTE: Need to mutate argv! - merge(argv, negateConflictingParams(argv, GLOBAL_OPTIONS)) - }) - .outputSettings({ maxWidth: helpTextMaxWidth() }) - .style(styleConfig) + const commands = sortBy(getAllCommands(), (c) => c.name) + commands.forEach((command) => this.addCommand(command)) + } + + renderHelp() { + const commands = Object.values(this.commands) + .sort() + .filter((cmd) => cmd.getPath().length === 1) - const commands = sortBy(coreCommands, (c) => c.name) - const globalOptions = Object.entries(GLOBAL_OPTIONS) + return ` +${cliStyles.heading("USAGE")} + garden ${cliStyles.commandPlaceholder()} ${cliStyles.optionsPlaceholder()} - commands.forEach((command) => this.addCommand(command, this.program)) - globalOptions.forEach(([key, opt]) => this.addGlobalOption(key, opt)) +${cliStyles.heading("COMMANDS")} +${renderCommands(commands)} + ` } - async initFileWriters(logger: Logger, root: string, gardenDirPath: string) { + private async initFileWriters(logger: Logger, root: string, gardenDirPath: string) { if (this.fileWritersInitialized) { return } @@ -241,15 +128,7 @@ export class GardenCli { this.fileWritersInitialized = true } - addGlobalOption(key: string, option: Parameter): void { - this.program.option(getOptionSynopsis(key, option), { - ...prepareOptionConfig(option), - group: GLOBAL_OPTIONS_GROUP_NAME, - hidden: true, - }) - } - - addCommand(command: Command, program): void { + addCommand(command: Command): void { const fullName = command.getFullName() if (this.commands[fullName]) { @@ -259,247 +138,258 @@ export class GardenCli { this.commands[fullName] = command - const { arguments: args = {}, options = {} } = command + const { options = {} } = command - const argKeys = getKeys(args) - const optKeys = getKeys(options) - const globalKeys = getKeys(GLOBAL_OPTIONS) + const optKeys = Object.keys(options) + const globalKeys = Object.keys(globalOptions) const dupKeys: string[] = intersection(optKeys, globalKeys) if (dupKeys.length > 0) { throw new PluginError(`Global option(s) ${dupKeys.join(" ")} cannot be redefined`, {}) } + } - const action = async (argv, cliContext) => { - // Sywac returns positional args and options in a single object which we separate into args and opts - // We include the "rest" parameter (`_`) in the arguments passed to the command handler - let rest = argv._ - - // sywac leaves the "--" argument (used to deliniate arguments that should be unparsed), so we strip it out here. - if (rest[0] === "--") { - rest = rest.slice(1) - } + async runCommand({ + command, + parsedArgs, + parsedOpts, + }: { + command: Command + parsedArgs: ParameterValues + parsedOpts: ParameterValues + }) { + const root = resolve(process.cwd(), parsedOpts.root) + const { + "logger-type": loggerTypeOpt, + "log-level": logLevel, + emoji, + "env": environmentName, + silent, + output, + "force-refresh": forceRefresh, + "var": cliVars, + } = parsedOpts + + // Parse command line --var input + const parsedCliVars = cliVars ? dotenv.parse(cliVars.join("\n")) : {} + + let loggerType = loggerTypeOpt || command.getLoggerType({ opts: parsedOpts, args: parsedArgs }) + + if (silent || output) { + loggerType = "quiet" + } - const parsedArgs = { _: rest, ...filterByKeys(argv, argKeys) } - const parsedOpts = filterByKeys(argv, optKeys.concat(globalKeys)) - const root = resolve(process.cwd(), parsedOpts.root) - const { - "logger-type": loggerTypeOpt, - "log-level": logLevel, - emoji, - "env": environmentName, - silent, - output, - "force-refresh": forceRefresh, - "var": cliVars, - } = parsedOpts - - // Parse command line --var input - const parsedCliVars = cliVars ? dotenv.parse(cliVars.join("\n")) : {} - - let loggerType = loggerTypeOpt || command.getLoggerType({ opts: parsedOpts, args: parsedArgs }) - - if (silent || output) { - loggerType = "quiet" - } + // Init logger + const level = parseLogLevel(logLevel) + const logger = initLogger({ level, loggerType, emoji }) - // Init logger - const level = parseLogLevel(logLevel) - const logger = initLogger({ level, loggerType, emoji }) - - // Currently we initialise empty placeholder entries and pass those to the - // framework as opposed to the logger itself. This is to give better control over where on - // the screen the logs are printed. - const headerLog = logger.placeholder() - const log = logger.placeholder() - const footerLog = logger.placeholder() - - // Init event & log streaming. - const sessionId = uuidv4() - const bufferedEventStream = new BufferedEventStream(log, sessionId) - - const contextOpts: GardenOpts = { - commandInfo: { - name: command.getFullName(), - args: parsedArgs, - opts: parsedOpts, - }, - environmentName, - log, - sessionId, - forceRefresh, - variables: parsedCliVars, - } + // Currently we initialise empty placeholder entries and pass those to the + // framework as opposed to the logger itself. This is to give better control over where on + // the screen the logs are printed. + const headerLog = logger.placeholder() + const log = logger.placeholder() + const footerLog = logger.placeholder() - let garden: Garden - let result: any + // Init event & log streaming. + const sessionId = uuidv4() + const bufferedEventStream = new BufferedEventStream(log, sessionId) - const { persistent } = await command.prepare({ - log, - headerLog, - footerLog, + const contextOpts: GardenOpts = { + commandInfo: { + name: command.getFullName(), args: parsedArgs, opts: parsedOpts, - }) + }, + environmentName, + log, + sessionId, + forceRefresh, + variables: parsedCliVars, + } - contextOpts.persistent = persistent - - do { - try { - if (command.noProject) { - garden = await makeDummyGarden(root, contextOpts) - } else { - garden = await Garden.factory(root, contextOpts) - } - - if (garden.enterpriseContext) { - log.silly(`Connecting Garden instance to BufferedEventStream`) - bufferedEventStream.connect({ - eventBus: garden.events, - enterpriseContext: garden.enterpriseContext, - environmentName: garden.environmentName, - namespace: garden.namespace, - }) - } else { - log.silly(`Skip connecting Garden instance to BufferedEventStream`) - } - - // Register log file writers. We need to do this after the Garden class is initialised because - // the file writers depend on the project root. - await this.initFileWriters(logger, garden.projectRoot, garden.gardenDirPath) - const analytics = await AnalyticsHandler.init(garden, log) - analytics.trackCommand(command.getFullName()) - - cliContext.details.analytics = analytics - - // tslint:disable-next-line: no-floating-promises - checkForUpdates(garden.globalConfigStore, headerLog) - - await checkForStaticDir() - - // Check if the command is protected and ask for confirmation to proceed if production flag is "true". - if (await command.isAllowedToRun(garden, log, parsedOpts)) { - // TODO: enforce that commands always output DeepPrimitiveMap - - result = await command.action({ - garden, - log, - footerLog, - headerLog, - args: parsedArgs, - opts: parsedOpts, - }) - } else { - // The command is protected and the user decided to not continue with the exectution. - log.setState("\nCommand aborted.") - result = {} - } - await garden.close() - } catch (err) { - // Generate a basic report in case Garden.factory(...) fails and command is "get debug-info". - // Other exceptions are handled within the implementation of "get debug-info". - if (command.name === "debug-info") { - // Use default Garden dir name as fallback since Garden class hasn't been initialised - await generateBasicDebugInfoReport(root, join(root, DEFAULT_GARDEN_DIR_NAME), log, parsedOpts.format) - } - throw err - } - } while (result.restartRequired) + let garden: Garden + let result: CommandResult + let analytics: AnalyticsHandler - await bufferedEventStream.close() + const { persistent } = await command.prepare({ + log, + headerLog, + footerLog, + args: parsedArgs, + opts: parsedOpts, + }) - // We attach the action result to cli context so that we can process it in the parse method - cliContext.details.result = result - } + contextOpts.persistent = persistent - // Command specific positional args and options are set inside the builder function - const setup = (parser) => { - const subCommands = command.getSubCommands() - subCommands.forEach((subCommand) => this.addCommand(subCommand, parser)) + do { + try { + if (command.noProject) { + garden = await makeDummyGarden(root, contextOpts) + } else { + garden = await Garden.factory(root, contextOpts) + } - argKeys.forEach((key) => parser.positional(getArgSynopsis(key, args[key]), prepareArgConfig(args[key]))) - optKeys.forEach((key) => parser.option(getOptionSynopsis(key, options[key]), prepareOptionConfig(options[key]))) + if (garden.enterpriseContext) { + log.silly(`Connecting Garden instance to BufferedEventStream`) + bufferedEventStream.connect({ + eventBus: garden.events, + enterpriseContext: garden.enterpriseContext, + environmentName: garden.environmentName, + namespace: garden.namespace, + }) + } else { + log.silly(`Skip connecting Garden instance to BufferedEventStream`) + } - // We only check for invalid flags for the last command since it might contain flags that - // the parent is unaware of, thus causing the check to fail for the parent - if (subCommands.length < 1) { - parser.check(failOnInvalidOptions) + // Register log file writers. We need to do this after the Garden class is initialised because + // the file writers depend on the project root. + await this.initFileWriters(logger, garden.projectRoot, garden.gardenDirPath) + analytics = await AnalyticsHandler.init(garden, log) + analytics.trackCommand(command.getFullName()) + + // tslint:disable-next-line: no-floating-promises + checkForUpdates(garden.globalConfigStore, headerLog) + + await checkForStaticDir() + + // Check if the command is protected and ask for confirmation to proceed if production flag is "true". + if (await command.isAllowedToRun(garden, log, parsedOpts)) { + // TODO: enforce that commands always output DeepPrimitiveMap + + result = await command.action({ + garden, + log, + footerLog, + headerLog, + args: parsedArgs, + opts: parsedOpts, + }) + } else { + // The command is protected and the user decided to not continue with the exectution. + log.setState("\nCommand aborted.") + result = {} + } + await garden.close() + } catch (err) { + // Generate a basic report in case Garden.factory(...) fails and command is "get debug-info". + // Other exceptions are handled within the implementation of "get debug-info". + if (command.name === "debug-info") { + // Use default Garden dir name as fallback since Garden class hasn't been initialised + await generateBasicDebugInfoReport(root, join(root, DEFAULT_GARDEN_DIR_NAME), log, parsedOpts.format) + } + throw err } - return parser - } + } while (result.restartRequired) - const commandConfig = { - setup, - aliases: command.alias, - desc: command.help, - hidden: command.hidden, - run: action, - } + await bufferedEventStream.close() - program.command(command.name, commandConfig) + return { result, analytics } } - async parse(args?: string[], exit = true): Promise { - const parseResult: SywacParseResults = await this.program.parse(args) - const { argv, details, errors, output: cliOutput } = parseResult - const { result: commandResult } = details - const { output } = argv - let { code } = parseResult + async run({ args, exitOnError }: { args: string[]; exitOnError: boolean }): Promise { + let argv = parseCliArgs({ stringArgs: args, cli: true }) + let logger: Logger + const errors: (GardenBaseError | Error)[] = [] // Note: Circumvents an issue where the process exits before the output is fully flushed. // Needed for output renderers and Winston (see: https://github.com/winstonjs/winston/issues/228) const waitForOutputFlush = () => sleep(100) - // Logger might not have been initialised if process exits early - try { - logger = getLogger() - } catch (_) { - logger = Logger.initialize({ - level: LogLevel.info, - writers: [new BasicTerminalWriter()], - }) + async function done(abortCode: number, consoleOutput: string, result: any = {}) { + if (exitOnError) { + logger && logger.stop() + // tslint:disable-next-line: no-console + console.log(consoleOutput) + await waitForOutputFlush() + process.exit(abortCode) + } else { + await waitForOutputFlush() + return { argv, code: abortCode, errors, result, consoleOutput } + } } - // Flushes the Analytics events queue in case there are some remaining events. - const { analytics } = details - if (analytics) { - await analytics.flush() + if (argv.v || argv.version || argv._[0] === "version") { + return done(0, getPackageVersion()) } - // --help or --version options were called so we log the cli output and exit - if (cliOutput && errors.length < 1) { - if (exit) { - logger.stop() - // tslint:disable-next-line: no-console - console.log(cliOutput) + const { command } = pickCommand(Object.values(this.commands), args) - // fix issue where sywac returns exit code 0 even when a command doesn't exist - if (!argv.h && !argv.v) { - code = 1 - } + if (!command) { + return done(0, this.renderHelp()) + } + + if (command instanceof CommandGroup) { + return done(0, command.renderHelp()) + } + + // Parse the arguments again with the Command set, to fully validate, and to ensure boolean options are + // handled correctly + argv = parseCliArgs({ stringArgs: args, command, cli: true }) + + // Slice command name from the positional args + argv._ = argv._.slice(command.getPath().length) - process.exit(code) + // handle -h/--help + if (argv.h || argv.help) { + if (command) { + // Show help for command + return done(0, command.renderHelp()) } else { - return { argv, code, errors, result: undefined } + // Show general help text + return done(0, this.renderHelp()) } } - const gardenErrors: GardenError[] = errors.map(toGardenError).concat((commandResult && commandResult.errors) || []) + let parsedArgs: ParameterValues + let parsedOpts: ParameterValues + + try { + const parseResults = processCliArgs({ parsedArgs: argv, command, cli: true }) + parsedArgs = parseResults.args + parsedOpts = parseResults.opts + } catch (err) { + errors.push(...(err.detail?.errors || []).map(toGardenError)) + return done(1, err.message + "\n" + command.renderHelp()) + } + + const runResults = await this.runCommand({ command, parsedArgs, parsedOpts }) + + const { result: commandResult, analytics } = runResults + errors.push(...(commandResult.errors || [])) + + // Flushes the Analytics events queue in case there are some remaining events. + if (analytics) { + await analytics.flush() + } + + const gardenErrors: GardenBaseError[] = errors + .map(toGardenError) + .concat((commandResult && commandResult.errors) || []) // --output option set - if (output) { - const renderer = OUTPUT_RENDERERS[output] + if (argv.output) { + const renderer = OUTPUT_RENDERERS[argv.output]! + if (gardenErrors.length > 0) { - // tslint:disable-next-line: no-console - console.error(renderer({ success: false, errors: gardenErrors })) + return done(1, renderer({ success: false, errors: gardenErrors }), commandResult?.result) } else { - // tslint:disable-next-line: no-console - console.log(renderer({ success: true, ...commandResult })) + return done(0, renderer({ success: true, ...commandResult }), commandResult?.result) } - await waitForOutputFlush() } + // Logger might not have been initialised if process exits early + try { + logger = getLogger() + } catch (_) { + logger = Logger.initialize({ + level: LogLevel.info, + writers: [new BasicTerminalWriter()], + }) + } + + let code = 0 + if (gardenErrors.length > 0) { for (const error of gardenErrors) { const entry = logger.error({ @@ -520,18 +410,22 @@ export class GardenCli { code = 1 } - logger.stop() - logger.cleanup() + if (exitOnError) { + logger.stop() + logger.cleanup() + } return { argv, code, errors, result: commandResult?.result } } } -export async function run(): Promise { - let code: number | undefined +export async function runCli(): Promise { + let code = 0 + try { const cli = new GardenCli() - const result = await cli.parse() + // Note: We slice off the binary/script name from argv. + const result = await cli.run({ args: process.argv.slice(2), exitOnError: true }) code = result.code } catch (err) { // tslint:disable-next-line: no-console diff --git a/garden-service/src/cli/helpers.ts b/garden-service/src/cli/helpers.ts index dd72f51697..3543a11e7d 100644 --- a/garden-service/src/cli/helpers.ts +++ b/garden-service/src/cli/helpers.ts @@ -9,60 +9,47 @@ import chalk from "chalk" import ci = require("ci-info") import { pathExists } from "fs-extra" -import { difference, flatten, range, reduce } from "lodash" -import moment = require("moment") +import { range, reduce, sortBy, max, isEqual, mapValues, pickBy } from "lodash" +import moment from "moment" import { platform, release } from "os" -import qs = require("qs") +import qs from "qs" +import stringWidth from "string-width" +import { maxBy, zip } from "lodash" -import { ChoicesParameter, ParameterValues, Parameter } from "../commands/base" -import { InternalError } from "../exceptions" -import { LogLevel } from "../logger/log-node" -import { getEnumKeys, getPackageVersion } from "../util/util" +import { ParameterValues, Parameter, Parameters } from "./params" +import { InternalError, ParameterError } from "../exceptions" +import { getPackageVersion } from "../util/util" import { LogEntry } from "../logger/log-entry" import { STATIC_DIR, VERSION_CHECK_URL, gardenEnv } from "../constants" import { printWarningMessage } from "../logger/util" import { GlobalConfigStore, globalConfigKeys } from "../config-store" import { got, GotResponse } from "../util/http" import { getUserId } from "../analytics/analytics" +import minimist = require("minimist") +import { renderTable, tablePresets, naturalList } from "../util/string" +import { globalOptions, GlobalOptions } from "./params" +import { Command, CommandGroup } from "../commands/base" -// Parameter types T which map between the Parameter class and the Sywac cli library. -// In case we add types that aren't supported natively by Sywac, see: http://sywac.io/docs/sync-config.html#custom -const VALID_PARAMETER_TYPES = ["boolean", "number", "choice", "string", "array:string", "path", "array:path"] - -export const styleConfig = { - usagePrefix: (str) => - ` -${chalk.bold(str.slice(0, 5).toUpperCase())} - ${chalk.italic(str.slice(7))}`, - usageCommandPlaceholder: (str) => chalk.blue(str), - usagePositionals: (str) => chalk.cyan(str), - usageArgsPlaceholder: (str) => chalk.cyan(str), - usageOptionsPlaceholder: (str) => chalk.yellow(str), - group: (str: string) => { - const cleaned = str.endsWith(":") ? str.slice(0, -1) : str - return chalk.bold(cleaned.toUpperCase()) - }, - flags: (str, _type) => { - const style = str.startsWith("-") ? chalk.green : chalk.cyan - return style(str) - }, - hints: (str) => chalk.gray(str), - groupError: (str) => chalk.red.bold(str), - flagsError: (str) => chalk.red.bold(str), - descError: (str) => chalk.yellow.bold(str), - hintsError: (str) => chalk.red(str), - messages: (str) => chalk.red.bold(str), // these are error messages -} - -// Helper functions -export const getKeys = (obj): string[] => Object.keys(obj || {}) -export const filterByKeys = (obj: any, keys: string[]): any => { - return keys.reduce((memo, key) => { - if (obj.hasOwnProperty(key)) { - memo[key] = obj[key] - } - return memo - }, {}) +export const cliStyles = { + heading: (str: string) => chalk.white.bold(str), + commandPlaceholder: () => chalk.blueBright(""), + optionsPlaceholder: () => chalk.yellowBright("[options]"), + hints: (str: string) => chalk.gray(str), + usagePositional: (key: string, required: boolean) => chalk.cyan(required ? `<${key}>` : `[${key}]`), + usageOption: (str: string) => chalk.cyan(`<${str}>`), + // group: (str: string) => { + // const cleaned = str.endsWith(":") ? str.slice(0, -1) : str + // return chalk.bold(cleaned.toUpperCase()) + // }, + // flags: (str) => { + // const style = str.startsWith("-") ? chalk.green : chalk.cyan + // return style(str) + // }, + // groupError: (str) => chalk.red.bold(str), + // flagsError: (str) => chalk.red.bold(str), + // descError: (str) => chalk.yellow.bold(str), + // hintsError: (str) => chalk.red(str), + // messages: (str) => chalk.red.bold(str), // these are error messages } /** @@ -70,14 +57,7 @@ export const filterByKeys = (obj: any, keys: string[]): any => { */ export function helpTextMaxWidth() { const cols = process.stdout.columns || 100 - return Math.min(100, cols) -} - -// Add platforms/terminals? -export function envSupportsEmoji() { - return ( - process.platform === "darwin" || process.env.TERM_PROGRAM === "Hyper" || process.env.TERM_PROGRAM === "HyperTerm" - ) + return Math.min(120, cols) } export type FalsifiedParams = { [key: string]: false } @@ -112,101 +92,6 @@ export function negateConflictingParams(argv, params: ParameterValues): Fal ) } -// Sywac specific transformers and helpers -export function getOptionSynopsis(key: string, { alias }: Parameter): string { - if (alias && alias.length > 1) { - return `--${alias}, --${key}` - } else if (alias) { - return `-${alias}, --${key}` - } else { - return `--${key}` - } -} - -export function getArgSynopsis(key: string, param: Parameter) { - return param.required ? `<${key}>` : `[${key}]` -} - -const getLogLevelNames = () => getEnumKeys(LogLevel) -const getNumericLogLevels = () => range(getLogLevelNames().length) -// Allow string or numeric log levels as CLI choices -export const getLogLevelChoices = () => [...getLogLevelNames(), ...getNumericLogLevels().map(String)] - -export function parseLogLevel(level: string): LogLevel { - let lvl: LogLevel - const parsed = parseInt(level, 10) - // Level is numeric - if (parsed || parsed === 0) { - lvl = parsed - // Level is a string - } else { - lvl = LogLevel[level] - } - if (!getNumericLogLevels().includes(lvl)) { - throw new InternalError( - `Unexpected log level, expected one of ${getLogLevelChoices().join(", ")}, got ${level}`, - {} - ) - } - return lvl -} - -export function prepareArgConfig(param: Parameter) { - return { - desc: param.help, - params: [prepareOptionConfig(param)], - } -} - -export interface SywacOptionConfig { - desc: string | string[] - type: string - defaultValue?: any - coerce?: Function - choices?: any[] - required?: boolean - hints?: string - strict: true - mustExist: true // For parameters of path type -} - -export function prepareOptionConfig(param: Parameter): SywacOptionConfig { - const { coerce, help: desc, hints, required, type } = param - - const defaultValue = param.cliDefault === undefined ? param.defaultValue : param.cliDefault - - if (!VALID_PARAMETER_TYPES.includes(type)) { - throw new InternalError(`Invalid parameter type for cli: ${type}`, { - type, - validParameterTypes: VALID_PARAMETER_TYPES, - }) - } - let config: SywacOptionConfig = { - coerce, - defaultValue, - desc, - required, - type, - hints, - strict: true, - mustExist: true, // For parameters of path type - } - if (type === "choice") { - config.type = "enum" - config.choices = (param).choices - } - return config -} - -export function failOnInvalidOptions(argv, ctx) { - const validOptions = flatten(ctx.details.types.filter((t) => t.datatype !== "command").map((t) => t.aliases)) - const receivedOptions = Object.keys(argv) - const invalid = difference(receivedOptions, validOptions) - if (invalid.length > 0) { - ctx.cliMessage(`Received invalid flag(s): ${invalid.join(", ")}`) - } -} - export async function checkForStaticDir() { if (!(await pathExists(STATIC_DIR))) { throw new InternalError( @@ -257,3 +142,246 @@ export async function checkForUpdates(config: GlobalConfigStore, logger: LogEntr logger.verbose(err) } } + +export function pickCommand(commands: (Command | CommandGroup)[], args: string[]) { + const command = sortBy(commands, (cmd) => -cmd.getPath().length).find((c) => { + for (const path of c.getPaths()) { + if (isEqual(path, args.slice(0, path.length))) { + return true + } + } + return false + }) + + const rest = command ? args.slice(command.getPath().length) : args + return { command, rest } +} + +export type ParamSpec = { + [key: string]: Parameter +} + +/** + * Parses the given CLI arguments using minimist. The result should be fed to `processCliArgs()` + * + * @param stringArgs Raw string arguments + * @param command The Command that the arguments are for, if any + */ +export function parseCliArgs({ stringArgs, command, cli }: { stringArgs: string[]; command?: Command; cli: boolean }) { + // Tell minimist which flags are to be treated explicitly as booleans and strings + const allOptions = { ...globalOptions, ...(command?.options || {}) } + const booleanKeys = Object.keys(pickBy(allOptions, (spec) => spec.type === "boolean")) + const stringKeys = Object.keys(pickBy(allOptions, (spec) => spec.type !== "boolean" && spec.type !== "number")) + + // Specify option flag aliases + const aliases = {} + const defaultValues = {} + + for (const [name, spec] of Object.entries(allOptions)) { + defaultValues[name] = spec.getDefaultValue(cli) + + if (spec.alias) { + aliases[name] = spec.alias + defaultValues[spec.alias] = defaultValues[name] + } + } + + return minimist(stringArgs, { + "--": true, + "boolean": booleanKeys, + "string": stringKeys, + "alias": aliases, + "default": defaultValues, + }) +} + +interface DefaultArgs { + // Contains anything after -- on the command line + _: string[] +} + +/** + * Takes parsed arguments (as returned by `parseCliArgs()`) and a Command, validates them, and + * returns args and opts ready to pass to that command's action method. + * + * @param parsedArgs Parsed arguments from `parseCliArgs()` + * @param command The Command that the arguments are for + * @param cli Set to false if `cliOnly` options should be ignored + */ +export function processCliArgs({ + parsedArgs, + command, + cli, +}: { + parsedArgs: minimist.ParsedArgs + command: Command + cli: boolean +}) { + const argSpec = command.arguments || {} + const argKeys = Object.keys(argSpec) + const processedArgs = { _: parsedArgs["--"] || [] } + + const errors: string[] = [] + + for (const idx of range(argKeys.length)) { + const argKey = argKeys[idx] + const argVal = parsedArgs._[idx] + const spec = argSpec[argKey] + + // Ensure all required positional arguments are present + if (!argVal) { + if (spec.required) { + errors.push(`Missing required argument ${chalk.white.bold(argKey)}`) + } + + // Commands expect unused arguments to be explicitly set to undefined. + processedArgs[argKeys[idx]] = undefined + } + } + + // TODO: support variadic arguments + for (const idx of range(parsedArgs._.length)) { + const argKey = argKeys[idx] + const argVal = parsedArgs._[idx] + const spec = argSpec[argKey] + + if (!spec) { + const expected = argKeys.length > 0 ? "only " + naturalList(argKeys.map((key) => chalk.white.bold(key))) : "none" + throw new ParameterError(`Unexpected positional argument "${argVal}" (expected ${expected})`, { + expectedKeys: argKeys, + extraValue: argVal, + }) + } + + try { + processedArgs[argKey] = spec.validate(spec.coerce(argVal)) + } catch (error) { + throw new ParameterError(`Invalid value for argument ${chalk.white.bold(argKey)}: ${error.message}`, { + error, + key: argKey, + value: argVal, + }) + } + } + + const optSpec = { ...globalOptions, ...(command.options || {}) } + const optsWithAliases: { [key: string]: Parameter } = {} + + // Apply default values + const processedOpts = mapValues(optSpec, (spec) => spec.getDefaultValue(cli)) + + for (const [name, spec] of Object.entries(optSpec)) { + optsWithAliases[name] = spec + if (spec.alias) { + optsWithAliases[spec.alias] = spec + } + } + + for (let [key, value] of Object.entries(parsedArgs)) { + if (key === "_" || key === "--") { + continue + } + + const spec = optsWithAliases[key] + const flagStr = chalk.white.bold(key.length === 1 ? "-" + key : "--" + key) + + if (!spec) { + errors.push(`Unrecognized option flag ${flagStr}`) + continue + } + + if (!optSpec[key]) { + // Don't double-process the aliases + continue + } + + if (!cli && spec.cliOnly) { + // ignore cliOnly flags if cli=false + continue + } + + if (Array.isArray(value)) { + // TODO: support multiple instances of an argument if it's an array type + value = value[value.length - 1] // Use the last value if the option is used multiple times + } + + if (value !== undefined) { + try { + value = spec.validate(spec.coerce(value)) + processedOpts[key] = value + } catch (err) { + errors.push(`Invalid value for option ${flagStr}: ${err.message}`) + } + } + } + + if (errors.length > 0) { + throw new ParameterError(chalk.red.bold(errors.join("\n")), { parsedArgs, processedArgs, processedOpts, errors }) + } + + return { + args: >processedArgs, + opts: & ParameterValues>processedOpts, + } +} + +export function renderCommands(commands: Command[]) { + if (commands.length === 0) { + return "\n" + } + + const sortedCommands = sortBy(commands, (cmd) => cmd.getFullName()) + + const rows = sortedCommands.map((command) => { + return [` ${chalk.cyan(command.getFullName())}`, command.help] + }) + + const maxCommandLength = max(rows.map((r) => r[0]!.length))! + + return renderTable(rows, { + ...tablePresets["no-borders"], + colWidths: [null, helpTextMaxWidth() - maxCommandLength - 2], + }) +} + +export function renderArguments(params: Parameters) { + return renderParameters(params, (name, param) => { + return " " + cliStyles.usagePositional(name, param.required) + }) +} + +export function renderOptions(params: Parameters) { + return renderParameters(params, (name, param) => { + const alias = param.alias ? `-${param.alias}, ` : "" + return chalk.green(` ${alias}--${name} `) + }) +} + +function renderParameters(params: Parameters, formatName: (name: string, param: Parameter) => string) { + const sortedParams = Object.keys(params).sort() + + const names = sortedParams.map((name) => formatName(name, params[name])) + + const helpTexts = sortedParams.map((name) => { + const param = params[name] + let out = param.help + let hints = "" + if (param.hints) { + hints = param.hints + } else { + hints = `\n[${param.type}]` + if (param.defaultValue) { + hints += ` [default: ${param.defaultValue}]` + } + } + return out + chalk.gray(hints) + }) + + const nameColWidth = stringWidth(maxBy(names, (n) => stringWidth(n)) || "") + 2 + const textColWidth = helpTextMaxWidth() - nameColWidth + + return renderTable(zip(names, helpTexts), { + ...tablePresets["no-borders"], + colWidths: [nameColWidth, textColWidth], + }) +} diff --git a/garden-service/src/cli/params.ts b/garden-service/src/cli/params.ts new file mode 100644 index 0000000000..d2ccbb2423 --- /dev/null +++ b/garden-service/src/cli/params.ts @@ -0,0 +1,334 @@ +/* + * Copyright (C) 2018-2020 Garden Technologies, Inc. + * + * 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 Joi = require("@hapi/joi") +import stripAnsi from "strip-ansi" +import stringify from "json-stringify-safe" + +import { joi, DeepPrimitiveMap } from "../config/common" +import { ParameterError } from "../exceptions" +import { parseEnvironment } from "../config/project" +import { LOGGER_TYPES, getLogLevelChoices, envSupportsEmoji } from "../logger/logger" +import { deline } from "../util/string" +import chalk = require("chalk") +import { LogLevel } from "../logger/log-node" +import { safeDumpYaml } from "../util/util" +import { resolve } from "path" + +export const OUTPUT_RENDERERS = { + json: (data: DeepPrimitiveMap) => { + return stringify(data, null, 2) + }, + yaml: (data: DeepPrimitiveMap) => { + return safeDumpYaml(data, { noRefs: true }) + }, +} + +export interface ParameterConstructor { + help: string + required?: boolean + alias?: string + defaultValue?: T + valueName?: string + hints?: string + overrides?: string[] + cliDefault?: T + cliOnly?: boolean +} + +export abstract class Parameter { + abstract type: string + abstract schema: Joi.Schema + + _valueType: T + + defaultValue: T | undefined + help: string + required: boolean + alias?: string + hints?: string + valueName: string + overrides: string[] + + readonly cliDefault: T | undefined // Optionally specify a separate default for CLI invocation + readonly cliOnly: boolean // If true, only expose in the CLI, and not in the HTTP/WS server. + + constructor({ + help, + required, + alias, + defaultValue, + valueName, + overrides, + hints, + cliDefault, + cliOnly, + }: ParameterConstructor) { + this.help = help + this.required = required || false + this.alias = alias + this.hints = hints + this.defaultValue = defaultValue + this.valueName = valueName || "_valueType" + this.overrides = overrides || [] + this.cliDefault = cliDefault + this.cliOnly = cliOnly || false + } + + // TODO: merge this and the parseString method? + validate(input: T): T | undefined { + // TODO: make sure the error message thrown is nice and readable + this.schema.validate(input) + return input + } + + coerce(input?: string): T { + return (input as unknown) as T + } + + getDefaultValue(cli: boolean) { + return cli && this.cliDefault !== undefined ? this.cliDefault : this.defaultValue + } + + async autoComplete(): Promise { + return [] + } +} + +export class StringParameter extends Parameter { + type = "string" + schema = joi.string() +} + +// Separating this from StringParameter for now because we can't set the output type based on the required flag +// FIXME: Maybe use a Required type to enforce presence, rather that an option flag? +export class StringOption extends Parameter { + type = "string" + schema = joi.string() +} + +export interface StringsConstructor extends ParameterConstructor { + delimiter?: string + variadic?: boolean +} + +export class StringsParameter extends Parameter { + type = "array:string" + schema = joi.array().items(joi.string()) + + delimiter: string | RegExp + variadic: boolean + + constructor(args: StringsConstructor) { + super(args) + + // The default delimiter splits on commas, ignoring commas between double quotes + this.delimiter = args.delimiter || /,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/ + this.variadic = !!args.variadic + } + + coerce(input?: string): string[] { + return input?.split(this.delimiter) || [] + } +} + +export class PathParameter extends Parameter { + type = "path" + schema = joi.string() + + coerce(input?: string): string { + return resolve(process.cwd(), input || ".") + } +} + +export class PathsParameter extends StringsParameter { + type = "array:path" + + coerce(input?: string): string[] { + const paths = super.coerce(input) + return paths.map((p) => resolve(process.cwd(), p)) + } +} + +export class IntegerParameter extends Parameter { + type = "number" + schema = joi.number().integer() + + coerce(input: string) { + const output = parseInt(input, 10) + if (isNaN(output)) { + throw new ParameterError(`Could not parse "${input}" as integer`, { + expectedType: "integer", + input, + }) + } + return output + } +} + +export interface ChoicesConstructor extends ParameterConstructor { + choices: string[] +} + +export class ChoicesParameter extends Parameter { + type = "choice" + choices: string[] + schema = joi.string() + + constructor(args: ChoicesConstructor) { + super(args) + + this.choices = args.choices + this.schema = joi.string().valid(...args.choices) + } + + coerce(input: string) { + if (this.choices.includes(input)) { + return input + } else { + throw new ParameterError( + `"${input}" is not a valid argument (should be any of ${this.choices.map((c) => `"${c}"`).join(", ")})`, + { + expectedType: `One of: ${this.choices.join(", ")}`, + input, + } + ) + } + } + + async autoComplete() { + return this.choices + } +} + +export class BooleanParameter extends Parameter { + type = "boolean" + schema = joi.boolean() + + constructor(args: ParameterConstructor) { + super(args) + this.defaultValue = args.defaultValue || false + } + + coerce(input: any) { + if (input === true || input === "true" || input === "1" || input === "yes" || input === 1) { + return true + } else if (input === false || input === "false" || input === "0" || input === "no" || input === 0) { + return false + } else { + throw new ParameterError(`Invalid boolean value: '${input}'`, { input }) + } + } +} + +export class EnvironmentOption extends StringParameter { + type = "string" + schema = joi.environment() + + constructor({ help = "The environment (and optionally namespace) to work against." } = {}) { + super({ + help, + required: false, + alias: "e", + }) + } + + validate(input: string | undefined) { + if (!input) { + return + } + // Validate the environment + parseEnvironment(input) + return input + } +} + +export type Parameters = { [key: string]: Parameter } +export type ParameterValues = { + [P in keyof T]: T[P]["_valueType"] +} + +export function describeParameters(args?: Parameters) { + if (!args) { + return + } + return Object.entries(args).map(([argName, arg]) => ({ + name: argName, + usageName: arg.required ? `<${argName}>` : `[${argName}]`, + ...arg, + help: stripAnsi(arg.help), + })) +} + +export const globalOptions = { + "root": new PathParameter({ + alias: "r", + help: + "Override project root directory (defaults to working directory). Can be absolute or relative to current directory.", + defaultValue: process.cwd(), + }), + "silent": new BooleanParameter({ + alias: "s", + help: "Suppress log output. Same as setting --logger-type=quiet.", + defaultValue: false, + cliOnly: true, + }), + "env": new EnvironmentOption(), + "logger-type": new ChoicesParameter({ + choices: [...LOGGER_TYPES], + help: deline` + Set logger type. + ${chalk.bold("fancy")} updates log lines in-place when their status changes (e.g. when tasks complete), + ${chalk.bold("basic")} appends a new log line when a log line's status changes, + ${chalk.bold("json")} same as basic, but renders log lines as JSON, + ${chalk.bold("quiet")} suppresses all log output, same as --silent. + `, + cliOnly: true, + }), + "log-level": new ChoicesParameter({ + alias: "l", + choices: getLogLevelChoices(), + help: deline` + Set logger level. Values can be either string or numeric and are prioritized from 0 to 5 + (highest to lowest) as follows: error: 0, warn: 1, info: 2, verbose: 3, debug: 4, silly: 5.`, + hints: "[choice] [default: info] [error || 0, warn || 1, info || 2, verbose || 3, debug || 4, silly || 5]", + defaultValue: LogLevel[LogLevel.info], + }), + "output": new ChoicesParameter({ + alias: "o", + choices: Object.keys(OUTPUT_RENDERERS), + help: "Output command result in specified format (note: disables progress logging and interactive functionality).", + }), + "emoji": new BooleanParameter({ + help: "Enable emoji in output (defaults to true if the environment supports it).", + defaultValue: envSupportsEmoji(), + }), + "yes": new BooleanParameter({ + alias: "y", + help: "Automatically approve any yes/no prompts during execution.", + defaultValue: false, + }), + "force-refresh": new BooleanParameter({ + help: "Force refresh of any caches, e.g. cached provider statuses.", + defaultValue: false, + }), + "var": new StringsParameter({ + help: + 'Set a specific variable value, using the format =, e.g. `--var some-key=custom-value`. This will override any value set in your project configuration. You can specify multiple variables by separating with a comma, e.g. `--var key-a=foo,key-b="value with quotes"`.', + }), + "version": new BooleanParameter({ + alias: "v", + help: "Show the current CLI version.", + }), + "help": new BooleanParameter({ + alias: "h", + help: "Show help", + }), +} + +export type GlobalOptions = typeof globalOptions diff --git a/garden-service/src/commands/base.ts b/garden-service/src/commands/base.ts index 1d6c954511..d82953347f 100644 --- a/garden-service/src/commands/base.ts +++ b/garden-service/src/commands/base.ts @@ -11,12 +11,10 @@ import chalk from "chalk" import dedent = require("dedent") import inquirer = require("inquirer") import stripAnsi from "strip-ansi" -import { range, fromPairs } from "lodash" -import minimist from "minimist" +import { fromPairs } from "lodash" -import { GlobalOptions } from "../cli/cli" import { joi, joiIdentifierMap, joiStringMap } from "../config/common" -import { GardenError, InternalError, RuntimeError, ParameterError } from "../exceptions" +import { InternalError, RuntimeError, GardenBaseError } from "../exceptions" import { Garden } from "../garden" import { LogEntry } from "../logger/log-entry" import { LoggerType } from "../logger/logger" @@ -25,227 +23,21 @@ import { ProcessResults } from "../process" import { GraphResults, GraphResult } from "../task-graph" import { RunResult } from "../types/plugin/base" import { capitalize } from "lodash" -import { parseEnvironment } from "../config/project" import { getDurationMsec, splitFirst } from "../util/util" import { buildResultSchema, BuildResult } from "../types/plugin/module/build" import { ServiceStatus, serviceStatusSchema } from "../types/service" import { TestResult, testResultSchema } from "../types/plugin/module/getTestResult" - -export interface ParameterConstructor { - help: string - required?: boolean - alias?: string - defaultValue?: T - valueName?: string - hints?: string - overrides?: string[] - cliDefault?: T - cliOnly?: boolean -} - -export abstract class Parameter { - abstract type: string - - // TODO: use this for validation in the CLI (currently just used in the service API) - abstract schema: Joi.Schema - - _valueType: T - - defaultValue: T | undefined - help: string - required: boolean - alias?: string - hints?: string - valueName: string - overrides: string[] - - readonly cliDefault: T | undefined // Optionally specify a separate default for CLI invocation - readonly cliOnly: boolean // If true, only expose in the CLI, and not in the HTTP/WS server. - - constructor({ - help, - required, - alias, - defaultValue, - valueName, - overrides, - hints, - cliDefault, - cliOnly, - }: ParameterConstructor) { - this.help = help - this.required = required || false - this.alias = alias - this.hints = hints - this.defaultValue = defaultValue - this.valueName = valueName || "_valueType" - this.overrides = overrides || [] - this.cliDefault = cliDefault - this.cliOnly = cliOnly || false - } - - coerce(input: T): T | undefined { - return input - } - - parseString(input?: string): T { - return (input as unknown) as T - } - - async autoComplete(): Promise { - return [] - } -} - -export class StringParameter extends Parameter { - type = "string" - schema = joi.string() -} - -// Separating this from StringParameter for now because we can't set the output type based on the required flag -// FIXME: Maybe use a Required type to enforce presence, rather that an option flag? -export class StringOption extends Parameter { - type = "string" - schema = joi.string() -} - -export interface StringsConstructor extends ParameterConstructor { - delimiter?: string -} - -export class StringsParameter extends Parameter { - type = "array:string" - schema = joi.array().items(joi.string()) - delimiter: string | RegExp - - constructor(args: StringsConstructor) { - super(args) - - // The default delimiter splits on commas, ignoring commas between double quotes - this.delimiter = args.delimiter || /,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/ - } - - // Sywac returns [undefined] if input is empty so we coerce that into undefined. - // This only applies to optional parameters since Sywac would throw if input is empty for a required parameter. - coerce(input: string[]) { - const filtered = input.filter((i) => !!i) - if (filtered.length < 1) { - return undefined - } - return filtered - } - - parseString(input?: string) { - return input?.split(this.delimiter) || [] - } -} - -export class PathParameter extends Parameter { - type = "path" - schema = joi.string() -} - -export class PathsParameter extends Parameter { - type = "array:path" - schema = joi.array().items(joi.posixPath()) - - parseString(input: string) { - return input.split(",") - } -} - -export class IntegerParameter extends Parameter { - type = "number" - schema = joi.number().integer() - - parseString(input: string) { - try { - return parseInt(input, 10) - } catch { - throw new ParameterError(`Could not parse "${input}" as integer`, { - expectedType: "integer", - input, - }) - } - } -} - -export interface ChoicesConstructor extends ParameterConstructor { - choices: string[] -} - -export class ChoicesParameter extends Parameter { - type = "choice" - choices: string[] - schema = joi.string() - - constructor(args: ChoicesConstructor) { - super(args) - - this.choices = args.choices - this.schema = joi.string().valid(...args.choices) - } - - parseString(input: string) { - if (this.choices.includes(input)) { - return input - } else { - throw new ParameterError(`"${input}" is not a valid argument`, { - expectedType: `One of: ${this.choices.join(", ")}`, - input, - }) - } - } - - async autoComplete() { - return this.choices - } -} - -export class BooleanParameter extends Parameter { - type = "boolean" - schema = joi.boolean() - - parseString(input: any) { - return !!input - } -} - -export class EnvironmentOption extends StringParameter { - type = "string" - schema = joi.environment() - - constructor({ help = "The environment (and optionally namespace) to work against." } = {}) { - super({ - help, - required: false, - alias: "e", - }) - } - - coerce(input: string | undefined) { - if (!input) { - return - } - // Validate the environment - parseEnvironment(input) - return input - } -} - -export type Parameters = { [key: string]: Parameter } -export type ParameterValues = { - [P in keyof T]: T[P]["_valueType"] -} +import { cliStyles, renderOptions, renderCommands, renderArguments } from "../cli/helpers" +import { GlobalOptions, ParameterValues, Parameters } from "../cli/params" export interface CommandConstructor { - new (parent?: Command): Command + new (parent?: CommandGroup): Command } export interface CommandResult { result?: T restartRequired?: boolean - errors?: GardenError[] + errors?: GardenBaseError[] } export interface CommandParamsBase { @@ -286,9 +78,7 @@ export abstract class Command { - const cmd = new cls(this) - return [cmd, ...cmd.getSubCommands()] - }) + /** + * Returns all paths that this command should match, including all aliases and permutations of those. + */ + getPaths(): string[][] { + if (this.parent) { + const parentPaths = this.parent.getPaths() + + if (this.alias) { + return parentPaths.flatMap((parentPath) => [ + [...parentPath, this.name], + [...parentPath, this.alias!], + ]) + } else { + return parentPaths.map((parentPath) => [...parentPath, this.name]) + } + } else if (this.alias) { + return [[this.name], [this.alias]] + } else { + return [[this.name]] + } } getLoggerType(_: CommandParamsBase): LoggerType { @@ -329,7 +137,6 @@ export abstract class Command new S(this).describe()) return { name, @@ -337,7 +144,6 @@ export abstract class Command} * @memberof Command */ - async isAllowedToRun(garden: Garden, log: LogEntry, opts: GlobalOptions): Promise { + async isAllowedToRun(garden: Garden, log: LogEntry, opts: ParameterValues): Promise { log.root.stop() if (!opts.yes && this.protected && garden.production) { const defaultMessage = chalk.yellow(dedent` @@ -394,6 +200,72 @@ export abstract class Command cliStyles.usagePositional(name, param.required)) + .join(" ") + " " + } + + out += cliStyles.optionsPlaceholder() + + if (this.arguments) { + const table = renderArguments(this.arguments) + out += `\n\n${cliStyles.heading("ARGUMENTS")}\n${table}` + } + + if (this.options) { + const table = renderOptions(this.options) + out += `\n\n${cliStyles.heading("OPTIONS")}\n${table}` + } + + return out + "\n" + } +} + +export abstract class CommandGroup extends Command { + abstract subCommands: CommandConstructor[] + + getSubCommands(): Command[] { + return this.subCommands.flatMap((cls) => { + const cmd = new cls(this) + if (cmd instanceof CommandGroup) { + return cmd.getSubCommands() + } else { + return [cmd] + } + }) + } + + async action() { + return {} + } + + describe() { + const description = super.describe() + const subCommands = this.subCommands.map((S) => new S(this).describe()) + + return { + ...description, + subCommands, + } + } + + renderHelp() { + const commands = this.subCommands.map((c) => new c(this)) + + return ` +${cliStyles.heading("USAGE")} + garden ${this.getFullName()} ${cliStyles.commandPlaceholder()} ${cliStyles.optionsPlaceholder()} + +${cliStyles.heading("COMMANDS")} +${renderCommands(commands)} +` + } } export function printResult({ @@ -619,62 +491,3 @@ export function describeParameters(args?: Parameters) { help: stripAnsi(arg.help), })) } - -export type ParamSpec = { - [key: string]: Parameter -} - -/** - * 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: Parameters; opts: Parameters } { - 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, - } -} diff --git a/garden-service/src/commands/build.ts b/garden-service/src/commands/build.ts index 25facd560d..fe035ef7ed 100644 --- a/garden-service/src/commands/build.ts +++ b/garden-service/src/commands/build.ts @@ -8,12 +8,10 @@ import Bluebird from "bluebird" import { - BooleanParameter, Command, CommandResult, CommandParams, handleProcessResults, - StringsParameter, PrepareParams, ProcessCommandResult, processCommandResultSchema, @@ -24,6 +22,7 @@ import { printHeader } from "../logger/util" import { startServer, GardenServer } from "../server/server" import { flatten } from "lodash" import { BuildTask } from "../tasks/build" +import { StringsParameter, BooleanParameter } from "../cli/params" const buildArgs = { modules: new StringsParameter({ @@ -32,7 +31,7 @@ const buildArgs = { } const buildOpts = { - force: new BooleanParameter({ help: "Force rebuild of module(s)." }), + force: new BooleanParameter({ help: "Force rebuild of module(s).", alias: "f" }), watch: new BooleanParameter({ help: "Watch for changes in module(s) and auto-build.", alias: "w", diff --git a/garden-service/src/commands/call.ts b/garden-service/src/commands/call.ts index ce389855bc..6615a323bd 100644 --- a/garden-service/src/commands/call.ts +++ b/garden-service/src/commands/call.ts @@ -9,7 +9,7 @@ import { parse, resolve } from "url" import chalk from "chalk" import { getStatusText } from "http-status-codes" -import { Command, CommandResult, CommandParams, StringParameter } from "./base" +import { Command, CommandResult, CommandParams } from "./base" import { splitFirst } from "../util/util" import { ParameterError, RuntimeError } from "../exceptions" import { find, includes } from "lodash" @@ -18,6 +18,7 @@ import { dedent } from "../util/string" import { printHeader } from "../logger/util" import { emptyRuntimeContext } from "../runtime-context" import { got, GotResponse } from "../util/http" +import { StringParameter } from "../cli/params" const callArgs = { serviceAndPath: new StringParameter({ diff --git a/garden-service/src/commands/commands.ts b/garden-service/src/commands/commands.ts index f9ce163229..7fe9f7f7d2 100644 --- a/garden-service/src/commands/commands.ts +++ b/garden-service/src/commands/commands.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command } from "./base" +import { Command, CommandGroup } from "./base" import { BuildCommand } from "./build" import { CallCommand } from "./call" import { CreateCommand } from "./create/create" @@ -35,7 +35,7 @@ import { LogOutCommand } from "./logout" import { ToolsCommand } from "./tools" import { UtilCommand } from "./util" -export const coreCommands: Command[] = [ +export const coreCommands: (Command | CommandGroup)[] = [ new BuildCommand(), new CallCommand(), new ConfigCommand(), @@ -64,3 +64,7 @@ export const coreCommands: Command[] = [ new UtilCommand(), new ValidateCommand(), ] + +export function getAllCommands() { + return coreCommands.flatMap((cmd) => (cmd instanceof CommandGroup ? [cmd, ...cmd.getSubCommands()] : [cmd])) +} diff --git a/garden-service/src/commands/config/config-analytics-enabled.ts b/garden-service/src/commands/config/config-analytics-enabled.ts index e8c23b9c79..0097a99f03 100644 --- a/garden-service/src/commands/config/config-analytics-enabled.ts +++ b/garden-service/src/commands/config/config-analytics-enabled.ts @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandParams, CommandResult, BooleanParameter } from "../base" +import { Command, CommandParams, CommandResult } from "../base" import dedent = require("dedent") import { AnalyticsHandler } from "../../analytics/analytics" +import { BooleanParameter } from "../../cli/params" const configAnalyticsEnabledArgs = { enable: new BooleanParameter({ diff --git a/garden-service/src/commands/config/config.ts b/garden-service/src/commands/config/config.ts index 00681e3161..111a4a91dc 100644 --- a/garden-service/src/commands/config/config.ts +++ b/garden-service/src/commands/config/config.ts @@ -6,16 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command } from "../base" +import { CommandGroup } from "../base" import { ConfigAnalyticsEnabled } from "./config-analytics-enabled" -export class ConfigCommand extends Command { +export class ConfigCommand extends CommandGroup { name = "config" help = "Configure user and project settings." subCommands = [ConfigAnalyticsEnabled] - - async action() { - return {} - } } diff --git a/garden-service/src/commands/create/create-module.ts b/garden-service/src/commands/create/create-module.ts index fd53eb43c9..ad1190e028 100644 --- a/garden-service/src/commands/create/create-module.ts +++ b/garden-service/src/commands/create/create-module.ts @@ -10,16 +10,7 @@ import chalk from "chalk" import dedent from "dedent" import { pathExists } from "fs-extra" import inquirer from "inquirer" -import { - Command, - CommandResult, - CommandParams, - PrepareParams, - PathParameter, - BooleanParameter, - StringOption, - StringParameter, -} from "../base" +import { Command, CommandResult, CommandParams, PrepareParams } from "../base" import { printHeader } from "../../logger/util" import { isDirectory, defaultConfigFilename } from "../../util/fs" import { loadConfigResources, findProjectConfig } from "../../config/base" @@ -40,6 +31,7 @@ import Bluebird from "bluebird" import { ModuleTypeMap } from "../../types/plugin/plugin" import { LogEntry } from "../../logger/log-entry" import { getProviderUrl, getModuleTypeUrl } from "../../docs/common" +import { PathParameter, StringParameter, BooleanParameter, StringOption } from "../../cli/params" const createModuleArgs = {} const createModuleOpts = { diff --git a/garden-service/src/commands/create/create-project.ts b/garden-service/src/commands/create/create-project.ts index be1507c275..5716f25772 100644 --- a/garden-service/src/commands/create/create-project.ts +++ b/garden-service/src/commands/create/create-project.ts @@ -10,16 +10,7 @@ import chalk from "chalk" import dedent from "dedent" import { pathExists, writeFile, copyFile } from "fs-extra" import inquirer from "inquirer" -import { - Command, - CommandResult, - CommandParams, - PrepareParams, - PathParameter, - BooleanParameter, - StringOption, - StringParameter, -} from "../base" +import { Command, CommandResult, CommandParams, PrepareParams } from "../base" import { printHeader } from "../../logger/util" import { isDirectory } from "../../util/fs" import { loadConfigResources } from "../../config/base" @@ -29,6 +20,7 @@ import { renderProjectConfigReference } from "../../docs/config" import { addConfig } from "./helpers" import { wordWrap } from "../../util/string" import { LoggerType } from "../../logger/logger" +import { PathParameter, StringParameter, BooleanParameter, StringOption } from "../../cli/params" const ignorefileName = ".gardenignore" const defaultIgnorefile = dedent` diff --git a/garden-service/src/commands/create/create.ts b/garden-service/src/commands/create/create.ts index a1b5e0f5d8..d348f8cdaa 100644 --- a/garden-service/src/commands/create/create.ts +++ b/garden-service/src/commands/create/create.ts @@ -6,17 +6,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command } from "../base" +import { CommandGroup } from "../base" import { CreateProjectCommand } from "./create-project" import { CreateModuleCommand } from "./create-module" -export class CreateCommand extends Command { +export class CreateCommand extends CommandGroup { name = "create" help = "Create new project or module." subCommands = [CreateProjectCommand, CreateModuleCommand] - - async action() { - return {} - } } diff --git a/garden-service/src/commands/delete.ts b/garden-service/src/commands/delete.ts index 090d236aa5..719275c6d0 100644 --- a/garden-service/src/commands/delete.ts +++ b/garden-service/src/commands/delete.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandResult, CommandParams, StringParameter, StringsParameter } from "./base" +import { Command, CommandResult, CommandParams, CommandGroup } from "./base" import { NotFoundError } from "../exceptions" import dedent from "dedent" import { ServiceStatusMap, serviceStatusSchema } from "../types/service" @@ -16,17 +16,14 @@ import { EnvironmentStatusMap } from "../types/plugin/provider/getEnvironmentSta import { DeleteServiceTask, deletedServiceStatuses } from "../tasks/delete-service" import { joi, joiIdentifierMap } from "../config/common" import { environmentStatusSchema } from "../config/status" +import { StringParameter, StringsParameter } from "../cli/params" -export class DeleteCommand extends Command { +export class DeleteCommand extends CommandGroup { name = "delete" alias = "del" help = "Delete configuration or objects." subCommands = [DeleteSecretCommand, DeleteEnvironmentCommand, DeleteServiceCommand] - - async action() { - return {} - } } const deleteSecretArgs = { diff --git a/garden-service/src/commands/deploy.ts b/garden-service/src/commands/deploy.ts index a017742748..c64b3394b3 100644 --- a/garden-service/src/commands/deploy.ts +++ b/garden-service/src/commands/deploy.ts @@ -10,12 +10,10 @@ import deline = require("deline") import dedent = require("dedent") import { - BooleanParameter, Command, CommandParams, CommandResult, handleProcessResults, - StringsParameter, PrepareParams, processCommandResultSchema, ProcessCommandResult, @@ -29,6 +27,7 @@ import { startServer, GardenServer } from "../server/server" import { DeployTask } from "../tasks/deploy" import { naturalList } from "../util/string" import chalk = require("chalk") +import { StringsParameter, BooleanParameter } from "../cli/params" export const deployArgs = { services: new StringsParameter({ diff --git a/garden-service/src/commands/dev.ts b/garden-service/src/commands/dev.ts index 277e7ba152..856cd39196 100644 --- a/garden-service/src/commands/dev.ts +++ b/garden-service/src/commands/dev.ts @@ -16,15 +16,7 @@ import moment = require("moment") import { join } from "path" import { getModuleWatchTasks } from "../tasks/helpers" -import { - Command, - CommandResult, - CommandParams, - StringsParameter, - handleProcessResults, - PrepareParams, - BooleanParameter, -} from "./base" +import { Command, CommandResult, CommandParams, handleProcessResults, PrepareParams } from "./base" import { STATIC_DIR } from "../constants" import { processModules } from "../process" import { Module } from "../types/module" @@ -36,6 +28,7 @@ import { BuildTask } from "../tasks/build" import { DeployTask } from "../tasks/deploy" import { Garden } from "../garden" import { LogEntry } from "../logger/log-entry" +import { StringsParameter, BooleanParameter } from "../cli/params" const ansiBannerPath = join(STATIC_DIR, "garden-banner-2.txt") diff --git a/garden-service/src/commands/exec.ts b/garden-service/src/commands/exec.ts index 4b8d8ea21f..f793e8cb5a 100644 --- a/garden-service/src/commands/exec.ts +++ b/garden-service/src/commands/exec.ts @@ -10,8 +10,9 @@ import chalk from "chalk" import { LoggerType } from "../logger/logger" import { ExecInServiceResult, execInServiceResultSchema } from "../types/plugin/service/execInService" import { printHeader } from "../logger/util" -import { Command, CommandResult, CommandParams, StringParameter, BooleanParameter, StringsParameter } from "./base" +import { Command, CommandResult, CommandParams } from "./base" import dedent = require("dedent") +import { StringParameter, StringsParameter, BooleanParameter } from "../cli/params" const execArgs = { service: new StringParameter({ diff --git a/garden-service/src/commands/get/get-config.ts b/garden-service/src/commands/get/get-config.ts index a341d522a8..5e3048b9f2 100644 --- a/garden-service/src/commands/get/get-config.ts +++ b/garden-service/src/commands/get/get-config.ts @@ -6,13 +6,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandResult, CommandParams, BooleanParameter, ChoicesParameter } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { ConfigDump } from "../../garden" import { environmentNameSchema } from "../../config/project" import { joiIdentifier, joiVariables, joiArray, joi } from "../../config/common" import { providerSchemaWithoutTools, providerConfigBaseSchema } from "../../config/provider" import { moduleConfigSchema } from "../../config/module" import { workflowConfigSchema } from "../../config/workflow" +import { BooleanParameter, ChoicesParameter } from "../../cli/params" export const getConfigOptions = { "exclude-disabled": new BooleanParameter({ diff --git a/garden-service/src/commands/get/get-debug-info.ts b/garden-service/src/commands/get/get-debug-info.ts index abe85feca2..964cd0b515 100644 --- a/garden-service/src/commands/get/get-debug-info.ts +++ b/garden-service/src/commands/get/get-debug-info.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandParams, ChoicesParameter, BooleanParameter } from "../base" +import { Command, CommandParams } from "../base" import { findProjectConfig } from "../../config/base" import { ensureDir, copy, remove, pathExists, writeFile } from "fs-extra" import { getPackageVersion, exec, safeDumpYaml } from "../../util/util" @@ -21,6 +21,7 @@ import { zipFolder } from "../../util/archive" import chalk from "chalk" import { GitHandler } from "../../vcs/git" import { ValidationError } from "../../exceptions" +import { ChoicesParameter, BooleanParameter } from "../../cli/params" export const TEMP_DEBUG_ROOT = "tmp" export const SYSTEM_INFO_FILENAME_NO_EXT = "system-info" diff --git a/garden-service/src/commands/get/get-secret.ts b/garden-service/src/commands/get/get-secret.ts index 0ac599bd99..2558a7e3fa 100644 --- a/garden-service/src/commands/get/get-secret.ts +++ b/garden-service/src/commands/get/get-secret.ts @@ -7,8 +7,9 @@ */ import { NotFoundError } from "../../exceptions" -import { Command, CommandResult, CommandParams, StringParameter } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import dedent = require("dedent") +import { StringParameter } from "../../cli/params" const getSecretArgs = { provider: new StringParameter({ diff --git a/garden-service/src/commands/get/get-task-result.ts b/garden-service/src/commands/get/get-task-result.ts index ed6275f76a..7b7848cc3f 100644 --- a/garden-service/src/commands/get/get-task-result.ts +++ b/garden-service/src/commands/get/get-task-result.ts @@ -7,7 +7,7 @@ */ import { ConfigGraph } from "../../config-graph" -import { Command, CommandResult, CommandParams, StringParameter } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { printHeader } from "../../logger/util" import { getTaskVersion } from "../../tasks/task" import { RunTaskResult } from "../../types/plugin/task/runTask" @@ -15,6 +15,7 @@ import chalk from "chalk" import { getArtifactFileList, getArtifactKey } from "../../util/artifacts" import { taskResultSchema } from "../../types/plugin/task/getTaskResult" import { joiArray, joi } from "../../config/common" +import { StringParameter } from "../../cli/params" const getTaskResultArgs = { name: new StringParameter({ diff --git a/garden-service/src/commands/get/get-tasks.ts b/garden-service/src/commands/get/get-tasks.ts index 81202c343a..e6359774bd 100644 --- a/garden-service/src/commands/get/get-tasks.ts +++ b/garden-service/src/commands/get/get-tasks.ts @@ -9,9 +9,10 @@ import chalk from "chalk" import indentString from "indent-string" import { sortBy, omit, uniq } from "lodash" -import { Command, CommandResult, CommandParams, StringsParameter, PrepareParams } from "../base" +import { Command, CommandResult, CommandParams, PrepareParams } from "../base" import { printHeader } from "../../logger/util" import { Task } from "../../types/task" +import { StringsParameter } from "../../cli/params" const getTasksArgs = { tasks: new StringsParameter({ diff --git a/garden-service/src/commands/get/get-test-result.ts b/garden-service/src/commands/get/get-test-result.ts index c47056450e..49f83ecee5 100644 --- a/garden-service/src/commands/get/get-test-result.ts +++ b/garden-service/src/commands/get/get-test-result.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandResult, CommandParams, StringParameter } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { NotFoundError } from "../../exceptions" import { TestResult, testResultSchema } from "../../types/plugin/module/getTestResult" import { getTestVersion } from "../../tasks/test" @@ -15,6 +15,7 @@ import { printHeader } from "../../logger/util" import chalk from "chalk" import { getArtifactFileList, getArtifactKey } from "../../util/artifacts" import { joi, joiArray } from "../../config/common" +import { StringParameter } from "../../cli/params" const getTestResultArgs = { module: new StringParameter({ diff --git a/garden-service/src/commands/get/get.ts b/garden-service/src/commands/get/get.ts index 03295eda26..3b5757cd5e 100644 --- a/garden-service/src/commands/get/get.ts +++ b/garden-service/src/commands/get/get.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command } from "../base" +import { CommandGroup } from "../base" import { GetGraphCommand } from "./get-graph" import { GetConfigCommand } from "./get-config" import { GetEysiCommand } from "./get-eysi" @@ -19,7 +19,7 @@ import { GetDebugInfoCommand } from "./get-debug-info" import { GetLinkedReposCommand } from "./get-linked-repos" import { GetOutputsCommand } from "./get-outputs" -export class GetCommand extends Command { +export class GetCommand extends CommandGroup { name = "get" help = "Retrieve and output data and objects, e.g. secrets, status info etc." @@ -36,8 +36,4 @@ export class GetCommand extends Command { GetTestResultCommand, GetDebugInfoCommand, ] - - async action() { - return {} - } } diff --git a/garden-service/src/commands/link/link.ts b/garden-service/src/commands/link/link.ts index 741d29c664..30e25e08b1 100644 --- a/garden-service/src/commands/link/link.ts +++ b/garden-service/src/commands/link/link.ts @@ -6,17 +6,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command } from "../base" +import { CommandGroup } from "../base" import { LinkSourceCommand } from "./source" import { LinkModuleCommand } from "./module" -export class LinkCommand extends Command { +export class LinkCommand extends CommandGroup { name = "link" help = "Link a remote source or module to a local path." subCommands = [LinkSourceCommand, LinkModuleCommand] - - async action() { - return {} - } } diff --git a/garden-service/src/commands/link/module.ts b/garden-service/src/commands/link/module.ts index 51bb155ec0..f523de4877 100644 --- a/garden-service/src/commands/link/module.ts +++ b/garden-service/src/commands/link/module.ts @@ -11,12 +11,13 @@ import dedent = require("dedent") import chalk from "chalk" import { ParameterError } from "../../exceptions" -import { Command, CommandResult, StringParameter, PathParameter, CommandParams } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { LinkedSource } from "../../config-store" import { printHeader } from "../../logger/util" import { addLinkedSources, hasRemoteSource } from "../../util/ext-source-util" import { joiArray, joi } from "../../config/common" import { linkedModuleSchema } from "../../config/project" +import { StringParameter, PathParameter } from "../../cli/params" const linkModuleArguments = { module: new StringParameter({ diff --git a/garden-service/src/commands/link/source.ts b/garden-service/src/commands/link/source.ts index 551b8bd826..251f45ac55 100644 --- a/garden-service/src/commands/link/source.ts +++ b/garden-service/src/commands/link/source.ts @@ -11,13 +11,14 @@ import dedent = require("dedent") import chalk from "chalk" import { ParameterError } from "../../exceptions" -import { Command, CommandResult, StringParameter, PathParameter } from "../base" +import { Command, CommandResult } from "../base" import { addLinkedSources } from "../../util/ext-source-util" import { LinkedSource } from "../../config-store" import { CommandParams } from "../base" import { printHeader } from "../../logger/util" import { joiArray, joi } from "../../config/common" import { linkedSourceSchema } from "../../config/project" +import { StringParameter, PathParameter } from "../../cli/params" const linkSourceArguments = { source: new StringParameter({ diff --git a/garden-service/src/commands/logs.ts b/garden-service/src/commands/logs.ts index 37525dcff9..1c4540a307 100644 --- a/garden-service/src/commands/logs.ts +++ b/garden-service/src/commands/logs.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandResult, CommandParams, StringsParameter, IntegerParameter, BooleanParameter } from "./base" +import { Command, CommandResult, CommandParams } from "./base" import chalk from "chalk" import { maxBy } from "lodash" import { ServiceLogEntry } from "../types/plugin/service/getServiceLogs" @@ -17,6 +17,7 @@ import { LoggerType } from "../logger/logger" import dedent = require("dedent") import { LogLevel } from "../logger/log-node" import { emptyRuntimeContext } from "../runtime-context" +import { StringsParameter, BooleanParameter, IntegerParameter } from "../cli/params" const logsArgs = { services: new StringsParameter({ diff --git a/garden-service/src/commands/migrate.ts b/garden-service/src/commands/migrate.ts index 7690838e37..931a7d207b 100644 --- a/garden-service/src/commands/migrate.ts +++ b/garden-service/src/commands/migrate.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandParams, CommandResult, BooleanParameter, StringsParameter } from "./base" +import { Command, CommandParams, CommandResult } from "./base" import { dedent } from "../util/string" import { readFile, writeFile } from "fs-extra" import { cloneDeep, isEqual } from "lodash" @@ -19,6 +19,7 @@ import { exec, safeDumpYaml } from "../util/util" import { LoggerType } from "../logger/logger" import Bluebird from "bluebird" import { loadAndValidateYaml, findProjectConfig } from "../config/base" +import { BooleanParameter, StringsParameter } from "../cli/params" const migrateOpts = { write: new BooleanParameter({ help: "Update the `garden.yml` in place." }), diff --git a/garden-service/src/commands/options.ts b/garden-service/src/commands/options.ts index 10c36a35ad..93fa0b1bc2 100644 --- a/garden-service/src/commands/options.ts +++ b/garden-service/src/commands/options.ts @@ -6,35 +6,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandParams, CommandResult, Parameter } from "./base" -import stringWidth = require("string-width") -import { maxBy, zip } from "lodash" -import CliTable from "cli-table3" -import { GLOBAL_OPTIONS, HIDDEN_OPTIONS } from "../cli/cli" -import { helpTextMaxWidth } from "../cli/helpers" -import chalk from "chalk" - -const tableConfig: CliTable.TableConstructorOptions = { - chars: { - "top": "", - "top-mid": "", - "top-left": "", - "top-right": "", - "bottom": "", - "bottom-mid": "", - "bottom-left": "", - "bottom-right": "", - "left": "", - "left-mid": "", - "mid": " ", - "mid-mid": "", - "right": "", - "right-mid": "", - "middle": "", - }, - wordWrap: true, - truncate: " ", // We need this to prevent ellipsis (empty string does not work) -} +import { Command, CommandParams, CommandResult } from "./base" +import { renderOptions, cliStyles } from "../cli/helpers" +import { globalOptions } from "../cli/params" export class OptionsCommand extends Command { name = "options" @@ -44,42 +18,10 @@ export class OptionsCommand extends Command { description = "Prints all global options (options that can be applied to any command)." async action({ log }: CommandParams): Promise { - // Show both global options and hidden commands (version and help) in the output - const allOpts = { ...GLOBAL_OPTIONS, ...HIDDEN_OPTIONS } - const sortedOpts = Object.keys(allOpts).sort() - const optNames = sortedOpts.map((optName) => { - const option = >allOpts[optName] - const alias = option.alias ? `-${option.alias}, ` : "" - return chalk.green(` ${alias}--${optName} `) - }) - - const helpTexts = sortedOpts.map((optName) => { - const option = >allOpts[optName] - let out = option.help - let hints = "" - if (option.hints) { - hints = option.hints - } else { - hints = `\n[${option.type}]` - if (option.defaultValue) { - hints += ` [default: ${option.defaultValue}]` - } - } - return out + chalk.gray(hints) - }) - - const nameColWidth = stringWidth(maxBy(optNames, (n) => stringWidth(n)) || "") + 1 - const textColWidth = helpTextMaxWidth() - nameColWidth - const table = new CliTable({ - ...tableConfig, - colWidths: [nameColWidth, textColWidth], - }) as CliTable.Table - - table.push(...zip(optNames, helpTexts)) - log.info("") - log.info(chalk.white.bold("GLOBAL OPTIONS")) - log.info(table.toString()) + log.info(cliStyles.heading("GLOBAL OPTIONS")) + log.info(renderOptions(globalOptions)) + log.info("") return {} } diff --git a/garden-service/src/commands/plugins.ts b/garden-service/src/commands/plugins.ts index cd3e708ae4..c022d40dce 100644 --- a/garden-service/src/commands/plugins.ts +++ b/garden-service/src/commands/plugins.ts @@ -13,11 +13,12 @@ import { dedent, renderTable, tablePresets } from "../util/string" import { ParameterError, toGardenError } from "../exceptions" import { LogEntry } from "../logger/log-entry" import { Garden } from "../garden" -import { Command, CommandResult, CommandParams, StringOption } from "./base" +import { Command, CommandResult, CommandParams } from "./base" import Bluebird from "bluebird" import { printHeader, getTerminalWidth } from "../logger/util" import { LoggerType } from "../logger/logger" import { Module } from "../types/module" +import { StringOption } from "../cli/params" const pluginArgs = { plugin: new StringOption({ diff --git a/garden-service/src/commands/publish.ts b/garden-service/src/commands/publish.ts index b172eaa951..c1dd792cf0 100644 --- a/garden-service/src/commands/publish.ts +++ b/garden-service/src/commands/publish.ts @@ -7,12 +7,10 @@ */ import { - BooleanParameter, Command, CommandParams, CommandResult, handleProcessResults, - StringsParameter, ProcessCommandResult, ProcessResultMetadata, prepareProcessResults, @@ -29,6 +27,7 @@ import dedent = require("dedent") import { ConfigGraph } from "../config-graph" import { PublishResult, publishResultSchema } from "../types/plugin/module/publishModule" import { joiIdentifierMap } from "../config/common" +import { StringsParameter, BooleanParameter } from "../cli/params" export const publishArgs = { modules: new StringsParameter({ diff --git a/garden-service/src/commands/run/module.ts b/garden-service/src/commands/run/module.ts index f4de189562..7ee0bd4369 100644 --- a/garden-service/src/commands/run/module.ts +++ b/garden-service/src/commands/run/module.ts @@ -13,18 +13,10 @@ import { prepareRuntimeContext } from "../../runtime-context" import { BuildTask } from "../../tasks/build" import { RunResult } from "../../types/plugin/base" import { dedent, deline } from "../../util/string" -import { - BooleanParameter, - Command, - CommandParams, - CommandResult, - handleRunResult, - StringParameter, - StringsParameter, - ProcessResultMetadata, -} from "../base" +import { Command, CommandParams, CommandResult, handleRunResult, ProcessResultMetadata } from "../base" import { printRuntimeContext } from "./run" import { GraphResults } from "../../task-graph" +import { StringParameter, StringsParameter, BooleanParameter } from "../../cli/params" const runModuleArgs = { module: new StringParameter({ diff --git a/garden-service/src/commands/run/run.ts b/garden-service/src/commands/run/run.ts index 8c1b74533d..171234885e 100644 --- a/garden-service/src/commands/run/run.ts +++ b/garden-service/src/commands/run/run.ts @@ -8,7 +8,7 @@ import { RuntimeContext } from "../../runtime-context" import { highlightYaml, safeDumpYaml } from "../../util/util" -import { Command } from "../base" +import { CommandGroup } from "../base" import { RunModuleCommand } from "./module" import { RunServiceCommand } from "./service" import { RunTaskCommand } from "./task" @@ -16,15 +16,11 @@ import { RunTestCommand } from "./test" import { RunWorkflowCommand } from "./workflow" import { LogEntry } from "../../logger/log-entry" -export class RunCommand extends Command { +export class RunCommand extends CommandGroup { name = "run" help = "Run ad-hoc instances of your modules, services, tests, tasks or workflows." subCommands = [RunModuleCommand, RunServiceCommand, RunTaskCommand, RunTestCommand, RunWorkflowCommand] - - async action() { - return {} - } } export function printRuntimeContext(log: LogEntry, runtimeContext: RuntimeContext) { diff --git a/garden-service/src/commands/run/service.ts b/garden-service/src/commands/run/service.ts index 2517f2b312..044781931a 100644 --- a/garden-service/src/commands/run/service.ts +++ b/garden-service/src/commands/run/service.ts @@ -16,17 +16,10 @@ import { getRunTaskResults, getServiceStatuses } from "../../tasks/base" import { DeployTask } from "../../tasks/deploy" import { RunResult } from "../../types/plugin/base" import { deline } from "../../util/string" -import { - BooleanParameter, - Command, - CommandParams, - CommandResult, - handleRunResult, - StringParameter, - ProcessResultMetadata, -} from "../base" +import { Command, CommandParams, CommandResult, handleRunResult, ProcessResultMetadata } from "../base" import { printRuntimeContext } from "./run" import { GraphResults } from "../../task-graph" +import { StringParameter, BooleanParameter } from "../../cli/params" const runServiceArgs = { service: new StringParameter({ diff --git a/garden-service/src/commands/run/task.ts b/garden-service/src/commands/run/task.ts index 00de83577e..0467705e9d 100644 --- a/garden-service/src/commands/run/task.ts +++ b/garden-service/src/commands/run/task.ts @@ -8,10 +8,8 @@ import chalk from "chalk" import { - BooleanParameter, Command, CommandParams, - StringParameter, CommandResult, handleTaskResult, ProcessResultMetadata, @@ -26,6 +24,7 @@ import { dedent, deline } from "../../util/string" import { RunTaskResult } from "../../types/plugin/task/runTask" import { taskResultSchema } from "../../types/plugin/task/getTaskResult" import { joi } from "../../config/common" +import { StringParameter, BooleanParameter } from "../../cli/params" export const runTaskArgs = { task: new StringParameter({ diff --git a/garden-service/src/commands/run/test.ts b/garden-service/src/commands/run/test.ts index 40e56e2172..32a0fea2b2 100644 --- a/garden-service/src/commands/run/test.ts +++ b/garden-service/src/commands/run/test.ts @@ -17,12 +17,10 @@ import { testFromConfig } from "../../types/test" import { dedent, deline } from "../../util/string" import { findByName, getNames } from "../../util/util" import { - BooleanParameter, Command, CommandParams, CommandResult, handleRunResult, - StringParameter, resultMetadataKeys, graphResultsSchema, ProcessResultMetadata, @@ -31,6 +29,7 @@ import { printRuntimeContext } from "./run" import { joi } from "../../config/common" import { testResultSchema, TestResult } from "../../types/plugin/module/getTestResult" import { GraphResults } from "../../task-graph" +import { StringParameter, BooleanParameter } from "../../cli/params" export const runTestArgs = { module: new StringParameter({ diff --git a/garden-service/src/commands/run/workflow.ts b/garden-service/src/commands/run/workflow.ts index 520f05d354..be44b5e997 100644 --- a/garden-service/src/commands/run/workflow.ts +++ b/garden-service/src/commands/run/workflow.ts @@ -7,12 +7,12 @@ */ import chalk from "chalk" -import { cloneDeep, isEqual, merge, repeat, take } from "lodash" +import { cloneDeep, merge, repeat } from "lodash" import { printHeader, getTerminalWidth, formatGardenError } from "../../logger/util" -import { StringParameter, Command, CommandParams, CommandResult, parseCliArgs } from "../base" +import { Command, CommandParams, CommandResult } from "../base" import { dedent, wordWrap, deline } from "../../util/string" import { Garden } from "../../garden" -import { getStepCommandConfigs, WorkflowStepSpec, WorkflowConfig, WorkflowFileSpec } from "../../config/workflow" +import { WorkflowStepSpec, WorkflowConfig, WorkflowFileSpec } from "../../config/workflow" import { LogEntry } from "../../logger/log-entry" import { GardenError, GardenBaseError } from "../../exceptions" import { WorkflowConfigContext, WorkflowStepConfigContext, WorkflowStepResult } from "../../config/config-context" @@ -27,6 +27,9 @@ import { ExecaError } from "execa" import { LogLevel } from "../../logger/log-node" import { gardenEnv } from "../../constants" import { registerWorkflowRun } from "../../enterprise/workflow-lifecycle" +import { parseCliArgs, pickCommand, processCliArgs } from "../../cli/helpers" +import { StringParameter } from "../../cli/params" +import { getAllCommands } from "../commands" const runWorkflowArgs = { workflow: new StringParameter({ @@ -78,7 +81,6 @@ export class RunWorkflowCommand extends Command { printHeader(headerLog, `Running workflow ${chalk.white(workflow.name)}`, "runner") - const stepCommandConfigs = getStepCommandConfigs() const startedAt = new Date().valueOf() const result: WorkflowRunOutput = { @@ -132,7 +134,6 @@ export class RunWorkflowCommand extends Command { headerLog: stepHeaderLog, log: stepBodyLog, footerLog: stepFooterLog, - stepCommandConfigs, }) } else if (step.script) { step.script = resolveTemplateString(step.script, stepTemplateContext) @@ -205,9 +206,7 @@ export interface RunStepParams { step: WorkflowStepSpec } -export interface RunStepCommandParams extends RunStepParams { - stepCommandConfigs: any -} +export interface RunStepCommandParams extends RunStepParams {} function getStepName(index: number, name?: string) { return name || `step-${index + 1}` @@ -293,13 +292,26 @@ export async function runStepCommand({ footerLog, headerLog, inheritedOpts, - stepCommandConfigs, step, }: RunStepCommandParams): Promise> { - const config = stepCommandConfigs.find((c) => isEqual(c.prefix, take(step.command!, c.prefix.length))) - const rest = step.command!.slice(config.prefix.length) // arguments + options - const { args, opts } = parseCliArgs(rest, config.args, config.opts) - const command: Command = new config.cmdClass() + const { command, rest } = pickCommand(getAllCommands(), step.command!) + + if (!command) { + throw new ConfigurationError(`Could not find Garden command '${step.command!}`, { + step, + }) + } + + if (!command?.workflows) { + throw new ConfigurationError(`Command '${command?.getFullName()}' is currently not supported in workflows`, { + step, + command: command?.getFullName(), + }) + } + + const parsedArgs = parseCliArgs({ stringArgs: rest, command, cli: false }) + const { args, opts } = processCliArgs({ parsedArgs, command, cli: false }) + const result = await command.action({ garden, log, diff --git a/garden-service/src/commands/serve.ts b/garden-service/src/commands/serve.ts index 11d9cee05b..cd9f8303d2 100644 --- a/garden-service/src/commands/serve.ts +++ b/garden-service/src/commands/serve.ts @@ -8,10 +8,11 @@ import dedent = require("dedent") import { LoggerType } from "../logger/logger" -import { IntegerParameter, PrepareParams } from "./base" +import { PrepareParams } from "./base" import { Command, CommandResult, CommandParams } from "./base" import { sleep } from "../util/util" import { GardenServer, startServer } from "../server/server" +import { IntegerParameter } from "../cli/params" const serveArgs = {} diff --git a/garden-service/src/commands/set.ts b/garden-service/src/commands/set.ts index 9affdd5e8b..1a2890d6ee 100644 --- a/garden-service/src/commands/set.ts +++ b/garden-service/src/commands/set.ts @@ -6,20 +6,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandResult, CommandParams, StringParameter } from "./base" +import { Command, CommandResult, CommandParams, CommandGroup } from "./base" import dedent = require("dedent") import { SetSecretResult } from "../types/plugin/provider/setSecret" +import { StringParameter } from "../cli/params" -export class SetCommand extends Command { +export class SetCommand extends CommandGroup { name = "set" help = "Set or modify data, e.g. secrets." hidden = true subCommands = [SetSecretCommand] - - async action() { - return {} - } } const setSecretArgs = { diff --git a/garden-service/src/commands/test.ts b/garden-service/src/commands/test.ts index 6c75f16a43..48a085b9e1 100644 --- a/garden-service/src/commands/test.ts +++ b/garden-service/src/commands/test.ts @@ -11,13 +11,10 @@ import { flatten } from "lodash" import dedent = require("dedent") import { - BooleanParameter, Command, CommandParams, CommandResult, handleProcessResults, - StringOption, - StringsParameter, PrepareParams, ProcessCommandResult, processCommandResultSchema, @@ -27,6 +24,7 @@ import { Module } from "../types/module" import { getTestTasks } from "../tasks/test" import { printHeader } from "../logger/util" import { GardenServer, startServer } from "../server/server" +import { StringsParameter, StringOption, BooleanParameter } from "../cli/params" export const testArgs = { modules: new StringsParameter({ diff --git a/garden-service/src/commands/tools.ts b/garden-service/src/commands/tools.ts index ed90ea258d..553f6d7460 100644 --- a/garden-service/src/commands/tools.ts +++ b/garden-service/src/commands/tools.ts @@ -11,13 +11,14 @@ import { max, omit, sortBy } from "lodash" import { dedent, renderTable, tablePresets } from "../util/string" import { LogEntry } from "../logger/log-entry" import { Garden, DummyGarden } from "../garden" -import { Command, CommandParams, StringOption, BooleanParameter } from "./base" +import { Command, CommandParams } from "./base" import { getTerminalWidth } from "../logger/util" import { LoggerType } from "../logger/logger" import { ParameterError } from "../exceptions" import { uniqByName, exec } from "../util/util" import { PluginTool } from "../util/ext-tools" import { findProjectConfig } from "../config/base" +import { StringOption, BooleanParameter } from "../cli/params" const toolsArgs = { tool: new StringOption({ diff --git a/garden-service/src/commands/unlink/module.ts b/garden-service/src/commands/unlink/module.ts index e13292b28c..933ec62767 100644 --- a/garden-service/src/commands/unlink/module.ts +++ b/garden-service/src/commands/unlink/module.ts @@ -8,10 +8,11 @@ import dedent = require("dedent") -import { Command, CommandResult, StringsParameter, BooleanParameter, CommandParams } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { removeLinkedSources } from "../../util/ext-source-util" import { printHeader } from "../../logger/util" import { localConfigKeys, LinkedSource } from "../../config-store" +import { StringsParameter, BooleanParameter } from "../../cli/params" const unlinkModuleArguments = { modules: new StringsParameter({ diff --git a/garden-service/src/commands/unlink/source.ts b/garden-service/src/commands/unlink/source.ts index 32ec2b0ac8..0fd44a2f9c 100644 --- a/garden-service/src/commands/unlink/source.ts +++ b/garden-service/src/commands/unlink/source.ts @@ -8,10 +8,11 @@ import dedent = require("dedent") -import { Command, CommandResult, StringsParameter, BooleanParameter, CommandParams } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { removeLinkedSources } from "../../util/ext-source-util" import { printHeader } from "../../logger/util" import { localConfigKeys, LinkedSource } from "../../config-store" +import { StringsParameter, BooleanParameter } from "../../cli/params" const unlinkSourceArguments = { sources: new StringsParameter({ diff --git a/garden-service/src/commands/unlink/unlink.ts b/garden-service/src/commands/unlink/unlink.ts index 47816d761d..2033b8fec3 100644 --- a/garden-service/src/commands/unlink/unlink.ts +++ b/garden-service/src/commands/unlink/unlink.ts @@ -6,17 +6,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command } from "../base" +import { CommandGroup } from "../base" import { UnlinkSourceCommand } from "./source" import { UnlinkModuleCommand } from "./module" -export class UnlinkCommand extends Command { +export class UnlinkCommand extends CommandGroup { name = "unlink" help = "Unlink a remote source or module from its local path." subCommands = [UnlinkSourceCommand, UnlinkModuleCommand] - - async action() { - return {} - } } diff --git a/garden-service/src/commands/update-remote/modules.ts b/garden-service/src/commands/update-remote/modules.ts index 4e9c4ddc16..c614969686 100644 --- a/garden-service/src/commands/update-remote/modules.ts +++ b/garden-service/src/commands/update-remote/modules.ts @@ -10,7 +10,7 @@ import { difference } from "lodash" import dedent = require("dedent") import chalk from "chalk" -import { Command, StringsParameter, CommandResult, CommandParams, ParameterValues } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { SourceConfig, moduleSourceSchema } from "../../config/project" import { ParameterError } from "../../exceptions" import { pruneRemoteSources } from "./helpers" @@ -19,6 +19,7 @@ import { printHeader } from "../../logger/util" import { Garden } from "../../garden" import { LogEntry } from "../../logger/log-entry" import { joiArray, joi } from "../../config/common" +import { StringsParameter, ParameterValues } from "../../cli/params" const updateRemoteModulesArguments = { modules: new StringsParameter({ diff --git a/garden-service/src/commands/update-remote/sources.ts b/garden-service/src/commands/update-remote/sources.ts index 953434a08e..bc234a549a 100644 --- a/garden-service/src/commands/update-remote/sources.ts +++ b/garden-service/src/commands/update-remote/sources.ts @@ -10,7 +10,7 @@ import { difference } from "lodash" import dedent = require("dedent") import chalk from "chalk" -import { Command, StringsParameter, CommandResult, CommandParams, ParameterValues } from "../base" +import { Command, CommandResult, CommandParams } from "../base" import { ParameterError } from "../../exceptions" import { pruneRemoteSources } from "./helpers" import { SourceConfig, projectSourceSchema } from "../../config/project" @@ -18,6 +18,7 @@ import { printHeader } from "../../logger/util" import { Garden } from "../../garden" import { LogEntry } from "../../logger/log-entry" import { joiArray, joi } from "../../config/common" +import { StringsParameter, ParameterValues } from "../../cli/params" const updateRemoteSourcesArguments = { sources: new StringsParameter({ diff --git a/garden-service/src/commands/update-remote/update-remote.ts b/garden-service/src/commands/update-remote/update-remote.ts index 8e02c0538c..f9ede78af8 100644 --- a/garden-service/src/commands/update-remote/update-remote.ts +++ b/garden-service/src/commands/update-remote/update-remote.ts @@ -6,18 +6,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command } from "../base" +import { CommandGroup } from "../base" import { UpdateRemoteSourcesCommand } from "./sources" import { UpdateRemoteModulesCommand } from "./modules" import { UpdateRemoteAllCommand } from "./all" -export class UpdateRemoteCommand extends Command { +export class UpdateRemoteCommand extends CommandGroup { name = "update-remote" help = "Pulls the latest version of remote sources or modules from their repository." subCommands = [UpdateRemoteSourcesCommand, UpdateRemoteModulesCommand, UpdateRemoteAllCommand] - - async action() { - return {} - } } diff --git a/garden-service/src/commands/util.ts b/garden-service/src/commands/util.ts index 0ec551f628..6ba4203950 100644 --- a/garden-service/src/commands/util.ts +++ b/garden-service/src/commands/util.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Command, CommandParams, BooleanParameter } from "./base" +import { Command, CommandParams, CommandGroup } from "./base" import { RuntimeError } from "../exceptions" import dedent from "dedent" import { GardenPlugin } from "../types/plugin/plugin" @@ -16,16 +16,13 @@ import Bluebird from "bluebird" import { PluginTool } from "../util/ext-tools" import { fromPairs, omit, uniqBy } from "lodash" import { printHeader, printFooter } from "../logger/util" +import { BooleanParameter } from "../cli/params" -export class UtilCommand extends Command { +export class UtilCommand extends CommandGroup { name = "util" help = "Misc utility commands." subCommands = [FetchToolsCommand] - - async action() { - return {} - } } const fetchToolsOpts = { diff --git a/garden-service/src/config/workflow.ts b/garden-service/src/config/workflow.ts index 3185673e3c..5102991bd8 100644 --- a/garden-service/src/config/workflow.ts +++ b/garden-service/src/config/workflow.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { cloneDeep, isEqual, take, pickBy } from "lodash" +import { cloneDeep, isEqual, take } from "lodash" import { joi, joiUserIdentifier, joiVariableName, joiIdentifier } from "./common" import { DEFAULT_API_VERSION } from "../constants" import { deline, dedent } from "../util/string" @@ -17,7 +17,7 @@ import { resolveTemplateStrings } from "../template-string" import { validateWithPath } from "./validation" import { ConfigurationError } from "../exceptions" import { coreCommands } from "../commands/commands" -import { Parameters } from "../commands/base" +import { CommandGroup } from "../commands/base" import { EnvironmentConfig, getNamespace } from "./project" export interface WorkflowConfig { @@ -140,9 +140,9 @@ export interface WorkflowStepSpec { } export const workflowStepSchema = () => { - const cmdConfigs = getStepCommandConfigs() + const cmdConfigs = getStepCommands() const cmdDescriptions = cmdConfigs - .map((c) => c.prefix.join(", ")) + .map((c) => c.getPath().join(", ")) .sort() .map((prefix) => `\`[${prefix}]\``) .join("\n") @@ -298,29 +298,27 @@ export function resolveWorkflowConfig(garden: Garden, config: WorkflowConfig) { return resolvedConfig } -function filterParameters(params: Parameters) { - return pickBy(params, (arg) => !arg.cliOnly) -} - /** - * Get all commands whitelisted for workflows, and allowed args/opts. + * Get all commands whitelisted for workflows */ -export function getStepCommandConfigs() { - const workflowCommands = coreCommands.flatMap((cmd) => [cmd, ...cmd.getSubCommands()]).filter((cmd) => cmd.workflows) - - return workflowCommands.map((cmd) => ({ - prefix: cmd.getPath(), - cmdClass: cmd.constructor, - args: filterParameters(cmd.arguments || {}), - opts: filterParameters(cmd.options || {}), - })) +function getStepCommands() { + return coreCommands + .flatMap((cmd) => { + if (cmd instanceof CommandGroup) { + return cmd.getSubCommands() + } else { + return [cmd] + } + }) + .filter((cmd) => cmd.workflows) } /** * Throws if one or more steps refers to a command that is not supported in workflows. */ function validateSteps(config: WorkflowConfig) { - const validStepCommandPrefixes = getStepCommandConfigs().map((c) => c.prefix) + const validStepCommandPrefixes = getStepCommands().map((c) => c.getPath()) + const invalidSteps: WorkflowStepSpec[] = config.steps.filter( (step) => !!step.command && !validStepCommandPrefixes.find((valid) => isEqual(valid, take(step.command, valid.length))) diff --git a/garden-service/src/docs/commands.ts b/garden-service/src/docs/commands.ts index 082a8b23b4..6346bf9f93 100644 --- a/garden-service/src/docs/commands.ts +++ b/garden-service/src/docs/commands.ts @@ -9,9 +9,9 @@ import { readFileSync, writeFileSync } from "fs" import handlebars from "handlebars" import { resolve } from "path" -import { GLOBAL_OPTIONS } from "../cli/cli" +import { globalOptions } from "../cli/params" import { coreCommands } from "../commands/commands" -import { describeParameters } from "../commands/base" +import { describeParameters, CommandGroup } from "../commands/base" import { TEMPLATES_DIR, renderConfigReference } from "./config" export function writeCommandReferenceDocs(docsRoot: string) { @@ -20,13 +20,11 @@ export function writeCommandReferenceDocs(docsRoot: string) { const commands = coreCommands .flatMap((cmd) => { - if (cmd.subCommands && cmd.subCommands.length) { - return cmd.subCommands - .map((subCommandCls) => { - const subCmd = new subCommandCls(cmd) - return subCmd.hidden ? null : subCmd.describe() - }) - .filter(Boolean) + if (cmd instanceof CommandGroup && cmd.subCommands?.length) { + return cmd + .getSubCommands() + .filter((c) => !c.hidden) + .map((c) => c.describe()) } else { return cmd.hidden ? [] : [cmd.describe()] } @@ -41,12 +39,10 @@ export function writeCommandReferenceDocs(docsRoot: string) { : null, })) - const globalOptions = describeParameters(GLOBAL_OPTIONS) - const templatePath = resolve(TEMPLATES_DIR, "commands.hbs") handlebars.registerPartial("argType", "{{#if choices}}{{#each choices}}`{{.}}` {{/each}}{{else}}{{type}}{{/if}}") const template = handlebars.compile(readFileSync(templatePath).toString()) - const markdown = template({ commands, globalOptions }) + const markdown = template({ commands, globalOptions: describeParameters(globalOptions) }) writeFileSync(outputPath, markdown) } diff --git a/garden-service/src/exceptions.ts b/garden-service/src/exceptions.ts index f5e44f2acf..67c22bd054 100644 --- a/garden-service/src/exceptions.ts +++ b/garden-service/src/exceptions.ts @@ -25,13 +25,15 @@ export abstract class GardenBaseError extends Error implements GardenError { } } -export function toGardenError(err: Error | GardenError): GardenError { +export function toGardenError(err: Error | GardenBaseError | string): GardenBaseError { if (err instanceof GardenBaseError) { return err - } else { + } else if (err instanceof Error) { const out = new RuntimeError(err.message, omit(err, ["message"])) out.stack = err.stack return out + } else { + return new RuntimeError(err, {}) } } diff --git a/garden-service/src/index.ts b/garden-service/src/index.ts index d94e54c173..c081ca7a87 100644 --- a/garden-service/src/index.ts +++ b/garden-service/src/index.ts @@ -8,4 +8,4 @@ const cli = require("./cli/cli") -cli.run() +cli.runCli() diff --git a/garden-service/src/logger/logger.ts b/garden-service/src/logger/logger.ts index 28f05c916a..1949ad6c71 100644 --- a/garden-service/src/logger/logger.ts +++ b/garden-service/src/logger/logger.ts @@ -15,15 +15,47 @@ import { LogLevel } from "./log-node" import { BasicTerminalWriter } from "./writers/basic-terminal-writer" import { FancyTerminalWriter } from "./writers/fancy-terminal-writer" import { JsonTerminalWriter } from "./writers/json-terminal-writer" -import { parseLogLevel } from "../cli/helpers" import { FullscreenTerminalWriter } from "./writers/fullscreen-terminal-writer" import { EventBus } from "../events" import { formatForEventStream } from "../enterprise/buffered-event-stream" import { gardenEnv } from "../constants" +import { getEnumKeys } from "../util/util" +import { range } from "lodash" export type LoggerType = "quiet" | "basic" | "fancy" | "fullscreen" | "json" export const LOGGER_TYPES = new Set(["quiet", "basic", "fancy", "fullscreen", "json"]) +const getLogLevelNames = () => getEnumKeys(LogLevel) +const getNumericLogLevels = () => range(getLogLevelNames().length) +// Allow string or numeric log levels as CLI choices +export const getLogLevelChoices = () => [...getLogLevelNames(), ...getNumericLogLevels().map(String)] + +export function parseLogLevel(level: string): LogLevel { + let lvl: LogLevel + const parsed = parseInt(level, 10) + // Level is numeric + if (parsed || parsed === 0) { + lvl = parsed + // Level is a string + } else { + lvl = LogLevel[level] + } + if (!getNumericLogLevels().includes(lvl)) { + throw new InternalError( + `Unexpected log level, expected one of ${getLogLevelChoices().join(", ")}, got ${level}`, + {} + ) + } + return lvl +} + +// Add platforms/terminals? +export function envSupportsEmoji() { + return ( + process.platform === "darwin" || process.env.TERM_PROGRAM === "Hyper" || process.env.TERM_PROGRAM === "HyperTerm" + ) +} + export function getWriterInstance(loggerType: LoggerType, level: LogLevel) { switch (loggerType) { case "basic": diff --git a/garden-service/src/server/commands.ts b/garden-service/src/server/commands.ts index 7699d0039c..02b15dcc68 100644 --- a/garden-service/src/server/commands.ts +++ b/garden-service/src/server/commands.ts @@ -8,14 +8,14 @@ import Joi = require("@hapi/joi") import Koa = require("koa") -import { Command, Parameters, ParameterValues } from "../commands/base" +import { Command, CommandGroup } from "../commands/base" import { joi } from "../config/common" import { validateSchema } from "../config/validation" import { extend, mapValues, omitBy } from "lodash" import { Garden } from "../garden" import { LogLevel } from "../logger/log-node" import { LogEntry } from "../logger/log-entry" -import { GLOBAL_OPTIONS } from "../cli/cli" +import { Parameters, ParameterValues, globalOptions } from "../cli/params" export interface CommandMap { [key: string]: { @@ -77,7 +77,7 @@ export async function resolveRequest( const cmdLog = log.placeholder({ level: LogLevel.silly, childEntriesInheritLevel: true }) const cmdArgs = mapParams(ctx, request.parameters, command.arguments) - const optParams = extend({ ...GLOBAL_OPTIONS, ...command.options }) + const optParams = extend({ ...globalOptions, ...command.options }) const cmdOpts = mapParams(ctx, request.parameters, optParams) // TODO: validate result schema @@ -100,7 +100,7 @@ export async function prepareCommands(): Promise { .object() .keys({ ...paramsToJoi(command.arguments), - ...paramsToJoi({ ...GLOBAL_OPTIONS, ...command.options }), + ...paramsToJoi({ ...globalOptions, ...command.options }), }) .unknown(false), }) @@ -110,7 +110,9 @@ export async function prepareCommands(): Promise { requestSchema, } - command.getSubCommands().forEach(addCommand) + if (command instanceof CommandGroup) { + command.getSubCommands().forEach(addCommand) + } } // Need to import this here to avoid circular import issues diff --git a/garden-service/src/util/profiling.ts b/garden-service/src/util/profiling.ts index 035e4f8726..c34b3afa2b 100644 --- a/garden-service/src/util/profiling.ts +++ b/garden-service/src/util/profiling.ts @@ -13,7 +13,7 @@ import { renderTable, tablePresets } from "./string" import chalk from "chalk" import { isPromise } from "./util" -const maxReportRows = 50 +const maxReportRows = 30 // Just storing the invocation duration for now type Invocation = number diff --git a/garden-service/test/helpers.ts b/garden-service/test/helpers.ts index ac78a8e13c..c8a75afbe9 100644 --- a/garden-service/test/helpers.ts +++ b/garden-service/test/helpers.ts @@ -32,7 +32,7 @@ import { EventBus, Events } from "../src/events" import { ValueOf, exec, findByName, getNames, isPromise } from "../src/util/util" import { LogEntry } from "../src/logger/log-entry" import timekeeper = require("timekeeper") -import { GLOBAL_OPTIONS, GlobalOptions } from "../src/cli/cli" +import { ParameterValues, globalOptions, GlobalOptions } from "../src/cli/params" import { RunModuleParams } from "../src/types/plugin/module/runModule" import { ConfigureModuleParams } from "../src/types/plugin/module/configure" import { SetSecretParams } from "../src/types/plugin/provider/setSecret" @@ -43,7 +43,7 @@ import { RunResult } from "../src/types/plugin/base" import { ExternalSourceType, getRemoteSourceRelPath, hashRepoUrl } from "../src/util/ext-source-util" import { ConfigureProviderParams } from "../src/types/plugin/provider/configureProvider" import { ActionRouter } from "../src/actions" -import { ParameterValues, ProcessCommandResult } from "../src/commands/base" +import { ProcessCommandResult } from "../src/commands/base" import stripAnsi from "strip-ansi" import { RunTaskParams, RunTaskResult } from "../src/types/plugin/task/runTask" import { SuiteFunction, TestFunction } from "mocha" @@ -497,7 +497,7 @@ export function getExampleProjects() { export function withDefaultGlobalOpts(opts: T) { return & T>extend( - mapValues(GLOBAL_OPTIONS, (opt) => opt.defaultValue), + mapValues(globalOptions, (opt) => opt.defaultValue), opts ) } @@ -592,6 +592,16 @@ export async function makeTempDir({ git = false }: { git?: boolean } = {}): Prom return tmpDir } +/** + * Trims the ends of each line of the given input string (useful for multi-line string comparisons) + */ +export function trimLineEnds(str: string) { + return str + .split("\n") + .map((line) => line.trimRight()) + .join("\n") +} + /** * Retrieves all the child log entries from the given LogEntry and returns a list of all the messages, * stripped of ANSI characters. Useful to check if a particular message was logged. diff --git a/garden-service/test/setup.ts b/garden-service/test/setup.ts index 35b40aaba6..e4d2abe05d 100644 --- a/garden-service/test/setup.ts +++ b/garden-service/test/setup.ts @@ -12,6 +12,7 @@ import { Logger } from "../src/logger/logger" import { LogLevel } from "../src/logger/log-node" import { makeTestGardenA } from "./helpers" import { getDefaultProfiler } from "../src/util/profiling" +import { gardenEnv } from "../src/constants" // import { BasicTerminalWriter } from "../src/logger/writers/basic-terminal-writer" // make sure logger is initialized @@ -26,6 +27,7 @@ try { // Global hooks before(async () => { getDefaultProfiler().setEnabled(true) + gardenEnv.GARDEN_DISABLE_ANALYTICS = true // doing this to make sure ts-node completes compilation before running tests await makeTestGardenA() diff --git a/garden-service/test/unit/src/cli/base.ts b/garden-service/test/unit/src/cli/base.ts deleted file mode 100644 index c460e89a06..0000000000 --- a/garden-service/test/unit/src/cli/base.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (C) 2018-2020 Garden Technologies, Inc. - * - * 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, StringsParameter } 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), - }) - }) -}) - -describe("StringsParameter", () => { - it("should by default split on a comma", () => { - const param = new StringsParameter({ help: "" }) - expect(param.parseString("service-a,service-b")).to.eql(["service-a", "service-b"]) - }) - - it("should not split on commas within double-quoted strings", () => { - const param = new StringsParameter({ help: "" }) - expect(param.parseString('key-a="comma,in,value",key-b=foo,key-c=bar')).to.eql([ - 'key-a="comma,in,value"', - "key-b=foo", - "key-c=bar", - ]) - }) -}) diff --git a/garden-service/test/unit/src/cli/cli.ts b/garden-service/test/unit/src/cli/cli.ts index a16883bdf2..7ca8246177 100644 --- a/garden-service/test/unit/src/cli/cli.ts +++ b/garden-service/test/unit/src/cli/cli.ts @@ -13,12 +13,43 @@ import { isEqual } from "lodash" import { makeDummyGarden, GardenCli } from "../../../../src/cli/cli" import { getDataDir, TestGarden, makeTestGardenA, enableAnalytics } from "../../../helpers" import { GARDEN_SERVICE_ROOT } from "../../../../src/constants" -import { join } from "path" -import { Command } from "../../../../src/commands/base" +import { join, resolve } from "path" +import { Command, CommandGroup } from "../../../../src/commands/base" +import { getPackageVersion } from "../../../../src/util/util" +import { UtilCommand } from "../../../../src/commands/util" +import { StringParameter } from "../../../../src/cli/params" +import stripAnsi from "strip-ansi" +import { ToolsCommand } from "../../../../src/commands/tools" +import { envSupportsEmoji } from "../../../../src/logger/logger" +import { safeLoad } from "js-yaml" describe("cli", () => { describe("run", () => { - it("should pass unparsed args to commands", async () => { + it("aborts with help text if no positional argument is provided", async () => { + const cli = new GardenCli() + const { code, consoleOutput } = await cli.run({ args: [], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(cli.renderHelp()) + }) + + it("aborts with default help text if -h option is set and no command", async () => { + const cli = new GardenCli() + const { code, consoleOutput } = await cli.run({ args: ["-h"], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(cli.renderHelp()) + }) + + it("aborts with default help text if --help option is set and no command", async () => { + const cli = new GardenCli() + const { code, consoleOutput } = await cli.run({ args: ["-h"], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(cli.renderHelp()) + }) + + it("aborts with command help text if --help option is set and command is specified", async () => { class TestCommand extends Command { name = "test-command" help = "halp!" @@ -29,12 +60,329 @@ describe("cli", () => { } } - const command = new TestCommand() const cli = new GardenCli() - cli.addCommand(command, cli["program"]) + const cmd = new TestCommand() + cli.addCommand(cmd) + + const { code, consoleOutput } = await cli.run({ args: ["test-command", "--help"], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(cmd.renderHelp()) + }) + + it("aborts with version text if -v is set", async () => { + const cli = new GardenCli() + const { code, consoleOutput } = await cli.run({ args: ["-v"], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(getPackageVersion()) + }) + + it("aborts with version text if --version is set", async () => { + const cli = new GardenCli() + const { code, consoleOutput } = await cli.run({ args: ["--version"], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(getPackageVersion()) + }) + + it("aborts with version text if version is first argument", async () => { + const cli = new GardenCli() + const { code, consoleOutput } = await cli.run({ args: ["version"], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(getPackageVersion()) + }) + + it("shows group help text if specified command is a group", async () => { + const cli = new GardenCli() + const cmd = new UtilCommand() + const { code, consoleOutput } = await cli.run({ args: ["util"], exitOnError: false }) + + expect(code).to.equal(0) + expect(consoleOutput).to.equal(cmd.renderHelp()) + }) + + it("picks and runs a command", async () => { + class TestCommand extends Command { + name = "test-command" + help = "halp!" + noProject = true + + async action({}) { + return { result: { something: "important" } } + } + } + + const cli = new GardenCli() + const cmd = new TestCommand() + cli.addCommand(cmd) + + const { code, result } = await cli.run({ args: ["test-command"], exitOnError: false }) - const { result } = await cli.parse(["test-command", "some", "args"]) - expect(result).to.eql({ args: { _: ["some", "args"] } }) + expect(code).to.equal(0) + expect(result).to.eql({ something: "important" }) + }) + + it("picks and runs a subcommand in a group", async () => { + class TestCommand extends Command { + name = "test-command" + help = "halp!" + noProject = true + + async action({}) { + return { result: { something: "important" } } + } + } + class TestGroup extends CommandGroup { + name = "test-group" + help = "" + + subCommands = [TestCommand] + } + + const cli = new GardenCli() + const group = new TestGroup() + + for (const cmd of group.getSubCommands()) { + cli.addCommand(cmd) + } + + const { code, result } = await cli.run({ args: ["test-group", "test-command"], exitOnError: false }) + + expect(code).to.equal(0) + expect(result).to.eql({ something: "important" }) + }) + + it("correctly parses and passes global options", async () => { + class TestCommand extends Command { + name = "test-command" + alias = "some-alias" + help = "" + noProject = true + + async action({ args, opts }) { + return { result: { args, opts } } + } + } + + const cli = new GardenCli() + const cmd = new TestCommand() + cli.addCommand(cmd) + + const { code, result } = await cli.run({ + args: [ + "test-command", + "--root", + "..", + "--silent", + "--env=default", + "--logger-type", + "basic", + "-l=4", + "--output", + "json", + "--yes", + "--emoji=false", + "--force-refresh", + "--var", + "my=value,other=something", + ], + exitOnError: false, + }) + + expect(code).to.equal(0) + expect(result).to.eql({ + args: { _: [] }, + opts: { + "root": resolve(process.cwd(), ".."), + "silent": true, + "env": "default", + "logger-type": "basic", + "log-level": "4", + "output": "json", + "emoji": false, + "yes": true, + "force-refresh": true, + "var": ["my=value", "other=something"], + "version": false, + "help": false, + }, + }) + }) + + it("correctly parses and passes arguments and options for a command", async () => { + class TestCommand extends Command { + name = "test-command" + alias = "some-alias" + help = "" + noProject = true + + arguments = { + foo: new StringParameter({ + help: "Some help text.", + required: true, + }), + bar: new StringParameter({ + help: "Another help text.", + }), + } + + options = { + floop: new StringParameter({ + help: "Option help text.", + }), + } + + async action({ args, opts }) { + return { result: { args, opts } } + } + } + + const cli = new GardenCli() + const cmd = new TestCommand() + cli.addCommand(cmd) + + const { code, result } = await cli.run({ + args: ["test-command", "foo-arg", "bar-arg", "--floop", "floop-opt"], + exitOnError: false, + }) + + expect(code).to.equal(0) + expect(result).to.eql({ + args: { _: [], foo: "foo-arg", bar: "bar-arg" }, + opts: { + "root": process.cwd(), + "silent": false, + "env": undefined, + "logger-type": undefined, + "log-level": "info", + "output": undefined, + "emoji": envSupportsEmoji(), + "yes": false, + "force-refresh": false, + "var": undefined, + "version": false, + "help": false, + "floop": "floop-opt", + }, + }) + }) + + it("correctly parses and passes arguments and options for a subcommand", async () => { + class TestCommand extends Command { + name = "test-command" + alias = "some-alias" + help = "" + noProject = true + + arguments = { + foo: new StringParameter({ + help: "Some help text.", + required: true, + }), + bar: new StringParameter({ + help: "Another help text.", + }), + } + + options = { + floop: new StringParameter({ + help: "Option help text.", + }), + } + + async action({ args, opts }) { + return { result: { args, opts } } + } + } + + class TestGroup extends CommandGroup { + name = "test-group" + help = "" + + subCommands = [TestCommand] + } + + const cli = new GardenCli() + const group = new TestGroup() + + for (const cmd of group.getSubCommands()) { + cli.addCommand(cmd) + } + + const { code, result } = await cli.run({ + args: ["test-group", "test-command", "foo-arg", "bar-arg", "--floop", "floop-opt"], + exitOnError: false, + }) + + expect(code).to.equal(0) + expect(result).to.eql({ + args: { _: [], foo: "foo-arg", bar: "bar-arg" }, + opts: { + "root": process.cwd(), + "silent": false, + "env": undefined, + "logger-type": undefined, + "log-level": "info", + "output": undefined, + "emoji": envSupportsEmoji(), + "yes": false, + "force-refresh": false, + "var": undefined, + "version": false, + "help": false, + "floop": "floop-opt", + }, + }) + }) + + it("aborts with usage information on invalid global options", async () => { + const cli = new GardenCli() + const cmd = new ToolsCommand() + const { code, consoleOutput } = await cli.run({ args: ["tools", "--logger-type", "bla"], exitOnError: false }) + + const stripped = stripAnsi(consoleOutput!).trim() + + expect(code).to.equal(1) + expect( + stripped.startsWith( + 'Invalid value for option --logger-type: "bla" is not a valid argument (should be any of "quiet", "basic", "fancy", "fullscreen", "json")' + ) + ).to.be.true + expect(consoleOutput).to.include(cmd.renderHelp()) + }) + + it("aborts with usage information on missing/invalid command arguments and options", async () => { + class TestCommand extends Command { + name = "test-command" + alias = "some-alias" + help = "" + noProject = true + + arguments = { + foo: new StringParameter({ + help: "Some help text.", + required: true, + }), + } + + async action({ args, opts }) { + return { result: { args, opts } } + } + } + + const cli = new GardenCli() + const cmd = new TestCommand() + cli.addCommand(cmd) + + const { code, consoleOutput } = await cli.run({ args: ["test-command"], exitOnError: false }) + + const stripped = stripAnsi(consoleOutput!).trim() + + expect(code).to.equal(1) + expect(stripped.startsWith("Missing required argument foo")).to.be.true + expect(consoleOutput).to.include(cmd.renderHelp()) }) it("should not parse args after -- and instead pass directly to commands", async () => { @@ -50,9 +398,9 @@ describe("cli", () => { const command = new TestCommand() const cli = new GardenCli() - cli.addCommand(command, cli["program"]) + cli.addCommand(command) - const { result } = await cli.parse(["test-command", "--", "-v", "--flag", "arg"]) + const { result } = await cli.run({ args: ["test-command", "--", "-v", "--flag", "arg"], exitOnError: false }) expect(result).to.eql({ args: { _: ["-v", "--flag", "arg"] } }) }) @@ -69,12 +417,53 @@ describe("cli", () => { const command = new TestCommand() const cli = new GardenCli() - cli.addCommand(command, cli["program"]) + cli.addCommand(command) - const { result } = await cli.parse(["test-command-var", "--var", 'key-a=value-a,key-b="value with quotes"']) + const { result } = await cli.run({ + args: ["test-command-var", "--var", 'key-a=value-a,key-b="value with quotes"'], + exitOnError: false, + }) expect(result).to.eql({ variables: { "key-a": "value-a", "key-b": "value with quotes" } }) }) + it("should output JSON if --output=json", async () => { + class TestCommand extends Command { + name = "test-command" + help = "halp!" + noProject = true + + async action() { + return { result: { some: "output" } } + } + } + + const command = new TestCommand() + const cli = new GardenCli() + cli.addCommand(command) + + const { consoleOutput } = await cli.run({ args: ["test-command", "--output=json"], exitOnError: false }) + expect(JSON.parse(consoleOutput!)).to.eql({ result: { some: "output" }, success: true }) + }) + + it("should output YAML if --output=json", async () => { + class TestCommand extends Command { + name = "test-command" + help = "halp!" + noProject = true + + async action() { + return { result: { some: "output" } } + } + } + + const command = new TestCommand() + const cli = new GardenCli() + cli.addCommand(command) + + const { consoleOutput } = await cli.run({ args: ["test-command", "--output=yaml"], exitOnError: false }) + expect(safeLoad(consoleOutput!)).to.eql({ result: { some: "output" }, success: true }) + }) + it(`should configure a dummy environment when command has noProject=true and --env is specified`, async () => { class TestCommand2 extends Command { name = "test-command-2" @@ -88,9 +477,9 @@ describe("cli", () => { const command = new TestCommand2() const cli = new GardenCli() - cli.addCommand(command, cli["program"]) + cli.addCommand(command) - const { result, errors } = await cli.parse(["test-command-2", "--env", "missing-env"], false) + const { result, errors } = await cli.run({ args: ["test-command-2", "--env", "missing-env"], exitOnError: false }) expect(errors).to.eql([]) expect(result).to.eql({ environmentName: "missing-env" }) }) @@ -108,12 +497,13 @@ describe("cli", () => { const command = new TestCommand3() const cli = new GardenCli() - cli.addCommand(command, cli["program"]) + cli.addCommand(command) + + const { errors } = await cli.run({ args: ["test-command-3", "--env", "$.%"], exitOnError: false }) - const { errors } = await cli.parse(["test-command-3", "--env", "$.%"], false) expect(errors.length).to.equal(1) - expect(errors[0].message).to.equal( - "Invalid environment specified ($.%): must be a valid environment name or ." + expect(stripAnsi(errors[0].message)).to.equal( + "Invalid value for option --env: Invalid environment specified ($.%): must be a valid environment name or ." ) }) @@ -146,7 +536,7 @@ describe("cli", () => { const command = new TestCommand() const cli = new GardenCli() - cli.addCommand(command, cli["program"]) + cli.addCommand(command) scope .post(`/v1/batch`, (body) => { @@ -164,7 +554,7 @@ describe("cli", () => { ]) }) .reply(200) - await cli.parse(["test-command"]) + await cli.run({ args: ["test-command"], exitOnError: false }) expect(scope.done()).to.not.throw }) diff --git a/garden-service/test/unit/src/cli/helpers.ts b/garden-service/test/unit/src/cli/helpers.ts index e92cb8c4ca..01a9dfb7b6 100644 --- a/garden-service/test/unit/src/cli/helpers.ts +++ b/garden-service/test/unit/src/cli/helpers.ts @@ -7,43 +7,422 @@ */ import { expect } from "chai" -import { parseLogLevel, getLogLevelChoices } from "../../../../src/cli/helpers" +import { pickCommand, processCliArgs } from "../../../../src/cli/helpers" +import { Parameters } from "../../../../src/cli/params" import { expectError } from "../../../helpers" import { getPackageVersion } from "../../../../src/util/util" import { GARDEN_SERVICE_ROOT } from "../../../../src/constants" import { join } from "path" +import { TestGarden, makeTestGardenA, withDefaultGlobalOpts } from "../../../helpers" +import { DeployCommand } from "../../../../src/commands/deploy" +import { parseCliArgs } from "../../../../src/cli/helpers" +import { LogEntry } from "../../../../src/logger/log-entry" +import { DeleteServiceCommand } from "../../../../src/commands/delete" +import { GetOutputsCommand } from "../../../../src/commands/get/get-outputs" +import { TestCommand } from "../../../../src/commands/test" +import { RunTaskCommand } from "../../../../src/commands/run/task" +import { RunTestCommand } from "../../../../src/commands/run/test" +import { PublishCommand } from "../../../../src/commands/publish" +import { BuildCommand } from "../../../../src/commands/build" +import { getLogLevelChoices, parseLogLevel } from "../../../../src/logger/logger" +import stripAnsi from "strip-ansi" +import { Command } from "../../../../src/commands/base" +import { dedent } from "../../../../src/util/string" +import { LogsCommand } from "../../../../src/commands/logs" +import { getAllCommands } from "../../../../src/commands/commands" -describe("helpers", () => { - const validLogLevels = ["error", "warn", "info", "verbose", "debug", "silly", "0", "1", "2", "3", "4", "5"] +const validLogLevels = ["error", "warn", "info", "verbose", "debug", "silly", "0", "1", "2", "3", "4", "5"] - describe("getPackageVersion", () => { - it("should return the version in package.json", async () => { - const version = require(join(GARDEN_SERVICE_ROOT, "package.json")).version - expect(getPackageVersion()).to.eq(version) +describe("getPackageVersion", () => { + it("should return the version in package.json", async () => { + const version = require(join(GARDEN_SERVICE_ROOT, "package.json")).version + expect(getPackageVersion()).to.eq(version) + }) +}) + +describe("getLogLevelChoices", () => { + it("should return all valid log levels as strings", async () => { + const choices = getLogLevelChoices().sort() + const sorted = [...validLogLevels].sort() + expect(choices).to.eql(sorted) + }) +}) + +describe("parseLogLevel", () => { + it("should return a level integer if valid", async () => { + const parsed = validLogLevels.map((el) => parseLogLevel(el)) + expect(parsed).to.eql([0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5]) + }) + it("should throw if level is not valid", async () => { + await expectError(() => parseLogLevel("banana"), "internal") + }) + it("should throw if level is not valid", async () => { + await expectError(() => parseLogLevel("-1"), "internal") + }) + it("should throw if level is not valid", async () => { + await expectError(() => parseLogLevel(""), "internal") + }) +}) + +describe("pickCommand", () => { + const commands = getAllCommands() + + it("picks a command and returns the rest of arguments", () => { + const { command, rest } = pickCommand(commands, ["build", "foo", "--force"]) + expect(command?.getPath()).to.eql(["build"]) + expect(rest).to.eql(["foo", "--force"]) + }) + + it("picks a subcommand and returns the rest of arguments", () => { + const { command, rest } = pickCommand(commands, ["run", "workflow", "foo", "--force"]) + expect(command?.getPath()).to.eql(["run", "workflow"]) + expect(rest).to.eql(["foo", "--force"]) + }) + + it("picks a command with an alias", () => { + const { command, rest } = pickCommand(commands, ["del", "env", "foo", "--force"]) + expect(command?.getPath()).to.eql(["delete", "environment"]) + expect(rest).to.eql(["foo", "--force"]) + }) + + it("returns undefined command if none is found", () => { + const args = ["bla", "ble"] + const { command, rest } = pickCommand(commands, args) + expect(command).to.be.undefined + expect(rest).to.eql(args) + }) +}) + +describe("parseCliArgs", () => { + it("parses string arguments and returns a mapping", () => { + const argv = parseCliArgs({ stringArgs: ["build", "my-module", "--force", "-l=5"], cli: true }) + + expect(argv._).to.eql(["build", "my-module"]) + expect(argv.force).to.be.true + expect(argv["log-level"]).to.equal("5") + }) + + it("correctly handles global boolean options", () => { + const argv = parseCliArgs({ + stringArgs: ["build", "my-module", "--force-refresh", "--silent=false", "-y"], + cli: true, + }) + + expect(argv["force-refresh"]).to.be.true + expect(argv.silent).to.be.false + expect(argv.yes).to.be.true + }) + + it("correctly handles command boolean options", () => { + const cmd = new BuildCommand() + const argv = parseCliArgs({ stringArgs: ["build", "my-module", "-f", "--watch"], command: cmd, cli: true }) + + expect(argv.force).to.be.true + expect(argv.watch).to.be.true + }) + + it("sets empty string value instead of boolean for string options", () => { + const cmd = new DeployCommand() + const argv = parseCliArgs({ stringArgs: ["deploy", "--hot"], command: cmd, cli: true }) + + expect(argv["hot-reload"]).to.equal("") + }) + + it("sets default global option values", () => { + const cmd = new DeployCommand() + const argv = parseCliArgs({ stringArgs: [], command: cmd, cli: true }) + + expect(argv.silent).to.be.false + expect(argv.root).to.equal(process.cwd()) + }) + + it("sets default command option values", () => { + const cmd = new BuildCommand() + const argv = parseCliArgs({ stringArgs: [], command: cmd, cli: true }) + + expect(argv.force).to.be.false + expect(argv.watch).to.be.false + }) + + it("sets prefers cliDefault over defaultValue when cli=true", () => { + const cmd = new RunTestCommand() + const argv = parseCliArgs({ stringArgs: [], command: cmd, cli: true }) + + expect(argv.interactive).to.be.true + }) + + it("sets prefers defaultValue over cliDefault when cli=false", () => { + const cmd = new RunTestCommand() + const argv = parseCliArgs({ stringArgs: [], command: cmd, cli: false }) + + expect(argv.interactive).to.be.false + }) +}) + +describe("processCliArgs", () => { + let garden: TestGarden + let log: LogEntry + let defaultActionParams: any + + before(async () => { + garden = await makeTestGardenA() + log = garden.log + defaultActionParams = { + garden, + log, + headerLog: log, + footerLog: log, + } + }) + + function parseAndProcess( + args: string[], + command: Command, + cli = true + ) { + return processCliArgs({ parsedArgs: parseCliArgs({ stringArgs: args, command, cli }), command, cli }) + } + + it("correctly handles blank arguments", () => { + const cmd = new BuildCommand() + const { args } = parseAndProcess([], cmd) + expect(args._).to.eql([]) + expect(args.modules).to.be.undefined + }) + + it("correctly handles command option flags", () => { + const cmd = new DeployCommand() + const { opts } = parseAndProcess(["--force-build=true", "--watch"], cmd) + expect(opts["force-build"]).to.be.true + expect(opts.watch).to.be.true + }) + + it("correctly handles option aliases", () => { + const cmd = new DeployCommand() + const { opts } = parseAndProcess(["-w", "--force-build=false"], cmd) + expect(opts.watch).to.be.true + expect(opts["force-build"]).to.be.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 last option when non-array option is repeated", () => { + const cmd = new DeployCommand() + const { opts } = parseAndProcess(["--force-build=false", "--force-build=true"], cmd) + expect(opts["force-build"]).to.be.true + }) + + it("correctly handles positional arguments", () => { + const cmd = new BuildCommand() + const { args } = parseAndProcess(["my-module"], cmd) + expect(args.modules).to.eql(["my-module"]) + }) + + it("correctly handles global option flags", () => { + const cmd = new BuildCommand() + const { opts } = parseAndProcess(["--log-level", "debug", "--logger-type=basic"], cmd) + expect(opts["logger-type"]).to.equal("basic") + expect(opts["log-level"]).to.equal("debug") + }) + + // TODO: do this after the refactor is done and tested + // it("should handle a variadic argument spec", async () => { + // const argSpec = { + // first: new StringParameter({ + // help: "Some help text.", + // }), + // rest: new StringsParameter({ + // help: "Some help text.", + // variadic: true, + // }), + // } + + // class VarCommand extends Command { + // name = "var-command" + // help = "halp!" + // noProject = true + + // arguments = argSpec + + // async action(params) { + // return { result: params } + // } + // } + + // const cmd = new VarCommand() + // const { args } = parseAndProcess(["test-command", "something", "a", "b", "c"], cmd) + + // expect(args.first).to.equal("something") + // expect(args.rest).to.eql(["a", "b", "c"]) + // }) + + it("throws an error when a required positional argument is missing", () => { + const cmd = new DeleteServiceCommand() + expectError( + () => parseAndProcess([], cmd), + (err) => expect(stripAnsi(err.message)).to.equal("Missing required argument services") + ) + }) + + it("throws an error when an unexpected positional argument is given", () => { + const cmd = new DeleteServiceCommand() + expectError( + () => parseAndProcess(["my-service", "bla"], cmd), + (err) => expect(stripAnsi(err.message)).to.equal(`Unexpected positional argument "bla" (expected only services)`) + ) + }) + + it("throws an error when an unrecognized option is set", () => { + const cmd = new BuildCommand() + expectError( + () => parseAndProcess(["--foo=bar"], cmd), + (err) => expect(stripAnsi(err.message)).to.equal("Unrecognized option flag --foo") + ) + }) + + it("throws an error when an invalid argument is given to a choice option", () => { + const cmd = new BuildCommand() + expectError( + () => parseAndProcess(["--logger-type=foo"], cmd), + (err) => + expect(stripAnsi(err.message)).to.equal( + 'Invalid value for option --logger-type: "foo" is not a valid argument (should be any of "quiet", "basic", "fancy", "fullscreen", "json")' + ) + ) + }) + + it("throws an error when an invalid argument is given to an integer option", () => { + const cmd = new LogsCommand() + expectError( + () => parseAndProcess(["--tail=foo"], cmd), + (err) => + expect(stripAnsi(err.message)).to.equal('Invalid value for option --tail: Could not parse "foo" as integer') + ) + }) + + it("ignores cliOnly options when cli=false", () => { + const cmd = new RunTestCommand() + const { opts } = parseAndProcess(["my-module", "my-test", "--interactive=true"], cmd, false) + expect(opts.interactive).to.be.false + }) + + it("sets default values for command flags", () => { + const cmd = new BuildCommand() + const { opts } = parseAndProcess([], cmd) + expect(opts.force).to.be.false + expect(opts.watch).to.be.false + }) + + it("sets default values for global flags", () => { + const cmd = new BuildCommand() + const { opts } = parseAndProcess([], cmd) + expect(opts.silent).to.be.false + expect(opts.root).to.equal(process.cwd()) + }) + + it("prefers defaultValue value over cliDefault when cli=false", () => { + const cmd = new RunTestCommand() + const { opts } = parseAndProcess(["my-module", "my-test"], cmd, false) + expect(opts.interactive).to.be.false + }) + + it("prefers cliDefault value over defaultValue when cli=true", () => { + const cmd = new RunTestCommand() + const { opts } = parseAndProcess(["my-module", "my-test"], cmd, true) + expect(opts.interactive).to.be.true + }) + + it("throws with all found errors if applicable", () => { + const cmd = new RunTestCommand() + expectError( + () => parseAndProcess(["--foo=bar", "--interactive=9"], cmd), + (err) => + expect(stripAnsi(err.message)).to.equal(dedent` + Missing required argument module + Missing required argument test + Unrecognized option flag --foo + `) + ) + }) + + it("parses args and opts for a DeployCommand", async () => { + const cmd = new DeployCommand() + + const { args, opts } = parseAndProcess(["service-a,service-b", "--force-build=true"], cmd) + + await cmd.action({ + ...defaultActionParams, + args, + opts: withDefaultGlobalOpts(opts), + }) + + const { args: args2, opts: opts2 } = parseAndProcess(["service-a", "--hot=service-a"], cmd) + + await cmd.action({ + ...defaultActionParams, + args: args2, + opts: withDefaultGlobalOpts(opts2), }) }) - describe("getLogLevelChoices", () => { - it("should return all valid log levels as strings", async () => { - const choices = getLogLevelChoices().sort() - const sorted = [...validLogLevels].sort() - expect(choices).to.eql(sorted) + it("parses args and opts for a DeleteServiceCommand", async () => { + const cmd = new DeleteServiceCommand() + const { args, opts } = parseAndProcess(["service-a"], cmd) + await cmd.action({ + ...defaultActionParams, + args, + opts: withDefaultGlobalOpts(opts), }) }) - describe("parseLogLevel", () => { - it("should return a level integer if valid", async () => { - const parsed = validLogLevels.map((el) => parseLogLevel(el)) - expect(parsed).to.eql([0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5]) + it("parses args and opts for a GetOutputsCommand", async () => { + const cmd = new GetOutputsCommand() + const { args, opts } = parseAndProcess([], cmd) + await cmd.action({ + ...defaultActionParams, + args, + opts: withDefaultGlobalOpts(opts), }) - it("should throw if level is not valid", async () => { - await expectError(() => parseLogLevel("banana"), "internal") + }) + + it("parses args and opts for a TestCommand", async () => { + const cmd = new TestCommand() + const { args, opts } = parseAndProcess(["module-a,module-b", "-n unit"], cmd) + await cmd.action({ + ...defaultActionParams, + args, + opts: withDefaultGlobalOpts(opts), }) - it("should throw if level is not valid", async () => { - await expectError(() => parseLogLevel("-1"), "internal") + }) + + it("parses args and opts for a RunTaskCommand", async () => { + const cmd = new RunTaskCommand() + const { args, opts } = parseAndProcess(["task-b"], cmd) + await cmd.action({ + ...defaultActionParams, + args, + opts: withDefaultGlobalOpts(opts), + }) + }) + + it("parses args and opts for a RunTestCommand", async () => { + const cmd = new RunTestCommand() + const { args, opts } = parseAndProcess(["module-b", "unit", "--interactive"], cmd) + await cmd.action({ + ...defaultActionParams, + args, + opts: withDefaultGlobalOpts(opts), }) - it("should throw if level is not valid", async () => { - await expectError(() => parseLogLevel(""), "internal") + }) + + it("parses args and opts for a PublishCommand", async () => { + const cmd = new PublishCommand() + const { args, opts } = parseAndProcess(["module-a,module-b", "--allow-dirty"], cmd) + await cmd.action({ + ...defaultActionParams, + args, + opts: withDefaultGlobalOpts(opts), }) }) }) diff --git a/garden-service/test/unit/src/cli/params.ts b/garden-service/test/unit/src/cli/params.ts new file mode 100644 index 0000000000..7b2c940888 --- /dev/null +++ b/garden-service/test/unit/src/cli/params.ts @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2018-2020 Garden Technologies, Inc. + * + * 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 { StringsParameter } from "../../../../src/cli/params" + +describe("StringsParameter", () => { + it("should by default split on a comma", () => { + const param = new StringsParameter({ help: "" }) + expect(param.coerce("service-a,service-b")).to.eql(["service-a", "service-b"]) + }) + + it("should not split on commas within double-quoted strings", () => { + const param = new StringsParameter({ help: "" }) + expect(param.coerce('key-a="comma,in,value",key-b=foo,key-c=bar')).to.eql([ + 'key-a="comma,in,value"', + "key-b=foo", + "key-c=bar", + ]) + }) +}) diff --git a/garden-service/test/unit/src/commands/base.ts b/garden-service/test/unit/src/commands/base.ts new file mode 100644 index 0000000000..d1e0ea5f5f --- /dev/null +++ b/garden-service/test/unit/src/commands/base.ts @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2018-2020 Garden Technologies, Inc. + * + * 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 { Command, CommandGroup } from "../../../../src/commands/base" +import { StringsParameter } from "../../../../src/cli/params" +import stripAnsi from "strip-ansi" +import { dedent } from "../../../../src/util/string" +import { trimLineEnds } from "../../../helpers" + +describe("Command", () => { + describe("renderHelp", () => { + it("renders the command help text", async () => { + class TestCommand extends Command { + name = "test-command" + alias = "some-alias" + help = "" + + arguments = { + foo: new StringsParameter({ + help: "Some help text.", + required: true, + }), + bar: new StringsParameter({ + help: "Another help text.", + }), + } + + options = { + floop: new StringsParameter({ + help: "Option help text.", + }), + } + + async action() { + return {} + } + } + const cmd = new TestCommand() + + expect(trimLineEnds(stripAnsi(cmd.renderHelp())).trim()).to.equal(dedent` + USAGE + garden test-command [bar] [options] + + ARGUMENTS + [bar] Another help text. + [array:string] + Some help text. + [array:string] + + OPTIONS + --floop Option help text. + [array:string] + `) + }) + }) + + describe("getPaths", () => { + it("returns the command path if not part of a group", () => { + class TestCommand extends Command { + name = "test-command" + help = "" + + async action() { + return {} + } + } + const cmd = new TestCommand() + expect(cmd.getPaths()).to.eql([["test-command"]]) + }) + + it("returns the command path and alias if set and not part of a group", () => { + class TestCommand extends Command { + name = "test-command" + alias = "some-alias" + help = "" + + async action() { + return {} + } + } + const cmd = new TestCommand() + expect(cmd.getPaths()).to.eql([["test-command"], ["some-alias"]]) + }) + + it("returns the full command path if part of a group", () => { + class TestCommand extends Command { + name = "test-command" + help = "" + + async action() { + return {} + } + } + class TestGroup extends CommandGroup { + name = "test-group" + help = "" + + subCommands = [TestCommand] + } + const cmd = new TestCommand(new TestGroup()) + expect(cmd.getPaths()).to.eql([["test-group", "test-command"]]) + }) + + it("returns the full command path if part of a group that has an alias", () => { + class TestCommand extends Command { + name = "test-command" + help = "" + + async action() { + return {} + } + } + class TestGroup extends CommandGroup { + name = "test-group" + alias = "group-alias" + help = "" + + subCommands = [TestCommand] + } + const cmd = new TestCommand(new TestGroup()) + expect(cmd.getPaths()).to.eql([ + ["test-group", "test-command"], + ["group-alias", "test-command"], + ]) + }) + + it("returns the full command paths including command alias if part of a group", () => { + class TestCommand extends Command { + name = "test-command" + alias = "command-alias" + help = "" + + async action() { + return {} + } + } + class TestGroup extends CommandGroup { + name = "test-group" + help = "" + + subCommands = [TestCommand] + } + const cmd = new TestCommand(new TestGroup()) + expect(cmd.getPaths()).to.eql([ + ["test-group", "test-command"], + ["test-group", "command-alias"], + ]) + }) + + it("returns all permutations with aliases if both command and group have an alias", () => { + class TestCommand extends Command { + name = "test-command" + alias = "command-alias" + help = "" + + async action() { + return {} + } + } + class TestGroup extends CommandGroup { + name = "test-group" + alias = "group-alias" + help = "" + + subCommands = [TestCommand] + } + const cmd = new TestCommand(new TestGroup()) + expect(cmd.getPaths()).to.eql([ + ["test-group", "test-command"], + ["test-group", "command-alias"], + ["group-alias", "test-command"], + ["group-alias", "command-alias"], + ]) + }) + }) +}) + +describe("CommandGroup", () => { + describe("getSubCommands", () => { + it("recursively returns all sub-commands", async () => { + class TestCommandA extends Command { + name = "test-command-a" + help = "" + + async action() { + return {} + } + } + class TestSubgroupA extends CommandGroup { + name = "test-group-a" + help = "" + + subCommands = [TestCommandA] + } + class TestCommandB extends Command { + name = "test-command-b" + help = "" + + async action() { + return {} + } + } + class TestSubgroupB extends CommandGroup { + name = "test-group-b" + help = "" + + subCommands = [TestCommandB] + } + class TestGroup extends CommandGroup { + name = "test-group" + help = "" + + subCommands = [TestSubgroupA, TestSubgroupB] + } + + const group = new TestGroup() + const commands = group.getSubCommands() + const fullNames = commands.map((cmd) => cmd.getFullName()).sort() + + expect(commands.length).to.equal(2) + expect(fullNames).to.eql(["test-group test-group-a test-command-a", "test-group test-group-b test-command-b"]) + }) + }) + + describe("renderHelp", () => { + it("renders the command help text", async () => { + class TestCommand extends Command { + name = "test-command" + alias = "command-alias" + help = "Some help text." + + async action() { + return {} + } + } + class TestGroup extends CommandGroup { + name = "test-group" + help = "" + + subCommands = [TestCommand] + } + + const cmd = new TestGroup() + + expect(trimLineEnds(stripAnsi(cmd.renderHelp())).trim()).to.equal(dedent` + USAGE + garden test-group [options] + + COMMANDS + test-group test-command Some help text. + `) + }) + }) +}) diff --git a/garden-service/test/unit/src/commands/dev.ts b/garden-service/test/unit/src/commands/dev.ts index 64934856f5..60e5a394b8 100644 --- a/garden-service/test/unit/src/commands/dev.ts +++ b/garden-service/test/unit/src/commands/dev.ts @@ -18,8 +18,7 @@ import { getDevCommandInitialTasks, } from "../../../../src/commands/dev" import { makeTestGardenA, withDefaultGlobalOpts, TestGarden } from "../../../helpers" -import { ParameterValues } from "../../../../src/commands/base" -import { GlobalOptions } from "../../../../src/cli/cli" +import { GlobalOptions, ParameterValues } from "../../../../src/cli/params" import { BaseTask } from "../../../../src/tasks/base" describe("DevCommand", () => {