@mountainpass/hooked-cli
Version:
A tool for runnable scripts
255 lines (254 loc) • 9.36 kB
JavaScript
import { isDefined, isString, sortCaseInsensitive } from '../types.js';
import logger from './logger.js';
/**
* Retrieves all ${...} references from a string.
* @param str e.g. 'hello ${name}'
* @returns e.g. ['name']
*/
export const getEnvVarRefs = (str) => {
const regex = /\${([^}]+)}/g;
return Object.keys([...str.matchAll(regex)].reduce((prev, curr) => {
// allow environment variable defaults (shell & bash syntax)
const envvar = curr[1];
// exclude env vars that have a fallback default value
const hasDefault = envvar.includes(':') || envvar.includes('=');
if (!hasDefault) {
prev[envvar] = 1;
}
return prev;
}, {}));
};
export function toJsonString(env, pretty = false) {
const sorted = Object.fromEntries(Object.entries(env)
.sort((a, b) => sortCaseInsensitive(a[0], b[0])));
return pretty ? JSON.stringify(sorted, null, 2) : JSON.stringify(sorted);
}
export class Environment {
constructor() {
/** Used for resolving variables, but not intended to be "kept". */
this.global = {};
/** Used for resolved variables, intended to be "kept". */
this.resolved = {};
/** Transient variables, that can be explicitly purged. */
this.secrets = {};
/** All keys defined here, should be excluded from resolution. */
this.doNotResolveList = [];
}
purgeSecrets() {
this.secrets = {};
}
/**
* Returns all environment variables, excluding secret variables.
* @returns
*/
getAll() {
// N.B. order is important, because resolved variables should override global variables
return Object.assign(Object.assign({}, this.global), this.resolved);
// N.B. secrets go in... but secrets should not come out!
}
setDoNotResolve(keys) {
this.doNotResolveList = [...keys];
}
hasSecret(key) {
return typeof this.secrets[key] === 'string';
}
isSecret(key) {
return /^.*secret.*$/i.test(key);
}
// put
putGlobal(key, value) {
// reject invalid values
if ((isString(value) && value.trim() === '') || !isDefined(value) || value === null) {
logger.debug(`Received invalid global value '${value}' for key '${key}', ignoring...`);
}
if (this.isSecret(key)) {
this.secrets[key] = value;
}
else {
this.global[key] = value;
}
return this;
}
putResolved(key, value) {
// reject invalid values
if ((isString(value) && value.trim() === '') || !isDefined(value) || value === null) {
logger.debug(`Received invalid resolved value '${value}' for key '${key}', ignoring...`);
}
if (this.isSecret(key)) {
this.putSecret(key, value);
}
else {
this.resolved[key] = value;
}
return this;
}
putSecret(key, value) {
// reject invalid values
if ((isString(value) && value.trim() === '') || !isDefined(value) || value === null) {
logger.debug(`Received invalid secret value '${value}' for key '${key}', ignoring...`);
}
this.secrets[key] = value;
return this;
}
// putAll
putAllGlobal(env) {
Object.entries(env).forEach(([key, value]) => {
this.putGlobal(key, value);
});
return this;
}
/**
* Put's all values into the resolved environment.
* @param env
* @param overwrite if true, then overwrite existing values. if false, then keep existing values. (default = true)
* @returns
*/
putAllResolved(env, overwrite = true) {
Object.entries(env).forEach(([key, value]) => {
if (isDefined(this.resolved[key]) && !overwrite) {
// do nothing... keep existing value
}
else {
this.putResolved(key, value);
}
});
return this;
}
putAllSecrets(env) {
Object.entries(env).forEach(([key, value]) => {
this.putSecret(key, value);
});
return this;
}
putOverwrite(key, value) {
const g = this.global[key];
if (isString(g) && g.trim().length > 0) {
this.putGlobal(key, value);
}
const r = this.resolved[key];
if (isString(r) && r.trim().length > 0) {
this.putResolved(key, value);
}
const s = this.secrets[key];
if (isString(s) && s.trim().length > 0) {
this.putSecret(key, value);
}
return this;
}
willNotBeResolved(key) {
return this.doNotResolveList.includes(key);
}
getMissingRequiredKeys(resolveMe) {
if (typeof resolveMe !== 'string')
throw new Error(`resolveMe must be a string, but was ${typeof resolveMe}`);
const requiredKeys = getEnvVarRefs(resolveMe);
const all = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets);
const missingKeys = requiredKeys.filter(key => typeof all[key] === 'undefined' ||
all[key] === null ||
(typeof all[key] === 'string' && all[key] === ''));
return missingKeys;
}
// RESOLVING VARIABLES
isResolvableByKey(key) {
const value = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets)[key];
const isResolvable = isString(value) && value.trim().length > 0;
// logger.debug(`isResolvableByKey('${key}') = ${String(isResolvable)}`)
return isResolvable;
}
resolveByKey(key) {
// OLD return this.getAll()[key] - we want to resolve JUST IN TIME now... not before!
if (this.isResolvableByKey(key)) {
const value = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets)[key];
return this.resolve(value, key);
}
else {
throw new Error(`Environment key '${key}' is not present.`);
}
}
/**
* Resolve the value of a string, using the current environment.
* @param resolveMe
* @param key
* @returns
*/
resolve(resolveMe, key = 'NOT_DEFINED') {
if (typeof resolveMe !== 'string')
throw new Error(`resolveMe must be a string, but was ${typeof resolveMe}`);
// EXEMPT_ENVIRONMENT_KEYWORDS are special exemptions - that are internally resolved!
if (this.willNotBeResolved(key))
return resolveMe;
// check for missing environment variables
// const requiredKeys = getEnvVarRefs(resolveMe)
const missingKeys = this.getMissingRequiredKeys(resolveMe);
if (missingKeys.length > 0) {
// eslint-disable-next-line max-len
const foundString = `Found: ${toJsonString(this.getAll(), true)}`;
throw new Error(`Environment '${key}' is missing required environment variables: ${JSON.stringify(missingKeys
.sort(sortCaseInsensitive))}.\n${foundString}`);
}
// use string replacement to resolve from the resolvedEnv
const all = Object.assign(Object.assign(Object.assign({}, this.global), this.resolved), this.secrets);
const newValue = resolveMe.replace(/\${([^}]+)}/g, (match, p1) => all[p1]);
return newValue;
}
/**
* Resolve the value, and put it in the resolved environment.
* @param key
* @param resolveMe
* @returns
*/
resolveAndPutResolved(key, resolveMe) {
const value = this.resolve(resolveMe, key);
this.putResolved(key, value);
return value;
}
/**
* Clones this object to a new instance. Includes secrets.
* @returns
*/
clone() {
const env = new Environment();
env.global = Object.assign({}, this.global);
env.resolved = Object.assign({}, this.resolved);
env.secrets = Object.assign({}, this.secrets);
env.doNotResolveList = [...this.doNotResolveList];
return env;
}
/**
* Replaces the provided environment variables with this instance. Excludes secrets.
* @param env
*/
replace(env) {
this.global = Object.assign({}, env.global);
this.resolved = Object.assign({}, env.resolved);
// this.secrets = { ...env.secrets }
this.doNotResolveList = [...env.doNotResolveList];
}
/**
* Converts the resolved environment variables to a docker .env file string.
* @returns
*/
envToDockerEnvfile() {
return Object.entries(this.resolved).map(([k, v]) => `${k}=${v}\n`)
.sort(sortCaseInsensitive)
.join('');
}
/**
* Converts the resolved environment variables to a shell exports string.
* @returns
*/
envToShellExports() {
const entries = Object.entries(this.resolved);
if (entries.length === 0)
return '';
return '\n' + entries.map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"\n`)
.sort(sortCaseInsensitive)
.join('') + '\n';
}
toJsonStringResolved(pretty = false) {
return toJsonString(this.resolved, pretty);
}
toString() {
return JSON.stringify(this.getAll());
}
}