import { colors, path, walkSync } from "./deps.ts"; import { env } from "./env.ts"; const DRAKE_VERS = "1.5.2"; /** Returns the Drake version number string. */ export function vers(): string { return DRAKE_VERS; } export class DrakeError extends Error { constructor(message?: string) { super(message); this.name = "DrakeError"; } } /** * Return `FileInfo` (or `null` if file does not exist). */ export function fileStat(path: string): Deno.FileInfo | null { try { return Deno.statSync(path); } catch (err) { if (err.code === "ENOENT") { return null; } else { throw err; } } } /** * Return `true` if `path` exists, otherwise return `false`. */ export function pathExists(path: string): boolean { return fileStat(path) != null; } /** * Return `true` if the `path` is that of a regular file. * Return `false` if it is not a regular file or does not exist. */ export function isFile(path: string): boolean { return !!fileStat(path)?.isFile; } /** * Return `true` if `path` is that of a directory. * Return `false` if it is not a directory or does not exist. */ export function isDirectory(path: string): boolean { return !!fileStat(path)?.isDirectory; } /** * Write an error message to to `stderr` and terminate execution. * * - If the `"--abort-exits"` environment option is `false` throw a `DrakeError`. * - If the `"--debug"` environment option is `true` include the stack trace in * the error message. */ export function abort(message: string): never { if (env("--abort-exits")) { message = `${colors.red(colors.bold("error"))}: ${message}`; if (env("--debug")) { const e = new Error(); if (e.stack) { message += `\n${e.stack}`; } } console.error(message); Deno.exit(1); } else { throw new DrakeError(message); } } /** * Log a message to stdout. Do not log the message if the `--quiet` * command-line option is set. */ export function log(message: string): void { if (!env("--quiet")) { console.log(message); } } export function logExecution(title: string, info: string, duration?: number) { if (env("--quiet")) { return; } if ((title == "sh" || title == "shCapture") && !env("--verbose")) { return; } info = `${colors.green(colors.bold(`${title}: `))}${info}`; if (duration !== undefined) { info += ` ${colors.brightWhite(colors.bold(`(${duration}ms)`))}`; } log(info); } /** * Write the `title` and `message` to stderr if it is a TTY and the * `--debug` command-line option was specified or the `DRAKE_DEBUG` shell * environment variable is set. */ // deno-lint-ignore no-explicit-any export function debug(title: string, message: any = ""): void { if (typeof message === "object") { message = JSON.stringify(message, null, 1); } if (env("--debug") && Deno.isatty(Deno.stderr.rid)) { if (title !== "") { message = `${colors.yellow(colors.bold(title + ":"))} ${message}`; } console.error(message); } } /** * Quote string array values with double-quotes then join them with a separator. * Double-quote characters are escaped with a backspace. * The separator defaults to a space character. */ export function quote(values: string[], sep = " "): string { values = values.map((value) => `"${value.replace(/"/g, '\\"')}"`); return values.join(sep); } /** Wait `ms` milliseconds. Must be called with `await`. */ // deno-lint-ignore require-await export async function sleep(ms: number): Promise<unknown> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Read the entire contents of a file synchronously to a UTF-8 string. */ export function readFile(filename: string): string { try { const result = Deno.readTextFileSync(filename); debug( "readFile", `${filename}: ${result.length} characters read`, ); return result; } catch (e) { abort(`readFile: ${filename}: ${e.message}`); } } /** * Write text to a file synchronously. * If the file exists it will be overwritten. * Returns `true` if a new file was created. * Returns `false` if the file already exists. */ export function writeFile(filename: string, text: string): boolean { const exists = pathExists(filename); try { debug( "writeFile", `${filename}: ${text.length} characters written`, ); Deno.writeTextFileSync(filename, text); } catch (e) { abort(`writeFile: ${filename}: ${e.message}`); } return !exists; } /** * Find and replace in text file synchronously. * If the file contents is unchanged return `false`. * If the contents has changed write it to the file and return `true`. */ export function updateFile( filename: string, find: RegExp, replace: string, ): boolean { debug( "updateFile", `${filename}: find: ${find}, replace: "${replace}"`, ); let changed = false; const text = readFile(filename); const updatedText = text.replace(find, replace); if (text !== updatedText) { writeFile(filename, updatedText); changed = true; } return changed; } /** * Create directory. * * - Missing parent directory paths are created. * - Returns `true` if a new directory was created. * - Returns `false` if the directory already exists. */ export function makeDir(dir: string): boolean { debug("makeDir", dir); const exists = pathExists(dir); if (exists) { if (!isDirectory(dir)) { abort(`file is not directory: ${dir}`); } } else { Deno.mkdirSync(dir, { recursive: true }); } return !exists; } /** * Return a sorted array of normalized file names matching the wildcard glob patterns. * Valid glob patterns are those supported by Deno's `path` library. * Example: `glob("tmp/*.ts", "lib/*.ts", "mod.ts");` */ export function glob(...patterns: string[]): string[] { function glob1(pattern: string): string[] { const globOptions = { extended: true, globstar: true } as const; pattern = path.normalizeGlob(pattern, globOptions); let root = path.dirname(pattern); while (root !== "." && path.isGlob(root)) { root = path.dirname(root); } const regexp = path.globToRegExp(pattern, globOptions); const iter = walkSync(root, { match: [regexp], includeDirs: false }); return Array.from(iter, (info) => info.path); } debug("glob", `${quote(patterns, ", ")}`); let result: string[] = []; for (const pattern of patterns) { try { result = [...result, ...glob1(pattern)]; } catch (e) { abort(`${pattern}: ${e.message}`); } } // Drop duplicates, normalize and sort paths. result = [...new Set(result)].map((p) => path.normalize(p)).sort(); debug("", `${result.slice(0, 100).join("\n")}`); if (result.length > 100) { debug("", `... (${result.length} files)`); } return result; } /** Synthesize platform dependent shell command arguments. */ function shArgs(command: string): string[] { if (Deno.build.os === "windows") { return ["PowerShell.exe", "-Command", command]; } else { let shellExe = Deno.env.get("SHELL")!; if (!shellExe) { shellExe = "/bin/bash"; if (!isFile(shellExe)) { abort( `cannot locate shell: no SHELL environment variable or ${shellExe} executable`, ); } } return [shellExe, "-c", command]; } } /** `sh` API options. */ export interface ShOpts { /** Working directory. */ cwd?: string; /** Map containing additional shell environment variables. */ env?: { [key: string]: string }; stdout?: "inherit" | "piped" | "null" | number; stderr?: "inherit" | "piped" | "null" | number; } /** * Execute commands asynchronously in the command shell. * * - If `commands` is a string execute it. * - If `commands` is an array of commands execute them asynchronously. * - If any command fails throw an error. * - If `opts.stdout` or `opts.stderr` is set to `"null"` then the respective outputs are ignored. * - `opts.cwd` sets the shell current working directory (defaults to the parent process working directory). * - The `opts.env` mapping passes additional environment variables to the shell. * * On MS Windows run `PowerShell.exe -Command <cmd>`. On other platforms run `$SHELL -c <cmd>` (if `SHELL` * is not defined use `/bin/bash`). * * Examples: * * await sh("echo Hello World"); * await sh(["echo Hello 1", "echo Hello 2", "echo Hello 3"]); * await sh("echo Hello World", { stdout: "null" }); */ export async function sh(commands: string | string[], opts: ShOpts = {}) { if (typeof commands === "string") { commands = [commands]; } debug("sh", `${commands.join("\n")}\nopts: ${JSON.stringify(opts)}`); const startTime = new Date().getTime(); const processes: Deno.Process[] = []; const results: Deno.ProcessStatus[] = []; try { for (const cmd of commands) { const p = Deno.run({ cmd: shArgs(cmd), cwd: opts.cwd, env: opts.env, stdout: opts.stdout ?? "inherit", stderr: opts.stderr ?? "inherit", }); processes.push(p); } results.push(...await Promise.all(processes.map((p) => p.status()))); } finally { for (const p of processes) { p.close(); } } for (const i in results) { const cmd = commands[i]; const code = results[i].code; if (code === undefined) { abort(`sh: ${cmd}: undefined exit code`); } if (code !== 0) { abort(`sh: ${cmd}: error code: ${code}`); } } logExecution( "sh", `${commands.join("\n")}`, new Date().getTime() - startTime, ); } export type ShOutput = { code: number | undefined; output: string; error: string; }; /** `shCapture` API options. */ export interface ShCaptureOpts extends ShOpts { /** Piped to shell stdin. */ input?: string; } /** * Execute `command` in the command shell and return a promise for * `{code, output, error}` (the exit code, the stdout output and the * stderr output). * * - If the `opts.input` string has been assigned then it is piped to the * shell `stdin`. * - `opts.cwd` sets the shell current working directory (defaults to the * parent process working directory). * - The `opts.env` mapping passes additional environment variables to * the shell. * - `opts.stdout` and `opts.stderr` have `Deno.RunOptions` semantics. * `opts.stdout` defaults to `"piped"`. `opts.stderr` defaults to * `"inherit"` (to capture stderr set `opts.stderr` to `"piped"`). * * Examples: * * const { code, stdout } = await shCapture("echo Hello"); * const { code, output, error } = await shCapture( "mkdir tmpdir", { stderr: "piped" }); */ export async function shCapture( command: string, opts: ShCaptureOpts = {}, ): Promise<ShOutput> { const startTime = new Date().getTime(); const p = Deno.run({ cmd: shArgs(command), cwd: opts.cwd, env: opts.env, stdin: opts.input !== undefined ? "piped" : undefined, stdout: opts.stdout ?? "piped", stderr: opts.stderr ?? "inherit", }); let status: Deno.ProcessStatus; let outputBytes, errorBytes: Uint8Array; try { if (p.stdin) { await p.stdin.write( new TextEncoder().encode(opts.input), ); p.stdin.close(); } [status, outputBytes, errorBytes] = await Promise.all( [ p.status(), p.stdout ? p.output() : Promise.resolve(new Uint8Array()), p.stderr ? p.stderrOutput() : Promise.resolve(new Uint8Array()), ], ); } finally { p.close(); } const result = { code: status.code, output: new TextDecoder().decode(outputBytes), error: new TextDecoder().decode(errorBytes), } as const; debug( "shCapture", `${command}\nopts: ${JSON.stringify(opts)}\noutputs: ${ JSON.stringify(result) }`, ); logExecution("shCapture", command, new Date().getTime() - startTime); return result; }