ForestAdmin/toolbelt

View on GitHub
src/utils/option-parser.ts

Summary

Maintainability
A
55 mins
Test Coverage
A
100%
// Note that we're using inject() in the functions because we can't create an injectable class...
// Doing so prevents us from using optionsToArgs() and optionsToFlags() at startup

import type { Command } from '@oclif/core';

import { inject } from '@forestadmin/context';
import { Flags as oflags } from '@oclif/core';

/** Option which can be used both as  flag or prompt */
export type CommandOptions<T = Record<string, unknown>> = {
  [name: string]: {
    type?: 'string' | 'boolean';
    exclusive?: string[];
    choices?: Array<{ name: string; value: unknown }>;
    when?: (v: T) => boolean;
    validate?: (v: string) => boolean | string;
    default?: unknown | ((v: T) => unknown);
    oclif: { char?: string; description: string };
    prompter?: { question: string; description?: string };
  };
};

function optionToInquirer(name: string, option: CommandOptions[string]): unknown {
  const { os } = inject() as any; // eslint-disable-line @typescript-eslint/no-explicit-any

  // Use rawlist on windows because of https://github.com/SBoudrias/Inquirer.js/issues/303
  const listType = /^win/.test(os.platform()) ? 'rawlist' : 'list';
  const inputType = name.match(/(password|secret)/i) ? 'password' : 'input';
  let type = option.choices ? listType : inputType;
  if (option.type === 'boolean') type = 'confirm';

  const result: Record<string, unknown> = { name, type, message: option.prompter.question };
  if (option.prompter.description) result.description = option.prompter.description;
  if (option.choices) result.choices = option.choices;
  if (option.validate) result.validate = option.validate;
  if (option.default !== undefined) result.default = option.default;
  if (option.when)
    // Make sure that the first question when() is evaluated after one tick (see hack below)
    result.when = async (args: Record<string, unknown>) => {
      await new Promise(resolve => setTimeout(resolve, 0));
      return option.when(args);
    };

  return result;
}

/** Query options interactively */
export async function getInteractiveOptions<T>(
  options: CommandOptions,
  values: Record<string, unknown> = {},
): Promise<T> {
  const { inquirer } = inject() as any; // eslint-disable-line @typescript-eslint/no-explicit-any

  const questions = Object.entries(options)
    .filter(
      ([name, option]) =>
        option.prompter && // Has a prompter
        values[name] === undefined && // Not already set
        (option.exclusive ?? []).every(e => values[e] === undefined), // Not exclusive with another option
    )
    .map(([name, option]) => optionToInquirer(name, option));

  const promise = inquirer.prompt(questions);

  // Passing answers we already have to inquirer is not supported in the legacy version we use
  // To work around this, we inject them in the inquirer ui object that is conveniently accessible
  // from the promise.
  // To fix this, we should upgrade to a newer version of inquirer.
  // Note that the if condition is always true, but not having it breaks the tests which are all
  // based on an inquirer.mock that returns a value (instead of a promise).
  if (promise?.ui?.answers) Object.assign(promise.ui.answers, values);

  return promise;
}

/** Get options that were passed in the command line */
export async function getCommandLineOptions<T>(instance: Command): Promise<T> {
  const { options } = instance.constructor as unknown as { options: CommandOptions };

  // Parse the command line arguments and flags.
  // @ts-expect-error: calling the argument parser from oclif is protected.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const { args, flags } = (await instance.parse(instance.constructor)) as any;
  const optionsFromCli = { ...args, ...flags };

  Object.entries(options).forEach(([k, v]) => {
    // Replace choices with their value
    const choice = v.choices?.find(c => c.name === optionsFromCli[k]);
    if (choice) optionsFromCli[k] = choice.value;

    // Validate
    const error = optionsFromCli[k] !== undefined && v.validate?.(optionsFromCli[k]);
    if (typeof error === 'string') throw new Error(`Invalid value for ${k}: ${error}`);
  });

  // Query missing options interactively
  const optionsFromPrompt = await getInteractiveOptions<T>(options, optionsFromCli);

  return { ...optionsFromCli, ...optionsFromPrompt };
}

/** Convert generic options to oclif flags */
export function optionsToFlags(options: CommandOptions) {
  // Not using Object.fromEntries() for compatibility with node 10
  const result = {};

  Object.entries(options).forEach(([key, value]) => {
    const constructor = value.type === 'boolean' ? oflags.boolean : oflags.string;
    const flag = {
      char: value.oclif?.char as 'a',
      description: value.oclif.description,
      exclusive: value.exclusive,
      required: false,
      options: value.choices?.map(c => c.name),
    };

    result[key] = constructor(flag);
  });

  return result;
}