Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve env & arg mocking pattern #16

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"chalk": "^4.0.0",
"columnify": "1.5.4",
"debug": "^4.3.3",
"lodash": "^4.17.21",
"lodash.isobject": "^3.0.2",
"lodash.isstring": "^4.0.1",
"lodash.keys": "^4.2.0",
Expand Down
37 changes: 36 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { autoConfig } from './index';
import { addMockHelpers, autoConfig } from './index';
import { mockArgv, setEnvKey } from './test/utils';

const processExitSpy = jest
Expand Down Expand Up @@ -205,3 +205,38 @@ describe('parsing', () => {
expect(config.flags).toEqual(['dev', 'qa', 'prod', 'staging']);
});
});

describe('Runtime control & mocking', () => {
test('supports runtime control of config values', () => {
const config = addMockHelpers(autoConfig({
port: {
args: ['--port', 'PORT'],
type: 'number',
default: 8080,
},
}));

config._set('port', 9999);
expect(config.port).toBe(9999);
config._set({ port: 3000 });
expect(config.port).toBe(3000);
config._restore();
expect(config.port).toBe(8080);
});

test('implicit type from default value', () => {
const config = addMockHelpers(autoConfig({
port: {
args: ['--port', 'PORT'],
default: '8080',
},
}));

config._set('port', `9999`);
expect(config.port).toBe(`9999`);
config._set({ port: `3000` });
expect(config.port).toBe(`3000`);
config._restore();
expect(config.port).toBe(`8080`);
});
});
181 changes: 120 additions & 61 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,121 @@
import * as z from 'zod';
import minimist from 'minimist';
import isString from 'lodash.isstring';
import debug from 'debug';
import chalk from 'chalk';
import path from 'path';
import * as z from "zod";
import minimist from "minimist";
import isString from "lodash/isString.js";
import cloneDeep from "lodash/cloneDeep.js";
import debug from "debug";
import chalk from "chalk";
import path from "path";
import {
applyType,
cleanupStringList,
getEnvAndArgs,
stripDashes,
} from './utils';
} from "./utils";
import type {
CommandOption,
ConfigInputsParsed,
ConfigResults,
} from './types';
import { optionsHelp } from './render';
GetTypeByTypeString,
MockHelpers,
} from "./types";
import { optionsHelp } from "./render";

export { easyConfig } from './easy-config';
export { easyConfig } from "./easy-config";

export const autoConfig = function <
TInput extends { [K in keyof TInput]: 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 } = getEnvAndArgs();
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);

const schemaObject = buildSchema(config);

const commandOptions = assembleConfigResults(config, {
let commandOptions = assembleConfigResults(config, {
cliArgs,
envKeys,
});
debugLog('commandOptions=', commandOptions);
debugLog("commandOptions=", commandOptions);

const results = verifySchema(schemaObject, commandOptions, {
verifySchema(schemaObject, commandOptions, {
cliArgs,
envKeys,
});

debugLog('DONE', JSON.stringify(commandOptions));
debugLog("DONE", JSON.stringify(commandOptions));
return commandOptions;
};

/**
*
* For testing, see test examples in ./test/index.test.ts
*/
export function addMockHelpers<
TInput extends { [K in keyof TInput]: TInput[K] }
>(data: TInput): MockHelpers<TInput> & ConfigResults<TInput> {
type Keys = keyof TInput;
let someMut = cloneDeep(data);
const theTruth = Object.freeze(data);
const baseMock: MockHelpers<TInput> = {
_set: (key: Keys | object, value?: TInput[Keys]) => {
if (typeof key === "object") {
Object.assign(someMut, key);
} else {
someMut[key as Keys] = value!;
}
},
_restore: () => Object.assign(someMut, theTruth),
_destroy: () => (someMut = {} as TInput & MockHelpers<TInput>),
};

return Object.entries(someMut).reduce(
(enhancedObject, [key, value]) =>
Object.defineProperty(enhancedObject, key, {
get() {
return someMut[key as Keys];
},
set(newValue: TInput[Keys]) {
someMut[key as Keys] = newValue;
},
}),
{ ...baseMock, ...someMut } as MockHelpers<TInput> & ConfigResults<TInput>
);
}

// (enhancedObject, {
// public set value(v : string) {
// [key]: {
// get() {
// return enhancedObject[key];
// },
// set(newValue: TInput[typeof key]) {
// if (prop in target) {
// target[prop as Keys] = value;
// return true;
// }
// return false;
// },
// },
// }),
// {} as any
// );
// }
// };

function buildSchema<TInput extends { [K in keyof TInput]: CommandOption }>(
config: TInput
) {
const schemaObject = z.object(
Object.entries<CommandOption>(config).reduce(
(schema, [name, commandOption]) => {
commandOption.type = commandOption.type || 'string';
commandOption.type = commandOption.type || "string";
schema[name as keyof TInput] = getOptionSchema({ commandOption });
return schema;
},
Expand All @@ -70,26 +129,26 @@ function verifySchema<TInput extends { [K in keyof TInput]: CommandOption }>(
config: ConfigResults<TInput>,
inputs: ConfigInputsParsed
): Record<string, unknown> {
const debugLog = debug('auto-config:verifySchema');
const debugLog = debug("auto-config:verifySchema");
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<string, string[]>);

const errorList = Object.entries(fieldErrors)
.map(
([message, errors]) =>
` - ${chalk.magentaBright(message)}: ${errors.join(', ')}`
` - ${chalk.magentaBright(message)}: ${errors.join(", ")}`
)
.join('\n');
.join("\n");
throw new Error(`${chalk.red.bold`ERROR:`} Found ${
issues.length
} Config Problem(s)!
Expand All @@ -113,18 +172,18 @@ function assembleConfigResults<
const commandOptions = Object.entries<CommandOption>(config).reduce(
(conf, [name, opt]) => {
if (opt) {
opt.type = opt.type || 'string';
opt.type = opt.type || "string";
const v = getOptionValue({
commandOption: opt,
inputCliArgs: cliArgs,
inputEnvKeys: envKeys,
});
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;
},
Expand All @@ -139,10 +198,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 void null;
}
console.error(`No package.json found from path ${__dirname}`);
Expand All @@ -152,7 +211,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
Expand All @@ -169,32 +228,32 @@ function getOptionSchema({
commandOption: CommandOption;
}) {
let zType =
opt.type === 'array'
opt.type === "array"
? z.array(z.string())
: opt.type === 'enum'
: opt.type === "enum"
? z.enum(opt.enum)
: z[opt.type || 'string']();
if (opt.type === 'boolean') {
: 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)
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)
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)
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)
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)
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)
if ("lt" in opt && typeof opt.lt === "number" && "lt" in zType)
zType = zType.lt(opt.lt);

return zType;
Expand All @@ -203,15 +262,15 @@ function getOptionSchema({
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;
},
{
Expand All @@ -231,40 +290,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)
Expand Down
Loading