From 2de87af4ca57fa8e664970437b4dc2ac72a45b58 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Tue, 15 Feb 2022 20:58:25 -0700 Subject: [PATCH 1/2] wip: almost working w/ CSV enum --- package.json | 1 + src/index.test.ts | 13 ++++- src/index.ts | 134 +++++++++++++++++++++++----------------------- src/types.ts | 50 +++++++++-------- yarn.lock | 5 ++ 5 files changed, 115 insertions(+), 88 deletions(-) diff --git a/package.json b/package.json index e68278f..0c095bf 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "lodash.keys": "^4.2.0", "lodash.mapvalues": "^4.6.0", "minimist": "^1.2.5", + "type-fest": "^2.11.2", "zod": "^3.11.6" }, "files": [ diff --git a/src/index.test.ts b/src/index.test.ts index 6c5c5e6..3a0f5dc 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,3 +1,4 @@ +import {Split, LiteralUnion, IterableElement} from 'type-fest'; import { autoConfig } from './index'; import { mockArgv, setEnvKey } from './test/utils'; @@ -181,11 +182,21 @@ describe('handles enum options', () => { resetEnv(); }); test('supports enum default values', () => { + const vars = ['variant1', 'variant2', 'variant3', 'variant4'] + const objVars = {variant1: 'variant1', variant2: 'variant2', variant3: 'variant3', variant4: 'variant4'} + const varJoin = vars.join(','); + const varCsv = 'variant1,variant2,variant3,variant4'; + let opt: undefined | Split[number] = undefined; + let csvOpt: undefined | Split[number] = undefined; + csvOpt = 'v'; + opt = 'v'; + let iterTest: LiteralUnion, string> = 'variant'; + const config = autoConfig({ featureFlagA: { args: ['FEATURE_FLAG_A'], type: 'enum', - enum: ['variant1', 'variant2'], + enum: ['variant1', 'variant2', 'variant3', 'variant4'], default: 'variant1', }, }); diff --git a/src/index.ts b/src/index.ts index 1f80593..82581a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,23 @@ -import * as z from "zod"; -import minimist from "minimist"; -import { applyType, cleanupStringList, stripDashes } from "./utils"; -import { CommandOption, ConfigInputs, ConfigResults } from "./types"; -import isString from "lodash.isstring"; -import { optionsHelp } from "./render"; -import debug from "debug"; -import chalk from "chalk"; -import path from "path"; +import * as z from 'zod'; +import minimist from 'minimist'; +import { applyType, cleanupStringList, stripDashes } from './utils'; +import { CommandOption, ConfigInputs, ConfigResults, Undefinedable } from './types'; +import isString from 'lodash.isstring'; +import { optionsHelp } from './render'; +import debug from 'debug'; +import chalk from 'chalk'; +import path from 'path'; export const autoConfig = function < - TInput extends { [K in keyof TInput]: CommandOption } + TInput extends { [K in keyof TInput]: TInput[K]["enum"] extends [string, ...string[]] ? CommandOption : CommandOption } >(config: TInput) { - const debugLog = debug("auto-config"); - debugLog("START: Loading runtime environment & command line arguments."); + const debugLog = debug('auto-config'); + debugLog('START: Loading runtime environment & command line arguments.'); let { cliArgs, envKeys } = extractEnvArgs(); if (debugLog.enabled) { - debugLog("runtime.cliArgs", JSON.stringify(cliArgs)); - debugLog("runtime.envKeys", JSON.stringify(envKeys)); - debugLog("config.keys", Object.keys(config).sort().join(", ")); + debugLog('runtime.cliArgs', JSON.stringify(cliArgs)); + debugLog('runtime.envKeys', JSON.stringify(envKeys)); + debugLog('config.keys', Object.keys(config).sort().join(', ')); } checkSpecialArgs(cliArgs, config); @@ -28,14 +28,14 @@ export const autoConfig = function < cliArgs, envKeys, }); - debugLog("commandOptions=", commandOptions); + debugLog('commandOptions=', commandOptions); const results = verifySchema(schemaObject, commandOptions, { cliArgs, envKeys, }); - debugLog("DONE", JSON.stringify(commandOptions)); + debugLog('DONE', JSON.stringify(commandOptions)); return commandOptions; }; @@ -45,7 +45,7 @@ function buildSchema( const schemaObject = z.object( Object.entries(config).reduce( (schema, [name, commandOption]) => { - commandOption.type = commandOption.type || "string"; + commandOption.type = commandOption.type || 'string'; schema[name as keyof TInput] = getOptionSchema({ commandOption }); return schema; }, @@ -59,17 +59,17 @@ function verifySchema( config: ConfigResults, inputs: ConfigInputs ): Record { - const debugLog = debug("auto-config:verifySchema"); + const debugLog = debug('auto-config:verifySchema'); // verify schema const parseResults = schema.safeParse(config); - debugLog("parse success?", parseResults.success); + debugLog('parse success?', parseResults.success); if (!parseResults.success) { const { issues } = parseResults.error; - debugLog("parse success?", parseResults.success); + debugLog('parse success?', parseResults.success); const fieldErrors = issues.reduce((groupedResults, issue) => { groupedResults[issue.message] = groupedResults[issue.message] || []; groupedResults[issue.message].push( - issue.path.join(".") + " " + issue.code + issue.path.join('.') + ' ' + issue.code ); return groupedResults; }, {} as Record); @@ -82,7 +82,7 @@ function verifySchema( ); Object.entries(fieldErrors).forEach(([message, errors]) => { console.error( - ` - ${chalk.magentaBright(message)}: ${errors.join(", ")}` + ` - ${chalk.magentaBright(message)}: ${errors.join(', ')}` ); }); return process.exit(1); @@ -100,7 +100,7 @@ function assembleConfigResults< const commandOptions = Object.entries(config).reduce( (conf, [name, opt]) => { if (opt) { - opt.type = opt.type || "string"; + opt.type = opt.type || 'string'; const v = getOptionValue({ commandOption: opt, inputCliArgs: cliArgs, @@ -108,10 +108,10 @@ function assembleConfigResults< }); conf[name as Keys] = v as any; // if (!opt.type || opt.type === 'string') - if (opt.type === "number") conf[name as Keys] = v as any; - if (opt.type === "boolean") conf[name as Keys] = v as any; - if (opt.type === "array") conf[name as Keys] = v as any; - if (opt.type === "date") conf[name as Keys] = new Date(v as any) as any; + if (opt.type === 'number') conf[name as Keys] = v as any; + if (opt.type === 'boolean') conf[name as Keys] = v as any; + if (opt.type === 'array') conf[name as Keys] = v as any; + if (opt.type === 'date') conf[name as Keys] = new Date(v as any) as any; } return conf; }, @@ -133,10 +133,10 @@ function checkSpecialArgs( ) { if (args.version) { // const pkg = getPackageJson(process.cwd()); - const version = process.env.npm_package_version || "unknown"; + const version = process.env.npm_package_version || 'unknown'; if (version) { - console.log("Version:", version); + console.log('Version:', version); return process.exit(0); } console.error(`No package.json found from path ${__dirname}`); @@ -146,7 +146,7 @@ function checkSpecialArgs( const pkgName = process.env.npm_package_name || path.basename(path.dirname(process.argv[1])) || - "This app"; + 'This app'; console.log( `\n${chalk.underline.bold.greenBright( pkgName @@ -163,49 +163,51 @@ function getOptionSchema({ commandOption: CommandOption; }) { let zType = - opt.type === "array" + opt.type === 'array' ? z.array(z.string()) - : opt.type === "enum" - ? z.enum(opt.enum) - : z[opt.type || "string"](); - if (opt.type === "boolean") { + : opt.type === 'enum' + ? opt.enum && z.enum(opt.enum!) + : z[opt.type || 'string'](); + if (opt.type === 'boolean') { // @ts-ignore zType = zType.default(opt.default || false); } else { // @ts-ignore - if (!opt.required && !("min" in opt)) zType = zType.optional(); + if (!opt.required && !('min' in opt)) zType = zType.optional(); } // @ts-ignore if (opt.default !== undefined) zType = zType.default(opt.default); - if ("min" in opt && typeof opt.min === "number" && "min" in zType) - zType = zType.min(opt.min); - if ("max" in opt && typeof opt.max === "number" && "max" in zType) - zType = zType.max(opt.max); - if ("gte" in opt && typeof opt.gte === "number" && "gte" in zType) - zType = zType.gte(opt.gte); - if ("lte" in opt && typeof opt.lte === "number" && "lte" in zType) - zType = zType.lte(opt.lte); - if ("gt" in opt && typeof opt.gt === "number" && "gt" in zType) - zType = zType.gt(opt.gt); - if ("lt" in opt && typeof opt.lt === "number" && "lt" in zType) - zType = zType.lt(opt.lt); + if (typeof zType === 'object') { + if ('min' in opt && typeof opt.min === 'number' && 'min' in zType) + zType = zType.min(opt.min); + if ('max' in opt && typeof opt.max === 'number' && 'max' in zType) + zType = zType.max(opt.max); + if ('gte' in opt && typeof opt.gte === 'number' && 'gte' in zType) + zType = zType.gte(opt.gte); + if ('lte' in opt && typeof opt.lte === 'number' && 'lte' in zType) + zType = zType.lte(opt.lte); + if ('gt' in opt && typeof opt.gt === 'number' && 'gt' in zType) + zType = zType.gt(opt.gt); + if ('lt' in opt && typeof opt.lt === 'number' && 'lt' in zType) + zType = zType.lt(opt.lt); + } - return zType; + return zType!; } function extractArgs(args: string[]) { return args.reduce( (result, arg) => { - if (arg.startsWith("--")) { + if (arg.startsWith('--')) { result.cliArgs.push(arg); return result; } - if (arg.startsWith("-")) { + if (arg.startsWith('-')) { result.cliFlag.push(arg); return result; } - if (typeof arg === "string" && arg.length > 0) result.envKeys.push(arg); + if (typeof arg === 'string' && arg.length > 0) result.envKeys.push(arg); return result; }, { @@ -225,40 +227,40 @@ function getOptionValue({ inputCliArgs: minimist.ParsedArgs; inputEnvKeys: NodeJS.ProcessEnv; }) { - const debugLog = debug("auto-config:getOption"); + const debugLog = debug('auto-config:getOption'); let { args, default: defaultValue } = commandOption; args = cleanupStringList(args); const { cliArgs, cliFlag, envKeys } = extractArgs(args); - debugLog("args", args.join(", ")); - debugLog("cliArgs", cliArgs); - debugLog("cliFlag", cliFlag); - debugLog("envKeys", envKeys); - debugLog("inputCliArgs:", inputCliArgs); + debugLog('args', args.join(', ')); + debugLog('cliArgs', cliArgs); + debugLog('cliFlag', cliFlag); + debugLog('envKeys', envKeys); + debugLog('inputCliArgs:', inputCliArgs); // debugLog('inputEnvKeys:', Object.keys(inputEnvKeys).filter((k) => !k.startsWith('npm')).sort()); - debugLog("Checking.cliArgs:", [...cliFlag, ...cliArgs]); + debugLog('Checking.cliArgs:', [...cliFlag, ...cliArgs]); // Match CLI args let argNameMatch = stripDashes( [...cliFlag, ...cliArgs].find( - (key) => typeof key === "string" && inputCliArgs[stripDashes(key)] + (key) => typeof key === 'string' && inputCliArgs[stripDashes(key)] ) ); - debugLog("argNameMatch:", argNameMatch); + debugLog('argNameMatch:', argNameMatch); const matchingArg = isString(argNameMatch) ? inputCliArgs[argNameMatch] : undefined; - debugLog("argValueMatch:", matchingArg); + debugLog('argValueMatch:', matchingArg); if (matchingArg) return applyType(matchingArg, commandOption.type); // Match env vars const envNameMatch = [...envKeys].find( - (key) => typeof key === "string" && inputEnvKeys[key] + (key) => typeof key === 'string' && inputEnvKeys[key] ); - debugLog("envNameMatch:", envNameMatch); + debugLog('envNameMatch:', envNameMatch); const matchingEnv = isString(envNameMatch) ? inputEnvKeys[envNameMatch as any] : undefined; - debugLog("envValueMatch:", matchingEnv); + debugLog('envValueMatch:', matchingEnv); if (matchingEnv) return applyType(matchingEnv, commandOption.type); if (commandOption.default != undefined) diff --git a/src/types.ts b/src/types.ts index 2c65481..6917389 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,12 @@ -import minimist from "minimist"; -import { infer, never } from "zod"; +import minimist from 'minimist'; +import { LiteralUnion, IterableElement } from 'type-fest'; +// import { infer, never } from "zod"; /** * CommandOption defines a system config parameter. * Includes where to load the value from, which names to check for, and any validation rules. */ -export type CommandOption = OptionTypeConfig & { +export type CommandOption = OptionTypeConfig & { /** Inline Documentation, used to render `--help` and provide intelligent error messages. */ help?: string; /** flag will only match command line args like `-p` or `-X`, not `--X` @@ -14,19 +15,22 @@ export type CommandOption = OptionTypeConfig & { args?: string | string[]; /** Throw an error on missing value. */ required?: boolean; + + enum?: TEnumItems; + _enumValue?: LiteralUnion; }; export type OptionTypeConfig = | OptionTypeEnum | { - type?: "string"; + type?: 'string'; default?: string; transform?: (input: unknown) => string; min?: number; max?: number; } | { - type: "number"; + type: 'number'; default?: number; transform?: (input: unknown) => number; min?: number; @@ -38,17 +42,17 @@ export type OptionTypeConfig = positive?: boolean; } | { - type: "boolean"; + type: 'boolean'; default?: boolean; transform?: (input: unknown) => boolean; } | { - type: "date"; + type: 'date'; default?: Date; transform?: (input: unknown) => Date; } | { - type: "array"; + type: 'array'; default?: Array; transform?: (input: unknown) => string[]; min?: number; @@ -59,8 +63,9 @@ export type OptionTypeConfig = // type GetEnumOption = TOption extends { enum: Array } ? EnumItem : never; type OptionTypeEnum = { - type: "enum"; - enum: Readonly<[string, ...string[]]>; + type: 'enum'; + // NOTE: Moving enum to base type to avoid TS limitation + // enum: Readonly<[string, ...string[]]>; default?: string; // default?: keyof OptionTypeConfig['enum']; transform?: (input: unknown) => string; @@ -74,26 +79,29 @@ export type ConfigInputs = { export type ConfigResults< TConfig extends { [K in keyof TConfig]: CommandOption } > = { - [K in keyof TConfig]: TConfig[K]["required"] extends true - ? NonNullable> - : Nullable>; + [K in keyof TConfig]: TConfig[K]['enum'] extends any[] + ? LiteralUnion + : TConfig[K]['required'] extends true + ? NonNullable> + : Nullable>; }; - -export type Nullable = T | null | undefined; +// LiteralUnion +// ValueOf +export type Nullable = T | null; export type Undefinedable = T | undefined; export type GetTypeByTypeString = - TType extends "string" + TType extends 'string' ? string - : TType extends "number" + : TType extends 'number' ? number - : TType extends "array" + : TType extends 'array' ? string[] - : TType extends "boolean" + : TType extends 'boolean' ? boolean - : TType extends "enum" + : TType extends 'enum' ? string - : TType extends "date" + : TType extends 'date' ? Date : TType extends undefined ? Undefinedable diff --git a/yarn.lock b/yarn.lock index 10bbd8c..882afa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7033,6 +7033,11 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-fest@^2.11.2: + version "2.11.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.11.2.tgz#5534a919858bc517492cd3a53a673835a76d2e71" + integrity sha512-reW2Y2Mpn0QNA/5fvtm5doROLwDPu2zOm5RtY7xQQS05Q7xgC8MOZ3yPNaP9m/s/sNjjFQtHo7VCNqYW2iI+Ig== + typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" From 7178cb4106d0768a53362dfc280701a94a34334e Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Tue, 15 Feb 2022 20:59:12 -0700 Subject: [PATCH 2/2] wip: almost working w/ CSV enum --- src/index.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 3a0f5dc..1f04229 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -173,7 +173,7 @@ describe('handles enum options', () => { featureFlagA: { args: ['FEATURE_FLAG_A'], type: 'enum', - enum: ['variant1', 'variant2'], + enum: 'variant1,variant2', }, }); @@ -190,7 +190,8 @@ describe('handles enum options', () => { let csvOpt: undefined | Split[number] = undefined; csvOpt = 'v'; opt = 'v'; - let iterTest: LiteralUnion, string> = 'variant'; + let iterTest: LiteralUnion>, string> = 'variant'; + iterTest = 'v'; const config = autoConfig({ featureFlagA: {