@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
300 lines (292 loc) • 11.4 kB
JavaScript
/**
* Module for process and shell command management.
* Provides utilities for executing shell commands, managing signals, and
* handling environment details.
*
* Execution semantics:
* - `shellExec(cmd)` throws `ShellExecError` on non-zero exit (fail-fast
* is the default). CI/CD chains observe the failure end-to-end.
* - `shellExec(cmd, { silentOnError: true })` opts out — returns the
* `ShellString` result with `.code/.stdout/.stderr` so callers can
* branch on the exit code themselves. Use for existence checks
* (`test -x …`, `command -v …`, `kubectl get` when "missing" is a
* normal answer).
* - `shellExec(cmd, { cwd: "..." })` runs hermetically in `cwd` without
* touching shelljs's global state.
* - All children spawned by `shellExec` register in
* `ProcessController.children` so SIGINT/SIGTERM forwarding can reach
* them before the parent exits.
*
* @module src/server/process.js
* @namespace Process
*/
// https://nodejs.org/api/process
import shell from 'shelljs';
import { loggerFactory } from './logger.js';
import clipboard from 'clipboardy';
import Underpost from '../index.js';
import { getNpmRootPath } from './conf.js';
const logger = loggerFactory(import.meta);
/**
* Gets the current working directory, replacing backslashes with forward slashes for consistency.
* @memberof Process
* @returns {string} The root directory path.
*/
const getRootDirectory = () => process.cwd().replace(/\\/g, '/');
/**
* Controls and manages process-level events and signals.
*
* Subprocess registry: any child process tracked here will receive
* SIGTERM (followed by SIGKILL after a short grace period) when the
* parent receives SIGINT or SIGTERM. This prevents orphaned children
* during Ctrl+C in dev and during pod-termination in K8S.
*
* @namespace ProcessController
*/
class ProcessController {
/**
* List of signals to listen for for graceful shutdown/handling.
* @memberof ProcessController
* @type {string[]}
*/
static SIG = [
'SIGPIPE',
'SIGHUP',
'SIGTERM',
'SIGINT',
'SIGBREAK',
'SIGWINCH',
// 'SIGKILL',
// 'SIGSTOP',
'SIGBUS',
'SIGFPE',
'SIGSEGV',
'SIGILL',
];
/**
* Registry of currently running tracked child processes.
* Populated when callers spawn via the streaming Node-native path
* (future expansion). The sets are exposed so signal handlers and
* test harnesses can introspect / clean up the registry.
*/
static children = new Set();
/** Internal: forward terminating signals to all tracked children. */
static _forwardToChildren(sig) {
if (ProcessController.children.size === 0) return;
for (const child of [...ProcessController.children]) {
try {
if (!child.killed) child.kill(sig);
} catch (_) {
// child may already have exited; ignore.
}
}
// Hard SIGKILL after 5s grace if any child is still alive.
setTimeout(() => {
for (const child of [...ProcessController.children]) {
try {
if (!child.killed) child.kill('SIGKILL');
} catch (_) {
/* noop */
}
}
}, 5000).unref();
}
/**
* Sets up listeners for various process signals defined in {@link ProcessController.SIG}.
* Handles graceful exit on 'SIGINT' (Ctrl+C) — but first forwards the
* signal to every tracked child so they get a chance to clean up.
* @memberof ProcessController
* @returns {Array<process.Process>} An array of process listener handles.
*/
static onSigListen() {
return ProcessController.SIG.map((sig) =>
process.on(sig, (...args) => {
ProcessController.logger.info(`process on ${sig}`, args);
switch (sig) {
case 'SIGINT':
case 'SIGTERM':
case 'SIGHUP':
ProcessController._forwardToChildren('SIGTERM');
// Give children a moment to exit cleanly before our own exit.
if (sig === 'SIGINT') {
setTimeout(() => process.exit(130), 200).unref();
}
break;
default:
break;
}
}),
);
}
/**
* Initializes the ProcessController.
* Sets up signal listeners, registers a listener for the 'exit' event, and cleans up temporary deployment environment variables.
* @memberof ProcessController
* @param {Object} logger - The logger instance to use for internal logging.
* @returns {void}
*/
static init(logger) {
ProcessController.logger = logger;
process.on('exit', (...args) => {
ProcessController.logger.info(`process on exit`, args);
// Last-chance reap: any tracked child still alive at exit time
// gets a hard kill so the parent does not leak orphans into the
// pod / shell session.
ProcessController._forwardToChildren('SIGKILL');
});
ProcessController.onSigListen();
}
}
/**
* `ShellExecError` — thrown by `shellExec` when the underlying command
* exits with a non-zero code (the default fail-fast behaviour). Carries
* the exit code, stdout, and stderr for inspection by callers / CI
* pipelines that need structured failure data.
*/
class ShellExecError extends Error {
constructor(cmd, code, stdout, stderr) {
super(`shellExec failed (exit=${code}): ${cmd}`);
this.name = 'ShellExecError';
this.cmd = cmd;
this.code = code;
this.stdout = stdout;
this.stderr = stderr;
}
}
/**
* Executes a shell command using shelljs.
*
* **Default behaviour is fail-fast**: a non-zero exit code throws
* `ShellExecError`. Callers that need to branch on the exit code
* (existence checks, optional commands) must pass `silentOnError: true`
* to opt out of throwing.
*
* The async-callback path is exempt from the throw — shelljs delivers
* `(code, stdout, stderr)` to the callback, which owns its own error
* handling.
*
* @memberof Process
* @param {string} cmd - The command string to execute.
* @param {Object} [options] - Options for execution.
* @param {boolean} [options.silent=false] - Suppress child stdout/stderr to the parent terminal.
* @param {boolean} [options.async=false] - Run the command asynchronously (use with `callback`).
* @param {boolean} [options.stdout=false] - Return stdout string instead of the `ShellString` result object.
* @param {boolean} [options.disableLog=false] - Skip the `[process] cmd …` info log line.
* @param {Function} [options.callback=null] - Async callback `(code, stdout, stderr) => void` when `async: true`.
* @param {boolean} [options.silentOnError=false] - When `true`, swallow non-zero exits and return the `ShellString` instead of throwing. Inverse of the previous `throwOnError` flag.
* @param {string} [options.cwd] - Hermetic working directory (snapshotted + restored — does NOT leak).
* @returns {string|shelljs.ShellString} `ShellString` by default; the stdout string when `stdout: true`.
* @throws {ShellExecError} On non-zero exit when `silentOnError` is not set.
*/
const shellExec = (cmd, options = {}) => {
if (!options.disableLog) logger.info(`cmd`, cmd);
// Whitelist exactly the keys `shelljs.exec` understands. Passing our own
// bookkeeping keys through (or a literal `cwd: undefined`) makes shelljs
// call `path.resolve(undefined)` and crash with ERR_INVALID_ARG_TYPE.
const shellOpts = {};
if (options.silent !== undefined) shellOpts.silent = options.silent;
if (options.async !== undefined) shellOpts.async = options.async;
// Hermetic cwd. shelljs.cd mutates a process-wide global; instead we
// snapshot the current cwd here, switch for the duration of this call,
// and restore in `finally`. We deliberately do NOT forward `cwd` to
// shelljs — leaving its `cwd` unset means it inherits our just-changed
// `process.cwd()`, and we keep full control of restore semantics.
const previousCwd = options.cwd ? process.cwd() : null;
if (options.cwd) {
try {
process.chdir(options.cwd);
} catch (err) {
if (Underpost.env.isInsideContainer()) Underpost.env.set('container-status', 'error')
throw new ShellExecError(cmd, -1, '', `chdir(${options.cwd}) failed: ${err.message}`);
}
}
try {
if (options.callback) {
// Async path. shelljs invokes the callback with (code, stdout, stderr).
// The callback owns its own error handling; the throw default does
// not apply here.
return shell.exec(cmd, shellOpts, options.callback);
}
const result = shell.exec(cmd, shellOpts);
if (!options.silentOnError && result && typeof result.code === 'number' && result.code !== 0) {
if (Underpost.env.isInsideContainer()) Underpost.env.set('container-status', 'error')
throw new ShellExecError(cmd, result.code, result.stdout || '', result.stderr || '');
}
return options.stdout ? result.stdout : result;
} finally {
if (previousCwd) {
try {
process.chdir(previousCwd);
} catch (_) {
/* best-effort restore */
}
}
}
};
/**
* Changes the current working directory using shelljs.
*
* Note: `shellCd` mutates global state. Prefer `shellExec(cmd, { cwd })`
* for one-shot directory-scoped commands; use `shellCd` only for the
* outermost shell where the cwd should persist across many calls.
*
* @memberof Process
* @param {string} cd - The path to change the directory to.
* @param {Object} [options] - Options for the CD operation.
* @param {boolean} [options.disableLog=false] - Prevent logging of the CD command.
* @returns {shelljs.ShellString} The result of the shelljs cd command.
*/
const shellCd = (cd, options = { disableLog: false }) => {
if (!options.disableLog) logger.info(`cd`, cd);
return shell.cd(cd);
};
/**
* Wraps a command to run it as a daemon process in a shell (keeping the process alive/terminal open).
*
* NB: callers must ensure `cmd` does not contain unescaped single quotes —
* the wrapper uses `bash -c '<cmd>; …'`. For arbitrary user input prefer
* a heredoc or a temporary script file.
*
* @memberof Process
* @param {string} cmd - The command to daemonize.
* @returns {string} The shell command string for the daemon process.
*/
const daemonProcess = (cmd) => `exec bash -c '${cmd}; exec tail -f /dev/null'`;
/**
* Retrieves the process ID (PID) of the most recently created gnome-terminal instance.
* Note: This function is environment-specific (GNOME/Linux) and uses `pgrep -n`.
* @memberof Process
* @returns {number|null} The PID of the last gnome-terminal process, or null if none running.
*/
// list all terminals: pgrep gnome-terminal
// list last terminal: pgrep -n gnome-terminal
const getTerminalPid = () => {
const raw = shellExec(`pgrep -n gnome-terminal`, { stdout: true, silent: true, silentOnError: true });
if (!raw || !raw.trim()) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
};
/**
* Copies text content to the system clipboard using clipboardy.
* Logs the copied content for confirmation.
* @memberof Process
* @param {string} [data='🦄'] - The data to copy. Defaults to '🦄'.
* @returns {void}
*/
function pbcopy(data) {
clipboard.writeSync(data || '🦄');
logger.info(`copied to clipboard`, clipboard.readSync());
}
export {
ProcessController,
ShellExecError,
getRootDirectory,
shellExec,
shellCd,
pbcopy,
getTerminalPid,
daemonProcess,
};