summaryrefslogtreecommitdiff
path: root/lib/process
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-07-27 17:03:10 -0700
committerElizabeth Hunt <me@liz.coffee>2025-07-27 18:30:30 -0700
commit9970036d203ba2d0a46b35ba6fad21d49441cdd4 (patch)
treea585d13933bf4149dcb07e28526063d071453105 /lib/process
downloadpengueno-9970036d203ba2d0a46b35ba6fad21d49441cdd4.tar.gz
pengueno-9970036d203ba2d0a46b35ba6fad21d49441cdd4.zip
hai
Diffstat (limited to 'lib/process')
-rw-r--r--lib/process/argv.ts79
-rw-r--r--lib/process/env.ts25
-rw-r--r--lib/process/exec.ts86
-rw-r--r--lib/process/index.ts5
-rw-r--r--lib/process/signals.ts49
-rw-r--r--lib/process/validate_identifier.ts18
6 files changed, 262 insertions, 0 deletions
diff --git a/lib/process/argv.ts b/lib/process/argv.ts
new file mode 100644
index 0000000..396fa96
--- /dev/null
+++ b/lib/process/argv.ts
@@ -0,0 +1,79 @@
+import { Either, type Mapper, type IEither, Optional } from '@emprespresso/pengueno';
+
+export const isArgKey = <K extends string>(k: string): k is K => k.startsWith('--');
+
+interface ArgHandler<V> {
+ absent?: V;
+ unspecified?: V;
+ present: Mapper<string, V>;
+}
+
+export const getArg = <K extends string, V>(
+ arg: K,
+ argv: Array<string>,
+ whenValue: ArgHandler<V>,
+): IEither<Error, V> => {
+ const argIndex = Optional.from(argv.findIndex((_argv) => isArgKey(_argv) && _argv.split('=')[0] === arg)).filter(
+ (index) => index >= 0 && index < argv.length,
+ );
+ if (!argIndex.present()) {
+ return Optional.from(whenValue.absent)
+ .map((v) => Either.right<Error, V>(v))
+ .orSome(() =>
+ Either.left(
+ new Error(`arg ${arg} is not present in arguments list and does not have an 'absent' value`),
+ ),
+ )
+ .get();
+ }
+
+ return argIndex
+ .flatMap((idx) =>
+ Optional.from(argv.at(idx)).map((_argv) => (_argv.includes('=') ? _argv.split('=')[1] : argv.at(idx + 1))),
+ )
+ .filter((next) => !isArgKey(next))
+ .map((next) => whenValue.present(next))
+ .orSome(() => whenValue.unspecified)
+ .map((v) => Either.right<Error, V>(<V>v))
+ .get();
+};
+
+type MappedArgs<
+ Args extends ReadonlyArray<string>,
+ Handlers extends Partial<Record<Args[number], ArgHandler<unknown>>>,
+> = {
+ [K in Args[number]]: K extends keyof Handlers ? (Handlers[K] extends ArgHandler<infer T> ? T : string) : string;
+};
+
+export const argv = <
+ const Args extends ReadonlyArray<string>,
+ const Handlers extends Partial<Record<Args[number], ArgHandler<unknown>>>,
+>(
+ args: Args,
+ handlers?: Handlers,
+ argv = process.argv.slice(2),
+): IEither<Error, MappedArgs<Args, Handlers>> => {
+ type Result = MappedArgs<Args, Handlers>;
+
+ const defaultHandler: ArgHandler<string> = { present: (value: string) => value };
+
+ const processArg = (arg: Args[number]): IEither<Error, [Args[number], unknown]> => {
+ const handler = handlers?.[arg] ?? defaultHandler;
+ return getArg(arg, argv, handler).mapRight((value) => [arg, value] as const);
+ };
+
+ const res = args
+ .map(processArg)
+ .reduce(
+ (acc: IEither<Error, Partial<Result>>, current: IEither<Error, [Args[number], unknown]>) =>
+ acc.flatMap((accValue) =>
+ current.mapRight(([key, value]) => ({
+ ...accValue,
+ [key]: value,
+ })),
+ ),
+ Either.right(<Partial<Result>>{}),
+ )
+ .mapRight((result) => <Result>result);
+ return res;
+};
diff --git a/lib/process/env.ts b/lib/process/env.ts
new file mode 100644
index 0000000..f59fadf
--- /dev/null
+++ b/lib/process/env.ts
@@ -0,0 +1,25 @@
+import { IOptional, Either, Optional, type IEither, type ObjectFromList } from '@emprespresso/pengueno';
+
+// type safe environment variables
+
+export const getEnv = (name: string): IOptional<string> => Optional.from(process.env[name]);
+
+export const getRequiredEnv = <V extends string>(name: V): IEither<Error, string> =>
+ Either.fromFailable(() => getEnv(name).get()).mapLeft(
+ () => new Error(`environment variable "${name}" is required D:`),
+ );
+
+export const getRequiredEnvVars = <V extends string>(vars: Array<V>): IEither<Error, ObjectFromList<typeof vars>> => {
+ type Environment = ObjectFromList<typeof vars>;
+ const emptyEnvironment = Either.right<Error, Environment>(<Environment>{});
+ const addTo = (env: Environment, key: V, val: string) =>
+ <Environment>{
+ ...env,
+ [key]: val,
+ };
+ return vars.reduce(
+ (environment, key) =>
+ environment.joinRight(getRequiredEnv(key), (value, environment) => addTo(environment, key, value)),
+ emptyEnvironment,
+ );
+};
diff --git a/lib/process/exec.ts b/lib/process/exec.ts
new file mode 100644
index 0000000..f8d572c
--- /dev/null
+++ b/lib/process/exec.ts
@@ -0,0 +1,86 @@
+import {
+ Either,
+ IEither,
+ type ITraceable,
+ LogLevel,
+ LogMetricTraceSupplier,
+ Metric,
+ TraceUtil,
+} from '@emprespresso/pengueno';
+import { exec } from 'node:child_process';
+
+export type Command = string[] | string;
+export type StdStreams = { stdout: string; stderr: string };
+
+export const CmdMetric = Metric.fromName('Exec').asResult();
+type Environment = Record<string, string>;
+type Options = { streamTraceable?: Array<'stdout' | 'stderr'>; env?: Environment; clearEnv?: boolean };
+export const getStdout = (
+ cmd: ITraceable<Command, LogMetricTraceSupplier>,
+ options: Options = { streamTraceable: [] },
+): Promise<IEither<Error, string>> =>
+ cmd
+ .flatMap(TraceUtil.withFunctionTrace(getStdout))
+ .flatMap((tCmd) => tCmd.traceScope(() => `Command = ${tCmd.get()}`))
+ .map((tCmd) => {
+ const cmd = tCmd.get();
+ const _exec = typeof cmd === 'string' ? cmd : cmd.join(' ');
+ const env = options.clearEnv ? options.env : { ...process.env, ...options.env };
+ return Either.fromFailableAsync<Error, StdStreams>(
+ new Promise<StdStreams>((res, rej) => {
+ const proc = exec(_exec, { env });
+ let stdout = '';
+ proc.stdout?.on('data', (d: Buffer) => {
+ const s = d.toString();
+ stdout += s;
+ if (options.streamTraceable?.includes('stdout')) {
+ tCmd.trace.trace(s);
+ }
+ });
+ const stderr = '';
+ proc.stderr?.on('data', (d: Buffer) => {
+ const s = d.toString();
+ stdout += s;
+ if (options.streamTraceable?.includes('stderr')) {
+ tCmd.trace.trace(s);
+ }
+ });
+
+ proc.on('exit', (code) => {
+ const streams = { stdout, stderr };
+ if (code === 0) {
+ res(streams);
+ } else {
+ rej(new Error(`exited with non-zero code: ${code}. ${stderr}`));
+ }
+ });
+ }),
+ );
+ })
+ .map(
+ TraceUtil.promiseify((tEitherStdStreams) =>
+ tEitherStdStreams.get().mapRight(({ stderr, stdout }) => {
+ if (stderr) tEitherStdStreams.trace.traceScope(LogLevel.DEBUG).trace(`StdErr = ${stderr}`);
+ return stdout;
+ }),
+ ),
+ )
+ .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(CmdMetric)))
+ .get();
+
+export const getStdoutMany = (
+ cmds: ITraceable<Array<Command>, LogMetricTraceSupplier>,
+ options: Options = { streamTraceable: [] },
+): Promise<IEither<Error, Array<string>>> =>
+ cmds
+ .coExtend((t) => t.get())
+ .reduce(
+ async (_result, tCmd) => {
+ const result = await _result;
+ return result.joinRightAsync(
+ () => tCmd.map((cmd) => getStdout(cmd, options)).get(),
+ (stdout, pre) => pre.concat(stdout),
+ );
+ },
+ Promise.resolve(Either.right<Error, Array<string>>([])),
+ );
diff --git a/lib/process/index.ts b/lib/process/index.ts
new file mode 100644
index 0000000..8515324
--- /dev/null
+++ b/lib/process/index.ts
@@ -0,0 +1,5 @@
+export * from './exec';
+export * from './env';
+export * from './validate_identifier';
+export * from './argv';
+export * from './signals';
diff --git a/lib/process/signals.ts b/lib/process/signals.ts
new file mode 100644
index 0000000..c4feb7a
--- /dev/null
+++ b/lib/process/signals.ts
@@ -0,0 +1,49 @@
+import {
+ Either,
+ IEither,
+ IMetric,
+ ITraceable,
+ LogMetricTrace,
+ LogMetricTraceSupplier,
+ Mapper,
+ Metric,
+ Optional,
+ ResultMetric,
+ SideEffect,
+ TraceUtil,
+} from '@emprespresso/pengueno';
+
+export const SigIntMetric = Metric.fromName('SigInt').asResult();
+export const SigTermMetric = Metric.fromName('SigTerm').asResult();
+
+export interface Closeable<TFailure> {
+ readonly close: SideEffect<SideEffect<TFailure | undefined>>;
+}
+
+export class Signals {
+ public static async awaitClose<E extends Error>(
+ t: ITraceable<Closeable<E>, LogMetricTraceSupplier>,
+ ): Promise<IEither<Error, void>> {
+ const success: IEither<Error, void> = Either.right(<void>undefined);
+ return new Promise<IEither<Error, void>>((res) => {
+ const metricizedInterruptHandler = (metric: ResultMetric) => (err: Error | undefined) =>
+ t
+ .flatMap(TraceUtil.withMetricTrace(metric))
+ .peek((_t) => _t.trace.trace('closing'))
+ .move(
+ Optional.from(err)
+ .map((e) => Either.left<Error, void>(e))
+ .orSome(() => success)
+ .get(),
+ )
+ .flatMap(TraceUtil.traceResultingEither(metric))
+ .map((e) => res(e.get()))
+ .peek((_t) => _t.trace.trace('finished'))
+ .get();
+ const sigintCloser = metricizedInterruptHandler(SigIntMetric);
+ const sigtermCloser = metricizedInterruptHandler(SigTermMetric);
+ process.on('SIGINT', () => t.flatMap(TraceUtil.withTrace('SIGINT')).get().close(sigintCloser));
+ process.on('SIGTERM', () => t.flatMap(TraceUtil.withTrace('SIGTERM')).get().close(sigtermCloser));
+ });
+ }
+}
diff --git a/lib/process/validate_identifier.ts b/lib/process/validate_identifier.ts
new file mode 100644
index 0000000..1ff3791
--- /dev/null
+++ b/lib/process/validate_identifier.ts
@@ -0,0 +1,18 @@
+import { Either, type IEither } from '@emprespresso/pengueno';
+
+export const validateIdentifier = (token: string) => {
+ return /^[a-zA-Z0-9_\-:. \/]+$/.test(token) && !token.includes('..');
+};
+
+// ensure {@param obj} is a Record<string, string> with stuff that won't
+// have the potential for shell injection, just to be super safe.
+type InvalidEntry<K, T> = [K, T];
+export const validateExecutionEntries = <T, K extends symbol | number | string = symbol | number | string>(
+ obj: Record<K, T>,
+): IEither<Array<InvalidEntry<K, T>>, Record<string, string>> => {
+ const invalidEntries = <Array<InvalidEntry<K, T>>>(
+ Object.entries(obj).filter((e) => !e.every((x) => typeof x === 'string' && validateIdentifier(x)))
+ );
+ if (invalidEntries.length > 0) return Either.left(invalidEntries);
+ return Either.right(<Record<string, string>>obj);
+};