From 490cff822dd206ee3d7c488ef96853a1115ae03e Mon Sep 17 00:00:00 2001 From: Hugo Josefson Date: Fri, 15 Jul 2022 15:29:19 +0200 Subject: [PATCH] feat: implement --- README.md | 6 ++- deps.ts | 7 ++++ example.ts | 2 +- mod.ts | 3 +- src/fn.ts | 25 +++++++++++ src/os.ts | 8 ++++ src/run.ts | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/fn.ts create mode 100644 src/os.ts diff --git a/README.md b/README.md index 94f51c9..f09ef94 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Simple run function to execute shell commands in Deno. +Returns a Promise of what the command printed. + +The promise rejects with status if the command fails. + ## Usage ```typescript @@ -22,13 +26,13 @@ console.log(idLine); const remoteHost = "10.20.30.40"; const remoteCommand = "ps -ef --forest"; const remoteProcessTree: string = await run(["ssh", remoteHost, remoteCommand]); +console.log(remoteProcessTree); // Supply STDIN to the command const contents = `# Remote file This will be the contents of the remote file. `; -// In this case, ignore response string await run( ["ssh", remoteHost, "bash", "-c", "cat > remote_file.md"], { stdin: contents }, diff --git a/deps.ts b/deps.ts index e69de29..5d268c6 100644 --- a/deps.ts +++ b/deps.ts @@ -0,0 +1,7 @@ +export { mapKeys } from "https://deno.land/std@0.141.0/collections/map_keys.ts"; +export { mapValues } from "https://deno.land/std@0.141.0/collections/map_values.ts"; + +export { camelCase } from "https://deno.land/x/case@2.1.1/mod.ts"; +export { Select } from "https://deno.land/x/cliffy@v0.24.2/prompt/select.ts"; +export { fetch as fetchFile } from "https://deno.land/x/file_fetch@0.2.0/mod.ts"; +export { default as _parseColumns } from "https://cdn.skypack.dev/pin/parse-columns@v3.0.0-7lgB0zjFrTuoTzGqeI3T/mode=imports/optimized/parse-columns.js"; diff --git a/example.ts b/example.ts index 867c112..4e42903 100644 --- a/example.ts +++ b/example.ts @@ -13,13 +13,13 @@ console.log(idLine); const remoteHost = "10.20.30.40"; const remoteCommand = "ps -ef --forest"; const remoteProcessTree: string = await run(["ssh", remoteHost, remoteCommand]); +console.log(remoteProcessTree); // Supply STDIN to the command const contents = `# Remote file This will be the contents of the remote file. `; -// In this case, ignore response string await run( ["ssh", remoteHost, "bash", "-c", "cat > remote_file.md"], { stdin: contents }, diff --git a/mod.ts b/mod.ts index cf68176..c4adaea 100644 --- a/mod.ts +++ b/mod.ts @@ -1 +1,2 @@ -export { run } from "./src/run.ts"; +export type { CommandFailure, RunOptions, SimpleValue } from "./src/run.ts"; +export { jsonRun, run } from "./src/run.ts"; diff --git a/src/fn.ts b/src/fn.ts new file mode 100644 index 0000000..4b16adb --- /dev/null +++ b/src/fn.ts @@ -0,0 +1,25 @@ +export function isString(s?: string | unknown): s is string { + return typeof s === "string"; +} + +const decoder = new TextDecoder(); +export function asString(buf: Uint8Array | null | undefined): string { + if (!buf) { + return ""; + } + return decoder.decode(buf).trim(); +} +export function j(obj: unknown, indentation = 2): string { + return JSON.stringify(obj, null, indentation); +} + +export function parseJsonSafe(input: string | unknown): string | unknown { + if (isString(input)) { + try { + return JSON.parse(input); + } catch (_ignore) { + /* intentional fall-through, and return the input as-is */ + } + } + return input; +} diff --git a/src/os.ts b/src/os.ts new file mode 100644 index 0000000..cfe32b5 --- /dev/null +++ b/src/os.ts @@ -0,0 +1,8 @@ +export async function weakEnvGet( + variable: string, +): Promise { + const query = { name: "env" as const, variable }; + const permissionResponse = await Deno.permissions.query(query); + const isGranted = permissionResponse.state === "granted"; + return isGranted && Deno.env.get(variable); +} diff --git a/src/run.ts b/src/run.ts index 61620c7..4f47dfa 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,3 +1,118 @@ -export async function run() { - throw new Error("Work In Progress; not implemented yet."); +import { asString, isString, j, parseJsonSafe } from "./fn.ts"; +import { weakEnvGet } from "./os.ts"; + +/** Item in the command array. */ +export type SimpleValue = string | number | boolean; + +/** + * What the promise is rejected with, if the command exits with a non-zero exit code. + */ +export interface CommandFailure { + /** {@link Deno.ProcessStatus} from the underlying {@link Deno.Process}. */ + status: Deno.ProcessStatus; + /** What the command printed to STDERR. */ + stderr: string; + /** What the command printed to STDOUT (as far as it got). */ + stdout: string; +} + +export interface RunOptions { + /** If specified, will be supplied as STDIN to the command. */ + stdin?: string; + /** Print extra details to STDERR; default to whether env variable `"VERBOSE"` has a truthy value, and `--allow-env` is enabled. */ + verbose?: boolean; +} + +const defaultRunOptions: RunOptions = { + verbose: !!await weakEnvGet("VERBOSE"), +}; + +async function tryRun( + cmd: string[], + options: RunOptions = defaultRunOptions, +): Promise { + options = { ...defaultRunOptions, ...options }; + + const pipeStdIn = isString(options.stdin); + + if (options.verbose) { + console.error(` +=============================================================================== +${j({ cmd, stdin: options.stdin })} +-------------------------------------------------------------------------------`); + } + const process = Deno.run({ + cmd, + stdin: pipeStdIn ? "piped" : "null", + stdout: "piped", + stderr: "piped", + }); + + if (pipeStdIn) { + const stdinBuf = new TextEncoder().encode(options.stdin); + try { + await process.stdin?.write(stdinBuf); + } finally { + process.stdin?.close(); + } + } + + const [ + status, + stdout, + stderr, + ] = await Promise.all([ + process.status(), + process.output(), + process.stderrOutput(), + ]); + process.close(); + + if (status.success) { + const stdoutString = asString(stdout); + if (options.verbose) { + console.error(stdoutString); + } + return stdoutString; + } + const reason: CommandFailure = { + status, + stderr: asString(stderr), + stdout: asString(stdout), + }; + return Promise.reject(reason); +} + +/** + * Runs command, returning a Promise for what the command prints to STDOUT. If the command exits with non-zero exit code, the promise rejects with a {@link CommandFailure}. + * @param command The command to run, as an array of strings, or as a single string. You do not need to quote arguments that contain spaces, when supplying the command as an array. If using the single string format, be careful with spaces. + * @param options Options for the execution. + */ +export async function run( + command: string | SimpleValue[], + options: RunOptions = defaultRunOptions, +): Promise { + const cmd: string[] = isString(command) + ? command.split(" ") + : command.map((segment) => `${segment}`); + try { + return await tryRun(cmd, options); + } catch (error) { + if (options.verbose) { + console.error(j({ cmd, error })); + } + throw error; + } +} + +/** + * Runs a command, just like {@link run}, but parses the response as JSON if possible. Otherwise, returns it as-is. + * @param command The command to run, as an array of strings, or as a single string. You do not need to quote arguments that contain spaces, when supplying the command as an array. If using the single string format, be careful with spaces. + * @param options {@link RunOptions} for the execution. + */ +export async function jsonRun( + command: string | SimpleValue[], + options: RunOptions = defaultRunOptions, +): Promise { + return parseJsonSafe(await run(command, options)) as T; }