UNPKG

@drop-in/new

Version:

A SvelteKit Svelte 5 PocketBase Starter Kit with a CLI

551 lines (500 loc) 16.1 kB
import type {OptionalLogger} from '@rocicorp/logger'; import {template} from 'chalk-template'; import type {OptionDefinition} from 'command-line-args'; import commandLineArgs from 'command-line-args'; import commandLineUsage from 'command-line-usage'; import kebabcase from 'lodash.kebabcase'; import merge from 'lodash.merge'; import snakeCase from 'lodash.snakecase'; import stripAnsi from 'strip-ansi'; import {assert} from './asserts.js'; import {must} from './must.js'; import * as v from './valita.js'; type Primitive = number | string | boolean; type Value = Primitive | Array<Primitive>; type RequiredOptionType = | v.Type<string> | v.Type<number> | v.Type<boolean> | v.Type<string[]> | v.Type<number[]> | v.Type<boolean[]>; type OptionalOptionType = | v.Optional<string> | v.Optional<number> | v.Optional<boolean> | v.Optional<string[]> | v.Optional<number[]> | v.Optional<boolean[]>; type OptionType = RequiredOptionType | OptionalOptionType; export type WrappedOptionType = { type: OptionType; /** Description lines to be displayed in --help. */ desc?: string[]; /** One-character alias for getopt-style short flags, e.g. -m */ alias?: string; /** Exclude this flag from --help text. Used for internal flags. */ hidden?: boolean; }; export type Option = OptionType | WrappedOptionType; // Related Options can be grouped. export type Group = Record<string, Option>; /** * # Options * * An `Options` object specifies of a set of (possibly grouped) configuration * values that are parsed from environment variables and/or command line flags. * * Each option is represented by a `valita` schema object. The `Options` * type supports one level of grouping for organizing related options. * * ```ts * { * port: v.number().default(8080), * * numWorkers: v.number(), * * log: { * level: v.union(v.literal('debug'), v.literal('info'), ...), * format: v.union(v.literal('text'), v.literal('json')).default('text'), * } * } * ``` * * {@link parseOptions()} will use an `Options` object to populate a {@link Config} * instance of the corresponding shape, consulting SNAKE_CASE environment variables * and/or camelCase command line flags, with flags taking precedence, based on the field * (and group) names: * * | Option | Flag | Env | * | -------------- | ------------- | ----------- | * | port | --port | PORT | * | numWorkers | --num-workers | NUM_WORKERS | * | log: { level } | --log-level | LOG_LEVEL | * | log: { format } | --log-format | LOG_FORMAT | * * `Options` supports: * * primitive valita types `string`, `number`, `boolean` * * single-type arrays or tuples of primitives * * optional values * * default values * * ### Additional Flag Configuration * * {@link parseOptions()} will generate a usage guide that is displayed for * the `--help` or `-h` flags, displaying the flag name, env name, value * type (or enumeration), and default values based on the valita schema. * * For additional configuration, each object can instead by represented by * a {@link WrappedOptionType}, where the valita schema is held in the `type` * field, along with additional optional fields: * * `desc` for documentation displayed in `--help` * * `alias` for getopt-style short flags like `-m` */ export type Options = Record<string, Group | Option>; /** Unwrap the Value type from an Option<V>. */ type ValueOf<T extends Option> = T extends v.Optional<infer V> ? V | undefined : T extends v.Type<infer V> ? V : T extends WrappedOptionType ? ValueOf<T['type']> : never; type Required = | RequiredOptionType | (WrappedOptionType & {type: RequiredOptionType}); type Optional = | OptionalOptionType | (WrappedOptionType & {type: OptionalOptionType}); // Type the fields for optional options as `field?` type ConfigGroup<G extends Group> = { [P in keyof G as G[P] extends Required ? P : never]: ValueOf<G[P]>; } & { // Values for optional options are in optional fields. [P in keyof G as G[P] extends Optional ? P : never]?: ValueOf<G[P]>; }; /** * A Config is an object containing values parsed from an {@link Options} object. * * Example: * * ```ts * { * port: number; * * numWorkers: number; * * // The "log" group * log: { * level: 'debug' | 'info' | 'warn' | 'error'; * format: 'text' | 'json' * }; * ... * } * ``` */ export type Config<O extends Options> = { [P in keyof O as O[P] extends Required | Group ? P : never]: O[P] extends Required ? ValueOf<O[P]> : O[P] extends Group ? ConfigGroup<O[P]> : never; } & { // Values for optional options are in optional fields. [P in keyof O as O[P] extends Optional ? P : never]?: O[P] extends Optional ? ValueOf<O[P]> : never; }; /** * Converts an Options instance into its corresponding {@link Config} schema. */ function configSchema<T extends Options>(options: T): v.Type<Config<T>> { function makeObjectType(options: Options | Group) { return v.object( Object.fromEntries( Object.entries(options).map( ([name, value]): [string, OptionType | v.Type] => { // OptionType if (v.instanceOfAbstractType(value)) { return [name, value]; } // WrappedOptionType const {type} = value; if (v.instanceOfAbstractType(type)) { return [name, type]; } // OptionGroup return [name, makeObjectType(value as Group)]; }, ), ), ); } return makeObjectType(options) as v.Type<Config<T>>; } /** * Converts an Options instance into an "env schema", which is an object with * ENV names as its keys, mapped to optional or required string values * (corresponding to the optionality of the corresponding options). * * This is used as a format for encoding options for a multi-tenant version * of an app, with an envSchema for each tenant. */ export function envSchema<T extends Options>(options: T, envNamePrefix = '') { const fields: [string, v.Type<string> | v.Optional<string>][] = []; function addField(name: string, type: OptionType, group?: string) { const flag = group ? `${group}_${name}` : name; const env = snakeCase(`${envNamePrefix}${flag}`).toUpperCase(); const {required} = getRequiredOrDefault(type); fields.push([env, required ? v.string() : v.string().optional()]); } function addFields(o: Options | Group, group?: string) { Object.entries(o).forEach(([name, value]) => { // OptionType if (v.instanceOfAbstractType(value)) { addField(name, value, group); return; } // WrappedOptionType const {type} = value; if (v.instanceOfAbstractType(type)) { addField(name, type, group); return; } // OptionGroup addFields(value as Group, name); }); } addFields(options); return v.object(Object.fromEntries(fields)); } // type TerminalType is not exported from badrap/valita type TerminalType = Parameters< Parameters<v.Type<unknown>['toTerminals']>[0] >[0]; function getRequiredOrDefault(type: OptionType) { const defaultResult = v.testOptional<Value>(undefined, type); return { required: !defaultResult.ok, defaultValue: defaultResult.ok ? defaultResult.value : undefined, }; } export function parseOptions<T extends Options>( options: T, argv: string[], envNamePrefix = '', processEnv = process.env, logger: OptionalLogger = console, exit = process.exit, ): Config<T> { return parseOptionsAdvanced( options, argv, envNamePrefix, false, false, processEnv, logger, exit, ).config; } export function parseOptionsAdvanced<T extends Options>( options: T, argv: string[], envNamePrefix = '', allowUnknown = false, allowPartial = false, processEnv = process.env, logger: OptionalLogger = console, exit = process.exit, ): {config: Config<T>; env: Record<string, string>; unknown?: string[]} { // The main logic for converting a valita Type spec to an Option (i.e. flag) spec. function addOption(field: string, option: WrappedOptionType, group?: string) { const {type, desc = [], alias, hidden} = option; // The group name is prepended to the flag name. const flag = group ? kebabcase(`${group}-${field}`) : kebabcase(field); const {required, defaultValue} = getRequiredOrDefault(type); let multiple = type.name === 'array'; const literals = new Set<string>(); const terminalTypes = new Set<string>(); type.toTerminals(getTerminalTypes); function getTerminalTypes(t: TerminalType) { switch (t.name) { case 'undefined': case 'optional': break; case 'array': { multiple = true; t.prefix.forEach(t => t.toTerminals(getTerminalTypes)); t.rest?.toTerminals(getTerminalTypes); t.suffix.forEach(t => t.toTerminals(getTerminalTypes)); break; } case 'literal': literals.add(String(t.value)); terminalTypes.add(typeof t.value); break; default: terminalTypes.add(t.name); break; } } if (terminalTypes.size > 1) { throw new TypeError(`--${flag} has mixed types ${[...terminalTypes]}`); } assert(terminalTypes.size === 1); const terminalType = [...terminalTypes][0]; const env = snakeCase(`${envNamePrefix}${flag}`).toUpperCase(); if (processEnv[env]) { if (multiple) { // Technically not water-tight; assumes values for the string[] flag don't contain commas. envArgv.push(`--${flag}`, ...processEnv[env].split(',')); } else { envArgv.push(`--${flag}`, processEnv[env]); } } names.set(flag, {field, env}); const spec = [ (required ? '{italic required}' : defaultValue !== undefined ? `default: ${JSON.stringify(defaultValue)}` : 'optional') + '\n', ]; if (desc) { spec.push(...desc); } const typeLabel = [ literals.size ? String([...literals].map(l => `{underline ${l}}`)) : multiple ? `{underline ${terminalType}[]}` : `{underline ${terminalType}}`, ` ${env} env`, ]; const opt = { name: flag, alias, type: valueParser(flag, terminalType), multiple, group, description: spec.join('\n') + '\n', typeLabel: typeLabel.join('\n') + '\n', hidden, }; optsWithoutDefaults.push(opt); optsWithDefaults.push({...opt, defaultValue}); } const names = new Map<string, {field: string; env: string}>(); const optsWithDefaults: DescribedOptionDefinition[] = []; const optsWithoutDefaults: DescribedOptionDefinition[] = []; const envArgv: string[] = []; try { for (const [name, val] of Object.entries(options)) { const {type} = val as {type: unknown}; if (v.instanceOfAbstractType(val)) { addOption(name, {type: val}); } else if (v.instanceOfAbstractType(type)) { addOption(name, val as WrappedOptionType); } else { const group = name; for (const [name, option] of Object.entries(val as Group)) { const wrapped = v.instanceOfAbstractType(option) ? {type: option} : option; addOption(name, wrapped, group); } } } const [defaults, env1, unknown] = parseArgs(optsWithDefaults, argv, names); const [fromEnv, env2] = parseArgs(optsWithoutDefaults, envArgv, names); const [withoutDefaults, env3] = parseArgs(optsWithoutDefaults, argv, names); switch (unknown?.[0]) { case undefined: break; case '--help': case '-h': showUsage(optsWithDefaults, logger); exit(0); break; default: if (!allowUnknown) { logger.error?.('Invalid arguments:', unknown); showUsage(optsWithDefaults, logger); exit(0); } break; } const parsedArgs = merge(defaults, fromEnv, withoutDefaults); const env = {...env1, ...env2, ...env3}; let schema = configSchema(options); if (allowPartial) { // TODO: Type configSchema() to return a v.ObjectType<...> schema = v.deepPartial(schema as v.ObjectType) as v.Type<Config<T>>; } return { config: v.parse(parsedArgs, schema), env, ...(unknown ? {unknown} : {}), }; } catch (e) { logger.error?.(String(e)); showUsage(optsWithDefaults, logger); throw e; } } function valueParser(flagName: string, typeName: string) { return (input: string) => { switch (typeName) { case 'string': return input; case 'boolean': { const bool = input.toLowerCase(); if (['true', '1'].includes(bool)) { return true; } else if (['false', '0'].includes(bool)) { return false; } throw new TypeError(`Invalid input for --${flagName}: "${input}"`); } case 'number': { const val = Number(input); if (Number.isNaN(val)) { throw new TypeError(`Invalid input for --${flagName}: "${input}"`); } return val; } default: // Should be impossible given the constraints of `Option` throw new TypeError( `--${flagName} flag has unsupported type ${typeName}`, ); } }; } function parseArgs( optionDefs: DescribedOptionDefinition[], argv: string[], names: Map<string, {field: string; env: string}>, ) { function normalizeFlagValue(value: unknown) { // A --flag without value is parsed by commandLineArgs() to `null`, // but this is a common convention to set a boolean flag to true. return value === null ? true : value; } const { _all, _none: ungrouped, _unknown: unknown, ...config } = commandLineArgs(optionDefs, { argv, partial: true, }); const result = {...config}; const envObj: Record<string, string> = {}; // Handle ungrouped flags first if (ungrouped) { for (const [flagName, value] of Object.entries(ungrouped)) { const {field, env} = must(names.get(flagName)); result[field] = normalizeFlagValue(value); envObj[env] = String(result[field]); } } // Then handle grouped flags for (const [groupName, group] of Object.entries(config)) { if (typeof group === 'object' && group !== null) { result[groupName] = {}; for (const [flagName, value] of Object.entries(group)) { const {field, env} = must(names.get(flagName)); result[groupName][field] = normalizeFlagValue(value); envObj[env] = String(result[groupName][field]); } } } return [result, envObj, unknown] as const; } function showUsage( optionList: DescribedOptionDefinition[], logger: OptionalLogger = console, ) { const hide: string[] = []; let leftWidth = 35; let rightWidth = 70; optionList.forEach(({name, typeLabel, description, hidden}) => { if (hidden) { hide.push(name); } const text = template(`${name} ${typeLabel ?? ''}`); const lines = stripAnsi(text).split('\n'); for (const l of lines) { leftWidth = Math.max(leftWidth, l.length + 2); } const desc = stripAnsi(template(description ?? '')).split('\n'); for (const l of desc) { rightWidth = Math.max(rightWidth, l.length + 2); } }); logger.info?.( commandLineUsage({ optionList, reverseNameOrder: true, // Display --flag-name before -alias hide, tableOptions: { columns: [ {name: 'option', width: leftWidth}, {name: 'description', width: rightWidth}, ], noTrim: true, }, }), ); } type DescribedOptionDefinition = OptionDefinition & { // Additional fields recognized by command-line-usage description?: string; typeLabel?: string | undefined; hidden?: boolean | undefined; };