@mountainpass/hooked-cli
Version:
A tool for runnable scripts
323 lines (322 loc) • 13.6 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import fs from 'fs';
import inquirer from 'inquirer';
import YAML from 'yaml';
import defaults from './defaults.js';
import { displaySuccessfulScript, fetchHistory } from './history.js';
import { schemaValidator } from './schema/HookedSchema.js';
import { resolveScript } from './scriptExecutors/ScriptExecutor.js';
import { isScript } from './types.js';
import { Environment } from './utils/Environment.js';
import fileUtils from './utils/fileUtils.js';
import { fetchImports } from './utils/imports.js';
import logger from './utils/logger.js';
const isDefined = (o) => typeof o !== 'undefined' && o !== null;
export const stripLeadingEmojiSpace = (str) => {
return str.replace(/^\p{Extended_Pictographic}\s+/u, '');
};
export const startsWithEmojiSpace = (str) => /^\p{Extended_Pictographic}\s/u.test(str);
export const stripEmojis = (str) => {
return str.replace(/\p{Extended_Pictographic}/gu, '')
.replace(/\s+/g, ' ')
.trim();
};
export const normaliseString = (str) => {
return str.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
};
/**
* Finds a script, given a path.
*/
export const findScript = (config, scriptPath, options) => __awaiter(void 0, void 0, void 0, function* () {
var _a, _b;
let script = (_a = config.scripts) !== null && _a !== void 0 ? _a : {};
// inject _logs_ into scripts
if (isDefined(config.scripts)) {
const history = fetchHistory().map((log) => {
const display = displaySuccessfulScript(log, true);
const $cmd = displaySuccessfulScript(log, false);
return [display, { $cmd }];
});
// if (history.length > 0) {
script = Object.assign({ [defaults.getDefaults().LOGS_MENU_OPTION]: Object.fromEntries(history) }, script);
// } else {
// logger.debug('No history found.')
// }
}
const resolvedScriptPath = [];
for (const path of scriptPath) {
if (isDefined(script[path])) {
// find exact match
resolvedScriptPath.push(path);
script = script[path];
}
else if (isDefined(script[stripEmojis(path)])) {
// find exact match without emojis
resolvedScriptPath.push(path);
script = script[path];
}
else {
// try to find partial match
// search by prefix
const entries = Object.entries(script);
const found = entries.filter(([key, value]) => stripEmojis(normaliseString(key)).startsWith(normaliseString(path)));
if (found.length === 1) {
const foundKey = found[0][0];
resolvedScriptPath.push(foundKey);
script = found[0][1];
}
}
}
// no match... prompt user for which script to select
if (!isScript(script) && options.batch === true) {
throw new Error(`Script not found - '${scriptPath.join(' ')}' (interactive prompts disabled).`);
}
while (!isScript(script)) {
if (typeof script === 'undefined' ||
script === null ||
Object.keys(script).length === 0) {
const availableScripts = `\t- ${stringifyScripts(config).join('\t- ')}`;
throw new Error(`No scripts found at path: ${JSON.stringify(scriptPath)}\nDid you mean?\n${availableScripts}`);
}
let choices = [];
const modifiedScripts = {};
if (((_b = config.plugins) === null || _b === void 0 ? void 0 : _b.icons) === true) {
// if environment flag is set, add emoji
choices = Object.entries(script).map(([c, v]) => {
if (startsWithEmojiSpace(c)) {
return c;
}
else {
// 🪝📁✅🟢
const icon = isScript(v) ? '🪝 ' : '📁';
// const icon = isScript(v) ? '⚡' : '📁'
const modScript = `${icon} ${c}`;
modifiedScripts[modScript] = true;
return modScript;
}
});
}
else {
choices = Object.keys(script);
}
// ask user for the next script
// const stdout = new CaptureStream(process.stdout)
yield inquirer
.prompt([
{
type: 'list',
name: 'next',
message: 'Please select an option:',
pageSize: defaults.getDefaults().PAGE_SIZE,
default: choices[0],
choices,
loop: true,
// output: stdout
}
])
.then((answers) => {
const nextScript = modifiedScripts[answers.next] === true ? stripLeadingEmojiSpace(answers.next) : answers.next;
resolvedScriptPath.push(nextScript);
script = script[nextScript];
});
}
logger.debug(`Found script: ${resolvedScriptPath.join(' ')}`);
return [script, resolvedScriptPath];
});
/**
* Gets a list of executable scripts.
*/
export const stringifyScripts = (config) => {
const scripts = [];
const walk = (obj, path = []) => {
for (const key in obj) {
if (key.startsWith('$')) {
scripts.push(path.join(' '));
}
else {
walk(obj[key], [...path, key]);
}
}
};
walk(config.scripts);
return scripts;
};
/**
* Resolves an environment configuration.
* @param config
* @param envName
* @returns
*/
export const internalFindEnv = (config, envName = 'default', options) => {
var _a;
// look for exact match
if (isDefined(config.env) && isDefined(config.env[envName])) {
logger.debug(`Using environment: ${envName}`);
const newLocal = config.env[envName];
return [newLocal === null ? {} : newLocal, envName];
}
// if only one environment, always use that? No
// search by prefix
const envs = Object.entries((_a = config.env) !== null && _a !== void 0 ? _a : {});
const found = envs.filter(([key, value]) => key.startsWith(envName));
if (found.length === 1) {
const foundEnv = found[0][0];
logger.debug(`Using environment: ${foundEnv}`);
const newLocal = found[0][1];
return [newLocal === null ? {} : newLocal, foundEnv];
}
else if (found.length === 0 && envName === 'default') {
// ignore blank env:, if the environment is 'default'
return [{}, 'default'];
}
const availableEnvs = envs.map(([key, value]) => `\t- ${key}`).join('\n');
throw new Error(`Environment not found: ${envName}\nDid you mean?\n${envs.length > 0 ? availableEnvs : '(No environments available)'}`);
};
/* Aggregator function, for merging imported configs. */
const _mergeEnvAndScripts = (tmp, aggrEnvs, aggrScripts) => {
// merge scripts - easy, they should all have unique top level names!
Object.assign(aggrScripts, tmp.scripts);
// merge envs - harder, they can have the same top level names...
if (isDefined(tmp.env)) {
Object.entries(tmp.env).forEach(([key, envVars]) => {
// if they have the same top level name, then merge them...
if (isDefined(aggrEnvs[key])) {
aggrEnvs[key] = Object.assign(Object.assign({}, aggrEnvs[key]), envVars);
}
else {
aggrEnvs[key] = envVars;
}
});
}
};
export const _resolveAndMergeConfigurationWithImports = (config_1, ...args_1) => __awaiter(void 0, [config_1, ...args_1], void 0, function* (config, pullLatestFlag = false) {
// load and apply imports
const envs = {};
const scripts = {};
// resolve external imports
const allLocal = yield fetchImports(config.imports, pullLatestFlag);
// load imports from local
for (const localpath of allLocal) {
const filepath = fileUtils.resolvePath(localpath);
logger.debug(`Importing: ${filepath}`);
// files must be loaded in order
const tmp = yield loadConfig(filepath, pullLatestFlag);
_mergeEnvAndScripts(tmp, envs, scripts);
}
// do final top level merge
_mergeEnvAndScripts(config, envs, scripts);
// overwrite the existing config object!
config.env = envs;
config.scripts = scripts;
});
/**
* Finds and resolves the environment.
* @param config
* @param environment
* @returns
*/
export const fetchGlobalEnvVars = (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (config = {}, environmentNames = ['default'], options = {}, envVars = {}) {
// look for and apply all matching environments
const allEnvNames = new Set();
for (const envName of environmentNames) {
const [foundEnv = {}, resolvedEnvName] = internalFindEnv(config, envName, options);
// collect ALL environment variables (in no particular order!)
for (const [key, script] of Object.entries(foundEnv)) {
envVars[key] = script;
}
allEnvNames.add(resolvedEnvName);
}
return [envVars, [...allEnvNames]];
});
export const resolveEnvironmentVariables = (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (config = {}, envVars, stdin = {}, env = new Environment(), options = {}) {
// TODO resolve scripts, regardless of order (option 1 - dumb brute force resolution, option 2 - resolve in order)
// OPTION 1 - brute force, up to 5 attempts...
let remainingAttempts = Object.entries(envVars);
let errors = [];
while (remainingAttempts.length > 0) {
const retry = [];
errors = [];
// attempt to resolve variables, sequentially...
for (const [key, script] of remainingAttempts) {
try {
yield resolveScript(key, script, stdin, env, config, options, envVars, false, true);
}
catch (err) {
// could not resolve, add to retry list
errors.push(err);
retry.push([key, script]);
}
}
// no progress made, abort!
if (retry.length === remainingAttempts.length) {
logger.debug(`Retry stuck with ${retry.length} unresolved environment variable(s)...`);
break;
}
else if (retry.length > 0) {
// some progress made, log and retry
logger.debug(`Retrying ${retry.length} unresolved environment variable(s)...`);
}
// update remaining attempts
remainingAttempts = retry;
}
// if there are still remaining attempts...
if (remainingAttempts.length > 0 && errors.length > 0) {
const errorsString = `- ${errors.map((err) => err.message).join('\n- ')}`;
// const envString = `environment=${toJsonString(envVars, true)}`
throw new Error(`Could not resolve ${errors.length} environment variables:\n${errorsString}`);
}
// OLD WAY - assumes env vars can be resolved in order...
// for (const [key, script] of keyScriptPairs) {
// await resolveScript(key, script, stdin, env, config, options)
// }
});
export const populateScriptPath = (scripts, parentPaths = []) => {
for (const [key, script] of Object.entries(scripts)) {
if (isScript(script)) {
script._scriptPath = [...parentPaths, key].join(' ');
script._scriptPathArray = [...parentPaths, key];
}
else {
// TODO verify unique paths?
const firstWord = key.trim().split(' ')[0];
populateScriptPath(script, [...parentPaths, firstWord]);
}
}
};
export const loadConfig = (configFile_1, ...args_1) => __awaiter(void 0, [configFile_1, ...args_1], void 0, function* (configFile, pullLatestFlag = false, validate = false) {
var _a;
const fileExists = fs.existsSync(configFile);
// file exists
if (fileExists) {
const yamlStr = fs.readFileSync(configFile, 'utf-8');
const config = YAML.parse(yamlStr);
if (config === null) {
throw new Error(`Invalid YAML in ${configFile} - ${yamlStr}`);
}
// validate this configuration file (called recursively for imports)
if (!schemaValidator(configFile, config)) {
if (validate) {
throw new Error(`Invalid configuration file: ${configFile}`);
}
else {
logger.warn("Attempting to continue with invalid configuration file...");
}
}
// add _scriptPath fields to all scripts
populateScriptPath((_a = config.scripts) !== null && _a !== void 0 ? _a : {});
// merge imports with current configuration
yield _resolveAndMergeConfigurationWithImports(config, pullLatestFlag);
return config;
}
else {
throw new Error(`No ${configFile} file found.`);
}
});