diff options
Diffstat (limited to 'lib/process')
-rw-r--r-- | lib/process/argv.ts | 79 | ||||
-rw-r--r-- | lib/process/env.ts | 25 | ||||
-rw-r--r-- | lib/process/exec.ts | 86 | ||||
-rw-r--r-- | lib/process/index.ts | 5 | ||||
-rw-r--r-- | lib/process/signals.ts | 49 | ||||
-rw-r--r-- | lib/process/validate_identifier.ts | 18 |
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); +}; |