From 9970036d203ba2d0a46b35ba6fad21d49441cdd4 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 27 Jul 2025 17:03:10 -0700 Subject: hai --- lib/process/argv.ts | 79 ++++++++++++++++++++++++++++++++++ lib/process/env.ts | 25 +++++++++++ lib/process/exec.ts | 86 ++++++++++++++++++++++++++++++++++++++ lib/process/index.ts | 5 +++ lib/process/signals.ts | 49 ++++++++++++++++++++++ lib/process/validate_identifier.ts | 18 ++++++++ 6 files changed, 262 insertions(+) create mode 100644 lib/process/argv.ts create mode 100644 lib/process/env.ts create mode 100644 lib/process/exec.ts create mode 100644 lib/process/index.ts create mode 100644 lib/process/signals.ts create mode 100644 lib/process/validate_identifier.ts (limited to 'lib/process') 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: string): k is K => k.startsWith('--'); + +interface ArgHandler { + absent?: V; + unspecified?: V; + present: Mapper; +} + +export const getArg = ( + arg: K, + argv: Array, + whenValue: ArgHandler, +): IEither => { + 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(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(v)) + .get(); +}; + +type MappedArgs< + Args extends ReadonlyArray, + Handlers extends Partial>>, +> = { + [K in Args[number]]: K extends keyof Handlers ? (Handlers[K] extends ArgHandler ? T : string) : string; +}; + +export const argv = < + const Args extends ReadonlyArray, + const Handlers extends Partial>>, +>( + args: Args, + handlers?: Handlers, + argv = process.argv.slice(2), +): IEither> => { + type Result = MappedArgs; + + const defaultHandler: ArgHandler = { present: (value: string) => value }; + + const processArg = (arg: Args[number]): IEither => { + const handler = handlers?.[arg] ?? defaultHandler; + return getArg(arg, argv, handler).mapRight((value) => [arg, value] as const); + }; + + const res = args + .map(processArg) + .reduce( + (acc: IEither>, current: IEither) => + acc.flatMap((accValue) => + current.mapRight(([key, value]) => ({ + ...accValue, + [key]: value, + })), + ), + Either.right(>{}), + ) + .mapRight((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 => Optional.from(process.env[name]); + +export const getRequiredEnv = (name: V): IEither => + Either.fromFailable(() => getEnv(name).get()).mapLeft( + () => new Error(`environment variable "${name}" is required D:`), + ); + +export const getRequiredEnvVars = (vars: Array): IEither> => { + type Environment = ObjectFromList; + const emptyEnvironment = Either.right({}); + const addTo = (env: Environment, key: V, val: string) => + { + ...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; +type Options = { streamTraceable?: Array<'stdout' | 'stderr'>; env?: Environment; clearEnv?: boolean }; +export const getStdout = ( + cmd: ITraceable, + options: Options = { streamTraceable: [] }, +): Promise> => + 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( + new Promise((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, LogMetricTraceSupplier>, + options: Options = { streamTraceable: [] }, +): Promise>> => + 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>([])), + ); 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 { + readonly close: SideEffect>; +} + +export class Signals { + public static async awaitClose( + t: ITraceable, LogMetricTraceSupplier>, + ): Promise> { + const success: IEither = Either.right(undefined); + return new Promise>((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(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 with stuff that won't +// have the potential for shell injection, just to be super safe. +type InvalidEntry = [K, T]; +export const validateExecutionEntries = ( + obj: Record, +): IEither>, Record> => { + const invalidEntries = >>( + Object.entries(obj).filter((e) => !e.every((x) => typeof x === 'string' && validateIdentifier(x))) + ); + if (invalidEntries.length > 0) return Either.left(invalidEntries); + return Either.right(>obj); +}; -- cgit v1.2.3-70-g09d2