@mountainpass/hooked-cli
Version:
A tool for runnable scripts
212 lines (211 loc) • 11 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());
});
};
/* eslint-disable no-template-curly-in-string */
/* eslint-disable max-len */
import child_process from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import { CaptureWritableStream } from '../common/CaptureWritableStream.js';
import { isDefined, isDockerCmdScript, isSSHCmdScript } from '../types.js';
import ApplicationMode from '../utils/ApplicationMode.js';
import { Environment } from '../utils/Environment.js';
import fileUtils from '../utils/fileUtils.js';
import logger from '../utils/logger.js';
import { resolveResolveScript } from './ScriptExecutor.js';
export const randomString = () => crypto.randomBytes(20).toString('hex');
export const injectEnvironmentInScript = (content, env) => {
// inject environment exports into content, IF provided
if (isDefined(env)) {
const REGEX_HAS_HASHBANG_LEADINGLINE = /^#![^\n]+\n/gm;
const envexports = env.envToShellExports();
// inject environment variables at the head of the file...
const match = REGEX_HAS_HASHBANG_LEADINGLINE.exec(content);
if (match != null) {
content = content.substring(0, REGEX_HAS_HASHBANG_LEADINGLINE.lastIndex) + envexports + content.substring(REGEX_HAS_HASHBANG_LEADINGLINE.lastIndex);
}
else {
// NOTE: hashbang required, otherwise -> Underlying error: "spawn Unknown system error -8"
content = "#!/bin/sh\n" + envexports + content;
}
}
return content.endsWith('\n') ? content : (content + '\n');
};
/**
* Writes an executable file. Optional injects environment variables into the file.
* @param filepath
* @param content
* @param env - optional
*/
const writeScript = (filepath, content, env) => {
const newContent = injectEnvironmentInScript(content, env);
logger.debug(`Writing file - ${filepath}`);
fs.writeFileSync(filepath, newContent, 'utf-8');
fs.chmodSync(filepath, 0o755);
};
export const childProcesses = [];
export const dockerNames = [];
// type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void
// interface ExecOutput { error: ExecException | null, stdout: string, stderr: string }
// /**
// * Internal function for running a process.
// * @param cmd
// * @param opts
// * @param customOpts
// * @returns
// */
// export const createProcessAsync = async (cmd: string, opts: ExecSyncOptions, customOpts: CustomOptions): Promise<string> => {
// logger.debug(`Creating process async - ${cmd}`)
// // const buffer = child_process.execSync(cmd, { ...opts, stdio: customOpts.captureStdout ? undefined : customOpts.printStdio ? 'inherit' : 'ignore' })
// // const stdout = buffer !== null ? buffer.toString() : ''
// const { stdout } = await new Promise<ExecOutput>((resolve, reject) => {
// child_process.exec(cmd, { ...opts }, (error, stdout, stderr) => {
// if (error != null) {
// reject(error)
// } else {
// resolve({ error, stdout, stderr })
// }
// })
// })
// if (!customOpts.captureStdout && customOpts.printStdio) {
// logger.info(stdout)
// }
// return customOpts.captureStdout ? stdout : ''
// }
export const createProcess = (cmdFilePath, stdoutFilePath, stderrFilePath, opts, customOpts) => __awaiter(void 0, void 0, void 0, function* () {
const inherit = !customOpts.captureStdout && customOpts.printStdio && ApplicationMode.getApplicationMode() !== 'test';
logger.debug(`Creating process sync - ${cmdFilePath} - inherit=${inherit ? 'Y' : 'N'}`);
const stdoutFD = fs.openSync(stdoutFilePath, 'a');
const stderrFD = fs.openSync(stderrFilePath, 'a');
const sysout = fs.createWriteStream('', { fd: stdoutFD });
const syserr = fs.createWriteStream('', { fd: stderrFD });
const child = child_process.spawn(cmdFilePath, Object.assign(Object.assign({}, opts), { stdio: inherit ? 'inherit' : ['ignore', sysout, syserr] }));
const exitCode = yield new Promise(resolve => child.on('close', resolve));
if (exitCode !== 0) {
const error = new Error(`Command failed: ${cmdFilePath}`);
error.status = exitCode;
throw error;
}
// to facilitate testing...
const stdoutContent = yield fsPromises.readFile(stdoutFilePath, { encoding: 'utf-8' });
const tmp = new CaptureWritableStream();
tmp.whenUpdated(stdoutContent);
tmp.whenFinished(stdoutContent);
// logger.debug(stdout.getCaptured())
return customOpts.captureStdout ? stdoutContent : '';
});
/**
* Executes the provided multiline command, and returns the stdout as a string.
* @param paths - the script paths
* @param multilineCommand
* @param dockerImage - optional - if provided, runs the command in a docker container.
* @param opts
* @returns
*/
export const executeCmd = (key_1, script_1, options_1, ...args_1) => __awaiter(void 0, [key_1, script_1, options_1, ...args_1], void 0, function* (key, script, options, opts = undefined, env, customOpts, timeoutMs) {
var _a;
// keep track of files that need to be cleaned up post run.
const cleanupFiles = [];
try {
// N.B. use randomString to stop script clashes (e.g. when calling another hooked command, from this command!)
const rand = randomString();
const scriptName = `${key.replace(/[^\w\d-]+/g, '')}-${rand}`;
const filepath = fileUtils.resolvePath(`.tmp-${scriptName}.sh`);
const stdoutpath = `${filepath}.stdout`;
const stderrpath = `${filepath}.stderr`;
const envfile = fileUtils.resolvePath(`.env-${scriptName}.txt`);
const parent = (_a = options.dockerHookedDir) !== null && _a !== void 0 ? _a : path.dirname(filepath);
const additionalOpts = { timeout: isDefined(timeoutMs) ? timeoutMs : undefined };
cleanupFiles.push(filepath);
cleanupFiles.push(envfile);
cleanupFiles.push(stdoutpath);
cleanupFiles.push(stderrpath);
// add "HOOKED_ROOT=false" to all child environments...
env.putResolved('HOOKED_ROOT', 'false');
// run script based on underlying implementations
if (isDockerCmdScript(script)) {
// write a docker file, and an env file...
const dockerfilepath = fileUtils.resolvePath(`.tmp-docker-${scriptName}.sh`);
cleanupFiles.push(dockerfilepath);
writeScript(dockerfilepath, script.$cmd);
writeScript(envfile, env.envToDockerEnvfile());
const dockerName = rand;
const DEFAULT_DOCKER_SCRIPT = 'docker run -t --rm --network host --entrypoint "" --env-file "${envfile}" -w "${parent}" -v "${parent}:${parent}" --name ${dockerName} ${dockerImage} /bin/sh -c "${filepath}"';
const { DOCKER_SCRIPT: dockerScript = DEFAULT_DOCKER_SCRIPT } = env.global;
const cmd = resolveResolveScript('-', { $resolve: dockerScript }, new Environment().putAllGlobal({
envfile,
filepath: path.join(parent, path.basename(dockerfilepath)),
dockerImage: script.$image,
dockerName,
parent
}), false);
dockerNames.push(dockerName);
// write a script to run the docker (include system env vars - these may be required e.g. DOCKER_HOST)...
const tmp = env.clone().putAllResolved(process.env, false);
writeScript(filepath, cmd, tmp);
return yield createProcess(filepath, stdoutpath, stderrpath, Object.assign(Object.assign({}, additionalOpts), opts), customOpts);
// end
}
else if (isSSHCmdScript(script)) {
// run on remote machine
const sshfilepath = fileUtils.resolvePath(`.tmp-ssh-${scriptName}.sh`);
cleanupFiles.push(sshfilepath);
writeScript(sshfilepath, script.$cmd, env);
const DEFAULT_SSH_SCRIPT = 'ssh -q -T "${user_at_server}" < "${filepath}"';
const { SSH_SCRIPT: sshScript = DEFAULT_SSH_SCRIPT } = env.global;
const sshConnection = resolveResolveScript('-', { $resolve: script.$ssh }, env, false);
const cmd = resolveResolveScript('-', { $resolve: sshScript }, new Environment().putAllGlobal({
envfile,
filepath: sshfilepath,
user_at_server: sshConnection,
parent
}), false);
// write a script to execute the shell script on the remote machine... (include system env vars - these may be required e.g. DOCKER_HOST)...
const tmp = env.clone().putAllResolved(process.env, false);
writeScript(filepath, cmd, tmp);
return yield createProcess(filepath, stdoutpath, stderrpath, Object.assign(Object.assign({}, additionalOpts), opts), customOpts);
// end
}
else {
// otherwise fallback to running a script on the local machine
writeScript(filepath, script.$cmd, env);
return yield createProcess(filepath, stdoutpath, stderrpath, Object.assign(Object.assign({}, additionalOpts), opts), customOpts);
// end
}
}
catch (err) {
const status = isDefined(err.status) ? err.status : err.code;
const message = err.message;
err.message = `Command failed with status code ${status}\n` +
`Underlying error: "${message}"\n` +
'Consider adding a "set -ve" to your $cmd to see which line errored.';
throw err;
}
finally {
// cleanup files... (if enabled!)
if (options.skipCleanup !== true) {
yield Promise.all(cleanupFiles.map((f) => __awaiter(void 0, void 0, void 0, function* () {
logger.debug(`Removing file - ${f}`);
if (fs.existsSync(f)) {
try {
yield fsPromises.unlink(f);
}
catch (err) {
const message = err.message;
if (!message.startsWith('ENOENT: no such file or directory')) {
logger.warn(`WARN: could not delete file - ${f} Reason: ${err.message}`);
}
}
}
})));
}
}
});