storybook/scripts/utils/options.ts

248 lines
7.5 KiB
TypeScript
Raw Normal View History

/**
* Use commander and prompts to gather a list of options for a script
*/
import prompts from 'prompts';
import type { PromptObject } from 'prompts';
import program from 'commander';
import type { Command } from 'commander';
2022-07-25 16:21:49 +10:00
import kebabCase from 'lodash/kebabCase';
2022-07-27 20:13:36 +10:00
// Option types
export type OptionId = string;
export type BaseOption = {
description?: string;
2022-07-25 16:21:49 +10:00
/**
* By default the one-char version of the option key will be used as short flag. Override here,
* e.g. `shortFlag: 'c'`
*/
shortFlag?: string;
};
2022-07-25 16:21:49 +10:00
export type BooleanOption = BaseOption & {
/**
* Does this option default true?
*/
inverse?: boolean;
};
export type StringOption = BaseOption & {
values: string[];
required?: boolean;
};
2022-07-27 20:13:36 +10:00
export type StringArrayOption = BaseOption & {
values: string[];
multiple: true;
};
// StringArrayOption requires `multiple: true;` but unless you use `as const` an object with
// { multiple: true } will be inferred as `multiple: boolean;`
type StringArrayOptionMatch = Omit<StringArrayOption, 'multiple'> & { multiple: boolean };
export type Option = BooleanOption | StringOption | StringArrayOption;
export type MaybeOptionValue<TOption extends Option> = TOption extends StringArrayOptionMatch
? string[]
: TOption extends StringOption
? string | undefined
: TOption extends BooleanOption
? boolean
: never;
// Note we use `required: boolean;` rather than `required: true` here for the same reason
// as `StringArrayOptionMatch` above. In both cases, the field should only ever be set to true
export type OptionValue<TOption extends Option> = TOption extends { required: boolean }
? string
: MaybeOptionValue<TOption>;
export type OptionSpecifier = Record<OptionId, Option>;
2022-07-27 20:13:36 +10:00
export type MaybeOptionValues<TOptions extends OptionSpecifier> = {
[TKey in keyof TOptions]: MaybeOptionValue<TOptions[TKey]>;
};
export type OptionValues<TOptions extends OptionSpecifier> = {
[TKey in keyof TOptions]: OptionValue<TOptions[TKey]>;
};
2022-07-27 20:13:36 +10:00
export function isStringOption(option: Option): option is StringOption {
return 'values' in option && !('multiple' in option);
}
export function isBooleanOption(option: Option): option is BooleanOption {
return !('values' in option);
}
export function isStringArrayOption(option: Option): option is StringArrayOption {
return 'values' in option && 'multiple' in option;
}
2022-07-25 16:21:49 +10:00
function shortFlag(key: OptionId, option: Option) {
2022-07-27 20:13:36 +10:00
const inverse = isBooleanOption(option) && option.inverse;
2022-07-25 16:21:49 +10:00
const defaultShortFlag = inverse ? key.substring(0, 1).toUpperCase() : key.substring(0, 1);
2022-07-27 20:13:36 +10:00
const short = option.shortFlag || defaultShortFlag;
if (short.length !== 1) {
2022-07-25 16:21:49 +10:00
throw new Error(
2022-07-27 20:13:36 +10:00
`Invalid shortFlag for ${key}: '${short}', needs to be a single character (e.g. 's')`
2022-07-25 16:21:49 +10:00
);
}
2022-07-27 20:13:36 +10:00
return short;
2022-07-25 16:21:49 +10:00
}
function longFlag(key: OptionId, option: Option) {
2022-07-27 20:13:36 +10:00
const inverse = isBooleanOption(option) && option.inverse;
2022-07-25 16:21:49 +10:00
return inverse ? `no-${kebabCase(key)}` : kebabCase(key);
}
function optionFlags(key: OptionId, option: Option) {
const base = `-${shortFlag(key, option)}, --${longFlag(key, option)}`;
2022-07-27 20:13:36 +10:00
if (isStringOption(option) || isStringArrayOption(option)) {
2022-07-25 16:21:49 +10:00
return `${base} <${key}>`;
}
return base;
}
2022-07-27 20:13:36 +10:00
export function getOptions<TOptions extends OptionSpecifier>(
command: Command,
options: TOptions,
argv: string[]
): MaybeOptionValues<TOptions> {
2022-07-25 16:21:49 +10:00
Object.entries(options)
.reduce((acc, [key, option]) => {
const flags = optionFlags(key, option);
2022-07-27 20:13:36 +10:00
if (isBooleanOption(option)) return acc.option(flags, option.description, !!option.inverse);
const checkStringValue = (raw: string) => {
if (!option.values.includes(raw))
throw new Error(`Unexpected value '${raw}' for option '${key}'`);
return raw;
};
if (isStringOption(option))
return acc.option(flags, option.description, (raw) => checkStringValue(raw));
if (isStringArrayOption(option)) {
return acc.option(
flags,
option.description,
(raw, values) => [...values, checkStringValue(raw)],
[]
);
}
2022-07-27 20:13:36 +10:00
throw new Error(`Unexpected option type '${key}'`);
}, command)
.parse(argv);
2022-07-27 20:13:36 +10:00
// Note the code above guarantees the types as they come in, so we cast here.
// Not sure there is an easier way to do this
return command.opts() as MaybeOptionValues<TOptions>;
2022-07-25 16:29:14 +10:00
}
2022-07-27 20:13:36 +10:00
export function areOptionsSatisfied<TOptions extends OptionSpecifier>(
options: TOptions,
values: MaybeOptionValues<TOptions>
) {
return !Object.entries(options)
.filter(([, option]) => isStringOption(option) && option.required)
.find(([key]) => !values[key]);
}
2022-07-27 20:13:36 +10:00
export async function promptOptions<TOptions extends OptionSpecifier>(
options: TOptions,
values: MaybeOptionValues<TOptions>
): Promise<OptionValues<TOptions>> {
const questions = Object.entries(options).map(([key, option]): PromptObject => {
2022-07-27 20:13:36 +10:00
if (!isBooleanOption(option)) {
const currentValue = values[key];
return {
2022-07-27 20:13:36 +10:00
type: isStringArrayOption(option) ? 'autocompleteMultiselect' : 'select',
message: option.description,
name: key,
// warn: ' ',
// pageSize: Object.keys(tasks).length + Object.keys(groups).length,
choices: option.values.map((value) => ({
title: value,
value,
selected:
currentValue === value ||
(Array.isArray(currentValue) && currentValue.includes?.(value)),
})),
};
}
return {
type: 'toggle',
message: option.description,
name: key,
2022-07-25 16:21:49 +10:00
initial: option.inverse,
2022-07-25 17:00:35 +10:00
active: 'yes',
inactive: 'no',
};
});
const selection = await prompts(questions);
2022-07-27 20:13:36 +10:00
// Again the structure of the questions guarantees we get responses of the type we need
return selection as OptionValues<TOptions>;
}
2022-07-27 20:13:36 +10:00
function getFlag<TOption extends Option>(
key: OptionId,
option: TOption,
2022-07-27 22:17:39 +10:00
value?: OptionValue<TOption>
2022-07-27 20:13:36 +10:00
) {
if (isBooleanOption(option)) {
const toggled = option.inverse ? !value : value;
return toggled ? `--${longFlag(key, option)}` : '';
}
if (isStringArrayOption(option)) {
2022-07-27 22:13:29 +10:00
// I'm not sure why TS isn't able to infer that OptionValue<TOption> is a
// OptionValue<StringArrayOption> (i.e. a string[]), given that it knows
// option is a StringArrayOption
2022-07-28 19:46:40 +10:00
return ((value || []) as OptionValue<typeof option>)
.map((v) => `--${longFlag(key, option)} ${v}`)
.join(' ');
2022-07-27 20:13:36 +10:00
}
if (isStringOption(option)) {
if (value) {
2022-07-25 16:21:49 +10:00
return `--${longFlag(key, option)} ${value}`;
}
2022-07-25 16:21:49 +10:00
return '';
}
2022-07-27 20:13:36 +10:00
throw new Error(`Unknown option type for '${key}'`);
}
2022-07-27 20:13:36 +10:00
export function getCommand<TOptions extends OptionSpecifier>(
prefix: string,
options: TOptions,
2022-07-27 22:17:39 +10:00
values: Partial<OptionValues<TOptions>>
2022-07-27 20:13:36 +10:00
) {
const flags = Object.keys(options)
2022-07-25 16:21:49 +10:00
.map((key) => getFlag(key, options[key], values[key]))
.filter(Boolean);
return `${prefix} ${flags.join(' ')}`;
}
2022-07-27 20:13:36 +10:00
export async function getOptionsOrPrompt<TOptions extends OptionSpecifier>(
commandPrefix: string,
options: TOptions
): Promise<OptionValues<TOptions>> {
const main = program.version('5.0.0');
const cliValues = getOptions(main as any, options, process.argv);
if (areOptionsSatisfied(options, cliValues)) {
2022-07-27 20:13:36 +10:00
// areOptionsSatisfied could be a type predicate but I'm not quite sure how to do it
return cliValues as OptionValues<TOptions>;
}
const finalValues = await promptOptions(options, cliValues);
const command = getCommand(commandPrefix, options, finalValues);
console.log(`\nTo run this directly next time, use:\n ${command}\n`);
return finalValues;
}