util/config/configurator/configurator.ts (283 lines of code) (raw):
import { $ } from "bun";
import {
intro,
outro,
select,
isCancel,
cancel,
text,
password,
} from "@clack/prompts";
import color from "picocolors";
import { parseArgs } from "util";
type Prompts = Record<string, PromptData>;
type PromptData = {
label?: string;
type: string | string[];
default?: string;
value?: string;
};
const OPS = process.env.OPS || "ops";
export const AdditionalArgsMsg = "Additional arguments will be ignored.";
export const NotValidJsonMsg = "Not a valid JSON file";
export const FileNotFoundJsonMsg = "The JSON file was not found. Pathname: ";
export const BadConfigMsg =
"Bad configuration file. Check the help message (-h) to see the expected format.";
export const HelpMsg = `
Usage: config <configjson> [-o | --override]
Description:
Prompt the user for configuration data defined in the config.json file.
The script will ignore the keys from the input config that are already set in ops
and only prompt the user for the missing data, unless the override flag is set.
Then they will be saved in the ops config. You can also define a default value.
The config.json file must be a JSON file with the following structure:
{
"KEY": {
"type": "string"
},
"OTHER_KEY": {
"label": "An optional custom message",
"type": "int",
"default": "42"
},
...
}
The keys must be uppercase words (separated by underscores).
The value for the "type" key must be either string with the following values:
- string
- int
- float
- bool
- password
- an array of strings with specific values (an enum).
`;
export default async function main() {
const options: { showhelp: { type: 'boolean' }, override: { type: 'boolean' } } = {
showhelp: {
type: 'boolean',
},
override: {
type: 'boolean',
},
};
const { values, positionals } = parseArgs({
args: Bun.argv,
options: options,
strict: true,
allowPositionals: true,
});
if (values.showhelp) {
console.log(HelpMsg);
return process.exit(0);
}
const override = values.override || false;
// 1. Read input config json
const readPosRes = readPositionalFile(positionals);
if (!readPosRes.success) {
cancel(readPosRes.message);
return process.exit(1);
}
if (readPosRes.help) {
console.log(readPosRes.help);
return process.exit(0);
}
if (readPosRes.message) {
console.warn(readPosRes.message);
}
// 2. Parse the json
const jsonRes = await parsePositionalFile(readPosRes.jsonFilePath!);
if (!jsonRes.success) {
cancel(jsonRes.message);
return process.exit(1);
}
const config = jsonRes.body;
// 3. Validate the given config json
if (!isInputConfigValid(config)) {
cancel(BadConfigMsg);
return process.exit(1);
}
// 4. Run OPS to get the available config data
const { exitCode, stderr, stdout } = await $`${OPS} -config -d`.quiet();
if (exitCode !== 0) {
cancel(stderr.toString());
return process.exit(1);
}
const opsCurrentConfig = stdout.toString();
// 5. Remove the keys from config that are already in the opsConfig
let missingData = config;
if (!override) {
missingData = findMissingConfig(config, opsCurrentConfig);
}
// 6. Ask the user for the missing data
console.log();
intro(color.inverse(" ops configurator "));
await askMissingData(missingData, override);
// 7. Save the data to the config?
console.log();
outro("You're all set!");
}
async function askMissingData(missingData: Prompts, override: boolean = false) {
if (Object.keys(missingData).length === 0) {
outro("Configuration set from ops");
process.exit(0);
}
console.log();
if (!override) {
console.log("Configuration partially set from ops. Need a few more:");
}
for (const key in missingData) {
let inputFromPrompt: string;
const prompt: PromptData = missingData[key];
// let askedForPassword = false;
if (Array.isArray(prompt.type)) {
const defaultValueOK = prompt.default && prompt.type.includes(prompt.default);
if (prompt.default && !defaultValueOK) {
console.log();
console.warn(`The default value ${prompt.default} is not in the enum values.`);
}
const selected = await select({
message: prompt.label || `Pick a value for '${key}'`,
options: prompt.type.map((v) => ({ label: v, value: v })),
initialValue: defaultValueOK ? prompt.default : undefined,
});
if (!selected || isCancel(selected)) {
cancel("Operation cancelled");
process.exit(0);
}
inputFromPrompt = selected.toString();
// inputConfigs[key] = { ...prompt, value: selected.toString() };
} else if (prompt.type === "bool") {
const selected = await select({
initialValue: prompt.default === "true" || prompt.default as unknown as boolean === true ? "true" : "false",
message: prompt.label || `Pick a true/false for '${key}'`,
options: [
{ label: "true", value: "true" },
{ label: "false", value: "false" },
],
});
if (!selected || isCancel(selected)) {
cancel("Operation cancelled");
process.exit(0);
}
inputFromPrompt = selected.toString();
// inputConfigs[key] = { ...prompt, value: selected.toString() };
} else if (prompt.type === "password") {
if (prompt.default) {
console.log();
console.warn("Default password value is not supported. Please enter the password manually.");
}
const input = await password({
message: prompt.label || `Enter password value for ${key}`,
validate: (value) => {
if (!value) {
return "Password cannot be empty";
}
}
});
if (isCancel(input)) {
cancel("Operation cancelled");
process.exit(0);
}
inputFromPrompt = input;
// askedForPassword = true;
} else {
const defaultMsgFragment = prompt.default ? `(default: ${prompt.default})` : "";
const message = `Enter value for ${key} ${defaultMsgFragment} (${prompt.type})`;
let input = await text({
message: prompt.label || message,
// defaultValue: prompt.default?.toString(),
initialValue: prompt.default?.toString(),
validate: (value) => {
switch (prompt.type) {
case "int":
if (!Number.isInteger(Number(value))) {
return `Value for ${key} must be an integer number`;
}
break;
case "float":
if (!Number(value)) {
return `Value for ${key} must be a number`;
}
}
return;
}
}) as string;
if (isCancel(input)) {
cancel("Operation cancelled");
process.exit(0);
}
inputFromPrompt = input;
// inputConfigs[key] = { ...prompt, value: input };
}
// if (!askedForPassword) {
// console.log("Setting", key, "to", inputFromPrompt);
// } else {
// console.log("Setting", key, "to", "*".repeat(inputFromPrompt.length));
// }
console.log(`Setting ${key} to ${inputFromPrompt}`);
const { exitCode, stderr } =
await $`${OPS} -config ${key}=${inputFromPrompt}`.nothrow();
if (exitCode !== 0) {
cancel(stderr.toString());
return process.exit(1);
}
}
}
export function findMissingConfig(
config: Record<string, any>,
opsCurrentConfig: string
): Prompts {
let newConfig: Record<string, any> = {};
let opsConfigKeys = opsCurrentConfig.split("\n").map((line) => line.split("=")[0]);
for (const key in config) {
if (opsConfigKeys.includes(key)) {
continue;
}
newConfig[key] = config[key];
}
return newConfig;
}
export function readPositionalFile(positionals: string[]): {
success: boolean;
message?: string;
help?: string;
jsonFilePath?: string;
} {
if (positionals.length < 2) {
console.error("This should not happen");
return { success: false, message: "This should not happen" };
}
if (positionals.length === 2) {
return { success: true, help: HelpMsg };
}
if (positionals.length > 3) {
return {
success: true,
message: AdditionalArgsMsg,
jsonFilePath: positionals[2],
};
}
return { success: true, jsonFilePath: positionals[2] };
}
export async function parsePositionalFile(
path: string
): Promise<{ success: boolean; message?: string; body?: any }> {
const file = Bun.file(Bun.pathToFileURL(path));
if (!await file.exists()) {
return { success: false, message: FileNotFoundJsonMsg + Bun.pathToFileURL(path) };
}
try {
const contents = await file.json();
return { success: true, body: contents };
} catch (error) {
return { success: false, message: NotValidJsonMsg };
}
}
export function isInputConfigValid(body: Record<string, any>): boolean {
// 1. If the body is empty, return false
if (Object.keys(body).length === 0) {
return false;
}
// 2. Check that each key in the body has the keys as the Prompt type
for (const key in body) {
const value = body[key];
if (typeof value !== "object") {
return false;
}
if (!value.type) {
return false;
}
if (
!["string", "int", "float", "bool", "password"].includes(value.type) &&
!Array.isArray(value.type)
) {
return false;
}
}
// 3. Check that all the keys are uppercase and can only have underscores not at the beginning or end
for (const key in body) {
if (!/^[A-Z][A-Z_]|[0-9]*[A-Z]|[0-9]$/.test(key)) {
return false;
}
}
return true;
}