Commit ab7e109

Anton Golub <antongolub@antongolub.com>
2025-07-27 08:30:14
feat: introduce `Fail` class (#1285)
* feat: introduce `Fail` class * test: verify `$` throws `Fail` instance
1 parent bcaa9ab
build/cli.cjs
@@ -181,7 +181,7 @@ function printUsage() {
    --env=<path>         path to env file
    --experimental       enables experimental features (deprecated)
 
- ${import_index.chalk.italic("Full documentation:")} ${import_index.chalk.underline("https://google.github.io/zx/")}
+ ${import_index.chalk.italic("Full documentation:")} ${import_index.chalk.underline(import_index.Fail.DOCS_URL)}
 `);
 }
 function main() {
@@ -270,7 +270,7 @@ function readScript() {
       if (script.length === 0) {
         printUsage();
         import_node_process2.default.exitCode = 1;
-        throw new Error("No script provided");
+        throw new import_index.Fail("No script provided");
       }
     } else if (/^https?:/.test(firstArg)) {
       const { name, ext: ext2 = argv.ext } = import_index.path.parse(new URL(firstArg).pathname);
build/cli.js
build/core.cjs
@@ -17,6 +17,7 @@ const {
 var core_exports = {};
 __export(core_exports, {
   $: () => $,
+  Fail: () => Fail,
   ProcessOutput: () => ProcessOutput,
   ProcessPromise: () => ProcessPromise,
   cd: () => cd,
@@ -203,44 +204,53 @@ var ERRNO_CODES = {
   130: "Previous owner died",
   131: "State not recoverable"
 };
-function getErrnoMessage(errno) {
-  return ERRNO_CODES[-errno] || "Unknown error";
-}
-var getExitCodeInfo = (exitCode) => EXIT_CODES[exitCode];
-var formatExitMessage = (code, signal, stderr, from, details = "") => {
-  if (code == 0 && signal == null) return `exit code: ${code}`;
-  const codeInfo = getExitCodeInfo(code);
-  let message = `${stderr}
+var DOCS_URL = "https://google.github.io/zx";
+var _Fail = class _Fail extends Error {
+  static formatExitMessage(code, signal, stderr, from, details = "") {
+    if (code == 0 && signal == null) return `exit code: ${code}`;
+    const codeInfo = _Fail.getExitCodeInfo(code);
+    let message = `${stderr}
     at ${from}
     exit code: ${code}${codeInfo ? " (" + codeInfo + ")" : ""}`;
-  if (signal != null) message += `
+    if (signal != null) message += `
     signal: ${signal}`;
-  if (details) message += `
+    if (details) message += `
     details: 
 ${details}`;
-  return message;
-};
-var formatErrorMessage = (err, from) => {
-  return `${err.message}
-    errno: ${err.errno} (${getErrnoMessage(err.errno)})
+    return message;
+  }
+  static formatErrorMessage(err, from) {
+    return `${err.message}
+    errno: ${err.errno} (${_Fail.getErrnoMessage(err.errno)})
     code: ${err.code}
     at ${from}`;
+  }
+  static formatErrorDetails(lines = [], lim = 20) {
+    if (lines.length < lim) return lines.join("\n");
+    let errors = lines.filter((l) => /(fail|error|not ok|exception)/i.test(l));
+    if (errors.length === 0) errors = lines;
+    return errors.slice(0, lim).join("\n") + (errors.length > lim ? "\n..." : "");
+  }
+  static getExitCodeInfo(exitCode) {
+    return EXIT_CODES[exitCode];
+  }
+  static getCallerLocationFromString(stackString = "unknown") {
+    const lines = stackString.split(/^\s*(at\s)?/m).filter((s) => s == null ? void 0 : s.includes(":"));
+    const i = lines.findIndex((l) => l.includes("Proxy.set"));
+    const offset = i < 0 ? i : i + 2;
+    return (lines.find((l) => l.includes("file://")) || lines[offset] || stackString).trim();
+  }
+  static getCallerLocation(err = new Error("zx error")) {
+    return _Fail.getCallerLocationFromString(err.stack);
+  }
+  static getErrnoMessage(errno) {
+    return ERRNO_CODES[-errno] || "Unknown error";
+  }
 };
-function getCallerLocation(err = new Error("zx error")) {
-  return getCallerLocationFromString(err.stack);
-}
-function getCallerLocationFromString(stackString = "unknown") {
-  const lines = stackString.split(/^\s*(at\s)?/m).filter((s) => s == null ? void 0 : s.includes(":"));
-  const i = lines.findIndex((l) => l.includes("Proxy.set"));
-  const offset = i < 0 ? i : i + 2;
-  return (lines.find((l) => l.includes("file://")) || lines[offset] || stackString).trim();
-}
-var formatErrorDetails = (lines = [], lim = 20) => {
-  if (lines.length < lim) return lines.join("\n");
-  let errors = lines.filter((l) => /(fail|error|not ok|exception)/i.test(l));
-  if (errors.length === 0) errors = lines;
-  return errors.slice(0, lim).join("\n") + (errors.length > lim ? "\n..." : "");
-};
+_Fail.DOCS_URL = DOCS_URL;
+_Fail.EXIT_CODES = EXIT_CODES;
+_Fail.ERRNO_CODES = ERRNO_CODES;
+var Fail = _Fail;
 
 // src/log.ts
 var import_vendor_core = require("./vendor-core.cjs");
@@ -427,8 +437,8 @@ var storage = new import_node_async_hooks.AsyncLocalStorage();
 var snapshots = [];
 var delimiters = [];
 var getStore = () => storage.getStore() || defaults;
-var getSnapshot = (snapshot, from, cmd) => __spreadProps(__spreadValues({}, snapshot), {
-  ac: snapshot.ac || new AbortController(),
+var getSnapshot = (opts, from, cmd) => __spreadProps(__spreadValues({}, opts), {
+  ac: opts.ac || new AbortController(),
   ee: new import_node_events.EventEmitter(),
   from,
   cmd
@@ -438,17 +448,15 @@ function within(callback) {
 }
 var $ = new Proxy(
   function(pieces, ...args) {
-    const snapshot = getStore();
+    const opts = getStore();
     if (!Array.isArray(pieces)) {
       return function(...args2) {
-        return within(
-          () => Object.assign($, snapshot, pieces).apply(this, args2)
-        );
+        return within(() => Object.assign($, opts, pieces).apply(this, args2));
       };
     }
-    const from = getCallerLocation();
+    const from = Fail.getCallerLocation();
     if (pieces.some((p) => p == null))
-      throw new Error(`Malformed command at ${from}`);
+      throw new Fail(`Malformed command at ${from}`);
     checkShell();
     checkQuote();
     const cmd = (0, import_vendor_core2.buildCmd)(
@@ -456,7 +464,7 @@ var $ = new Proxy(
       pieces,
       args
     );
-    snapshots.push(getSnapshot(snapshot, from, cmd));
+    snapshots.push(getSnapshot(opts, from, cmd));
     const pp = new ProcessPromise(import_util.noop);
     if (!pp.isHalted()) pp.run();
     return pp.sync ? pp.output : pp;
@@ -546,7 +554,7 @@ var _ProcessPromise = class _ProcessPromise extends Promise {
       },
       on: {
         start: () => {
-          $2.log({ kind: "cmd", cmd: self.cmd, cwd, verbose: self.isVerbose(), id });
+          $2.log({ kind: "cmd", cmd: $2.cmd, cwd, verbose: self.isVerbose(), id });
           self.timeout($2.timeout, $2.timeoutSignal);
         },
         stdout: (data) => {
@@ -625,19 +633,19 @@ var _ProcessPromise = class _ProcessPromise extends Promise {
     return promisifyStream(dest, this);
   }
   abort(reason) {
-    if (this.isSettled()) throw new Error("Too late to abort the process.");
+    if (this.isSettled()) throw new Fail("Too late to abort the process.");
     if (this.signal !== this.ac.signal)
-      throw new Error("The signal is controlled by another process.");
+      throw new Fail("The signal is controlled by another process.");
     if (!this.child)
-      throw new Error("Trying to abort a process without creating one.");
+      throw new Fail("Trying to abort a process without creating one.");
     this.ac.abort(reason);
   }
   kill(signal = $.killSignal) {
-    if (this.isSettled()) throw new Error("Too late to kill the process.");
+    if (this.isSettled()) throw new Fail("Too late to kill the process.");
     if (!this.child)
-      throw new Error("Trying to kill a process without creating one.");
-    if (!this.child.pid) throw new Error("The process pid is undefined.");
-    return $.kill(this.child.pid, signal);
+      throw new Fail("Trying to kill a process without creating one.");
+    if (!this.pid) throw new Fail("The process pid is undefined.");
+    return $.kill(this.pid, signal);
   }
   /**
    *  @deprecated Use $({halt: true})`cmd` instead.
@@ -833,7 +841,7 @@ var _ProcessPromise = class _ProcessPromise extends Promise {
         return;
       }
       Object.defineProperty(p, k, { configurable: true, get() {
-        throw new Error("Inappropriate usage. Apply $ instead of direct instantiation.");
+        throw new Fail("Inappropriate usage. Apply $ instead of direct instantiation.");
       } });
     });
   }
@@ -896,7 +904,7 @@ var _ProcessOutput = class _ProcessOutput extends Error {
   }
   blob(type = "text/plain") {
     if (!globalThis.Blob)
-      throw new Error(
+      throw new Fail(
         "Blob is not supported in this environment. Provide a polyfill"
       );
     return new Blob([this.buffer()], { type });
@@ -936,10 +944,10 @@ var _ProcessOutput = class _ProcessOutput extends Error {
 }`;
   }
 };
-_ProcessOutput.getExitMessage = formatExitMessage;
-_ProcessOutput.getErrorMessage = formatErrorMessage;
-_ProcessOutput.getErrorDetails = formatErrorDetails;
-_ProcessOutput.getExitCodeInfo = getExitCodeInfo;
+_ProcessOutput.getExitMessage = Fail.formatExitMessage;
+_ProcessOutput.getErrorMessage = Fail.formatErrorMessage;
+_ProcessOutput.getErrorDetails = Fail.formatErrorDetails;
+_ProcessOutput.getExitCodeInfo = Fail.getExitCodeInfo;
 var ProcessOutput = _ProcessOutput;
 function usePowerShell() {
   $.shell = import_vendor_core2.which.sync("powershell.exe");
@@ -968,14 +976,11 @@ try {
 } catch (err) {
 }
 function checkShell() {
-  if (!$.shell)
-    throw new Error(`No shell is available: https://google.github.io/zx/shell`);
+  if (!$.shell) throw new Fail(`No shell is available: ${Fail.DOCS_URL}/shell`);
 }
 function checkQuote() {
   if (!$.quote)
-    throw new Error(
-      "No quote function is defined: https://google.github.io/zx/quotes"
-    );
+    throw new Fail(`No quote function is defined: ${Fail.DOCS_URL}/quotes`);
 }
 var cwdSyncHook;
 function syncProcessCwd(flag = true) {
@@ -1052,6 +1057,7 @@ function resolveDefaults(defs = defaults, prefix = ENV_PREFIX, env = import_node
 // Annotate the CommonJS export names for ESM import in node:
 0 && (module.exports = {
   $,
+  Fail,
   ProcessOutput,
   ProcessPromise,
   cd,
build/core.d.ts
@@ -6,11 +6,13 @@ import cp, { type ChildProcess, type IOType, type StdioOptions } from 'node:chil
 import { type Encoding } from 'node:crypto';
 import { type Readable, type Writable } from 'node:stream';
 import { inspect } from 'node:util';
+import { Fail } from './error.js';
 import { log } from './log.js';
 import { type TSpawnStore } from './vendor-core.js';
 import { type Duration, quote } from './util.js';
 export { default as path } from 'node:path';
 export * as os from 'node:os';
+export { Fail } from './error.js';
 export { log, type LogEntry } from './log.js';
 export { chalk, which, ps } from './vendor-core.js';
 export { type Duration, quote, quotePowerShell } from './util.js';
@@ -170,10 +172,10 @@ export declare class ProcessOutput extends Error {
     [Symbol.toPrimitive](): string;
     [Symbol.iterator](): Iterator<string>;
     [inspect.custom](): string;
-    static getExitMessage: (code: number | null, signal: NodeJS.Signals | null, stderr: string, from: string, details?: string) => string;
-    static getErrorMessage: (err: NodeJS.ErrnoException, from: string) => string;
-    static getErrorDetails: (lines?: string[], lim?: number) => string;
-    static getExitCodeInfo: (exitCode: number | null) => string | undefined;
+    static getExitMessage: typeof Fail.formatExitMessage;
+    static getErrorMessage: typeof Fail.formatErrorMessage;
+    static getErrorDetails: typeof Fail.formatErrorDetails;
+    static getExitCodeInfo: typeof Fail.getExitCodeInfo;
 }
 export declare function usePowerShell(): void;
 export declare function usePwsh(): void;
build/core.js
@@ -3,6 +3,7 @@ import "./deno.js"
 import * as __module__ from "./core.cjs"
 const {
   $,
+  Fail,
   ProcessOutput,
   ProcessPromise,
   cd,
@@ -25,6 +26,7 @@ const {
 } = globalThis.Deno ? globalThis.require("./core.cjs") : __module__
 export {
   $,
+  Fail,
   ProcessOutput,
   ProcessPromise,
   cd,
build/deps.cjs
@@ -24,7 +24,7 @@ function installDeps(dependencies, prefix, registry, installerType = "npm") {
     );
     if (packages.length === 0) return;
     if (!installer) {
-      throw new Error(
+      throw new import_index.Fail(
         `Unsupported installer type: ${installerType}. Supported types: ${Object.keys(installers).join(", ")}`
       );
     }
build/error.d.ts
@@ -0,0 +1,12 @@
+export declare class Fail extends Error {
+    static DOCS_URL: string;
+    static EXIT_CODES: Record<number, string>;
+    static ERRNO_CODES: Record<number, string>;
+    static formatExitMessage(code: number | null, signal: NodeJS.Signals | null, stderr: string, from: string, details?: string): string;
+    static formatErrorMessage(err: NodeJS.ErrnoException, from: string): string;
+    static formatErrorDetails(lines?: string[], lim?: number): string;
+    static getExitCodeInfo(exitCode: number | null): string | undefined;
+    static getCallerLocationFromString(stackString?: string): string;
+    static getCallerLocation(err?: Error): string;
+    static getErrnoMessage(errno?: number): string;
+}
build/index.cjs
@@ -159,9 +159,8 @@ function question(_0) {
 function stdin() {
   return __async(this, arguments, function* (stream = import_node_process.default.stdin) {
     let buf = "";
-    stream.setEncoding("utf8");
     try {
-      for (var iter = __forAwait(stream), more, temp, error; more = !(temp = yield iter.next()).done; more = false) {
+      for (var iter = __forAwait(stream.setEncoding("utf8")), more, temp, error; more = !(temp = yield iter.next()).done; more = false) {
         const chunk = temp.value;
         buf += chunk;
       }
@@ -181,7 +180,7 @@ function stdin() {
 function retry(count, d, cb) {
   return __async(this, null, function* () {
     if (typeof d === "function") return retry(count, 0, d);
-    if (!cb) throw new Error("Callback is required for retry");
+    if (!cb) throw new import_core.Fail("Callback is required for retry");
     const total = count;
     const gen = typeof d === "object" ? d : function* (d2) {
       while (true) yield d2;
build/index.js
@@ -28,6 +28,7 @@ const {
   updateArgv,
   version,
   $,
+  Fail,
   ProcessOutput,
   ProcessPromise,
   cd,
@@ -75,6 +76,7 @@ export {
   updateArgv,
   version,
   $,
+  Fail,
   ProcessOutput,
   ProcessPromise,
   cd,
src/cli.ts
@@ -29,6 +29,7 @@ import {
   path,
   stdin,
   VERSION,
+  Fail,
 } from './index.ts'
 import { installDeps, parseDeps } from './deps.ts'
 import { startRepl } from './repl.ts'
@@ -90,7 +91,7 @@ export function printUsage() {
    --env=<path>         path to env file
    --experimental       enables experimental features (deprecated)
 
- ${chalk.italic('Full documentation:')} ${chalk.underline('https://google.github.io/zx/')}
+ ${chalk.italic('Full documentation:')} ${chalk.underline(Fail.DOCS_URL)}
 `)
 }
 
@@ -192,7 +193,7 @@ async function readScript() {
     if (script.length === 0) {
       printUsage()
       process.exitCode = 1
-      throw new Error('No script provided')
+      throw new Fail('No script provided')
     }
   } else if (/^https?:/.test(firstArg)) {
     const { name, ext = argv.ext } = path.parse(new URL(firstArg).pathname)
src/core.ts
@@ -27,13 +27,7 @@ import process from 'node:process'
 import { type Readable, type Writable } from 'node:stream'
 import { inspect } from 'node:util'
 
-import {
-  formatErrorDetails,
-  formatErrorMessage,
-  formatExitMessage,
-  getCallerLocation,
-  getExitCodeInfo,
-} from './error.ts'
+import { Fail } from './error.ts'
 import { log } from './log.ts'
 import {
   exec,
@@ -66,6 +60,7 @@ import {
 
 export { default as path } from 'node:path'
 export * as os from 'node:os'
+export { Fail } from './error.ts'
 export { log, type LogEntry } from './log.ts'
 export { chalk, which, ps } from './vendor-core.ts'
 export { type Duration, quote, quotePowerShell } from './util.ts'
@@ -172,13 +167,9 @@ const delimiters: Options['delimiter'][] = []
 
 const getStore = () => storage.getStore() || defaults
 
-const getSnapshot = (
-  snapshot: Options,
-  from: string,
-  cmd: string
-): Snapshot => ({
-  ...snapshot,
-  ac: snapshot.ac || new AbortController(),
+const getSnapshot = (opts: Options, from: string, cmd: string): Snapshot => ({
+  ...opts,
+  ac: opts.ac || new AbortController(),
   ee: new EventEmitter(),
   from,
   cmd,
@@ -193,17 +184,15 @@ export type $ = Shell & Options
 
 export const $: $ = new Proxy<$>(
   function (pieces: TemplateStringsArray | Partial<Options>, ...args: any[]) {
-    const snapshot = getStore()
+    const opts = getStore()
     if (!Array.isArray(pieces)) {
       return function (this: any, ...args: any) {
-        return within(() =>
-          Object.assign($, snapshot, pieces).apply(this, args)
-        )
+        return within(() => Object.assign($, opts, pieces).apply(this, args))
       }
     }
-    const from = getCallerLocation()
+    const from = Fail.getCallerLocation()
     if (pieces.some((p) => p == null))
-      throw new Error(`Malformed command at ${from}`)
+      throw new Fail(`Malformed command at ${from}`)
 
     checkShell()
     checkQuote()
@@ -213,7 +202,7 @@ export const $: $ = new Proxy<$>(
       pieces as TemplateStringsArray,
       args
     ) as string
-    snapshots.push(getSnapshot(snapshot, from, cmd))
+    snapshots.push(getSnapshot(opts, from, cmd))
     const pp = new ProcessPromise(noop)
 
     if (!pp.isHalted()) pp.run()
@@ -327,7 +316,7 @@ export class ProcessPromise extends Promise<ProcessOutput> {
       },
       on: {
         start: () => {
-          $.log({ kind: 'cmd', cmd: self.cmd, cwd, verbose: self.isVerbose(), id })
+          $.log({ kind: 'cmd', cmd: $.cmd, cwd, verbose: self.isVerbose(), id })
           self.timeout($.timeout, $.timeoutSignal)
         },
         stdout: (data) => {
@@ -441,22 +430,22 @@ export class ProcessPromise extends Promise<ProcessOutput> {
   }
 
   abort(reason?: string) {
-    if (this.isSettled()) throw new Error('Too late to abort the process.')
+    if (this.isSettled()) throw new Fail('Too late to abort the process.')
     if (this.signal !== this.ac.signal)
-      throw new Error('The signal is controlled by another process.')
+      throw new Fail('The signal is controlled by another process.')
     if (!this.child)
-      throw new Error('Trying to abort a process without creating one.')
+      throw new Fail('Trying to abort a process without creating one.')
 
     this.ac.abort(reason)
   }
 
   kill(signal = $.killSignal): Promise<void> {
-    if (this.isSettled()) throw new Error('Too late to kill the process.')
+    if (this.isSettled()) throw new Fail('Too late to kill the process.')
     if (!this.child)
-      throw new Error('Trying to kill a process without creating one.')
-    if (!this.child.pid) throw new Error('The process pid is undefined.')
+      throw new Fail('Trying to kill a process without creating one.')
+    if (!this.pid) throw new Fail('The process pid is undefined.')
 
-    return $.kill(this.child.pid, signal)
+    return $.kill(this.pid, signal)
   }
 
   /**
@@ -696,7 +685,7 @@ export class ProcessPromise extends Promise<ProcessOutput> {
       if (k in Promise.prototype) return
       if (!toggle) { Reflect.deleteProperty(p, k); return }
       Object.defineProperty(p, k, { configurable: true, get() {
-        throw new Error('Inappropriate usage. Apply $ instead of direct instantiation.')
+        throw new Fail('Inappropriate usage. Apply $ instead of direct instantiation.')
       }})
     })
   }
@@ -798,7 +787,7 @@ export class ProcessOutput extends Error {
 
   blob(type = 'text/plain'): Blob {
     if (!globalThis.Blob)
-      throw new Error(
+      throw new Fail(
         'Blob is not supported in this environment. Provide a polyfill'
       )
     return new Blob([this.buffer()], { type })
@@ -853,13 +842,13 @@ export class ProcessOutput extends Error {
 }`
   }
 
-  static getExitMessage = formatExitMessage
+  static getExitMessage = Fail.formatExitMessage
 
-  static getErrorMessage = formatErrorMessage
+  static getErrorMessage = Fail.formatErrorMessage
 
-  static getErrorDetails = formatErrorDetails
+  static getErrorDetails = Fail.formatErrorDetails
 
-  static getExitCodeInfo = getExitCodeInfo
+  static getExitCodeInfo = Fail.getExitCodeInfo
 }
 
 export function usePowerShell() {
@@ -892,15 +881,12 @@ try {
 } catch (err) {}
 
 function checkShell() {
-  if (!$.shell)
-    throw new Error(`No shell is available: https://google.github.io/zx/shell`)
+  if (!$.shell) throw new Fail(`No shell is available: ${Fail.DOCS_URL}/shell`)
 }
 
 function checkQuote() {
   if (!$.quote)
-    throw new Error(
-      'No quote function is defined: https://google.github.io/zx/quotes'
-    )
+    throw new Fail(`No quote function is defined: ${Fail.DOCS_URL}/quotes`)
 }
 
 let cwdSyncHook: AsyncHook
src/deps.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import { builtinModules } from 'node:module'
-import { $, spinner } from './index.ts'
+import { $, spinner, Fail } from './index.ts'
 import { depseek } from './vendor.ts'
 
 /**
@@ -35,7 +35,7 @@ export async function installDeps(
   )
   if (packages.length === 0) return
   if (!installer) {
-    throw new Error(
+    throw new Fail(
       `Unsupported installer type: ${installerType}. Supported types: ${Object.keys(installers).join(', ')}`
     )
   }
src/error.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-const EXIT_CODES = {
+const EXIT_CODES: Record<number, string> = {
   2: 'Misuse of shell builtins',
   126: 'Invoked command cannot execute',
   127: 'Command not found',
@@ -47,7 +47,7 @@ const EXIT_CODES = {
   159: 'Bad syscall',
 }
 
-const ERRNO_CODES = {
+const ERRNO_CODES: Record<number, string> = {
   0: 'Success',
   1: 'Not super-user',
   2: 'No such file or directory',
@@ -169,69 +169,77 @@ const ERRNO_CODES = {
   131: 'State not recoverable',
 }
 
-export function getErrnoMessage(errno?: number): string {
-  return (
-    ERRNO_CODES[-(errno as number) as keyof typeof ERRNO_CODES] ||
-    'Unknown error'
-  )
-}
+const DOCS_URL = 'https://google.github.io/zx'
 
-export const getExitCodeInfo = (exitCode: number | null): string | undefined =>
-  EXIT_CODES[exitCode as keyof typeof EXIT_CODES]
+export class Fail extends Error {
+  static DOCS_URL = DOCS_URL
+  static EXIT_CODES = EXIT_CODES
+  static ERRNO_CODES = ERRNO_CODES
 
-export const formatExitMessage = (
-  code: number | null,
-  signal: NodeJS.Signals | null,
-  stderr: string,
-  from: string,
-  details: string = ''
-): string => {
-  if (code == 0 && signal == null) return `exit code: ${code}`
+  static formatExitMessage(
+    code: number | null,
+    signal: NodeJS.Signals | null,
+    stderr: string,
+    from: string,
+    details: string = ''
+  ): string {
+    if (code == 0 && signal == null) return `exit code: ${code}`
 
-  const codeInfo = getExitCodeInfo(code)
-  let message = `${stderr}
+    const codeInfo = Fail.getExitCodeInfo(code)
+    let message = `${stderr}
     at ${from}
     exit code: ${code}${codeInfo ? ' (' + codeInfo + ')' : ''}`
 
-  if (signal != null) message += `\n    signal: ${signal}`
+    if (signal != null) message += `\n    signal: ${signal}`
 
-  if (details) message += `\n    details: \n${details}`
+    if (details) message += `\n    details: \n${details}`
 
-  return message
-}
+    return message
+  }
 
-export const formatErrorMessage = (
-  err: NodeJS.ErrnoException,
-  from: string
-): string => {
-  return `${err.message}
-    errno: ${err.errno} (${getErrnoMessage(err.errno)})
+  static formatErrorMessage(err: NodeJS.ErrnoException, from: string): string {
+    return `${err.message}
+    errno: ${err.errno} (${Fail.getErrnoMessage(err.errno)})
     code: ${err.code}
     at ${from}`
-}
+  }
 
-export function getCallerLocation(err = new Error('zx error')): string {
-  return getCallerLocationFromString(err.stack)
-}
+  static formatErrorDetails(lines: string[] = [], lim = 20): string {
+    if (lines.length < lim) return lines.join('\n')
 
-export function getCallerLocationFromString(stackString = 'unknown'): string {
-  const lines = stackString
-    .split(/^\s*(at\s)?/m)
-    .filter((s) => s?.includes(':'))
-  const i = lines.findIndex((l) => l.includes('Proxy.set'))
-  const offset = i < 0 ? i : i + 2
-
-  return (
-    lines.find((l) => l.includes('file://')) ||
-    lines[offset] ||
-    stackString
-  ).trim()
-}
+    let errors = lines.filter((l) => /(fail|error|not ok|exception)/i.test(l))
+    if (errors.length === 0) errors = lines
+    return (
+      errors.slice(0, lim).join('\n') + (errors.length > lim ? '\n...' : '')
+    )
+  }
+
+  static getExitCodeInfo(exitCode: number | null): string | undefined {
+    return EXIT_CODES[exitCode as keyof typeof EXIT_CODES]
+  }
+
+  static getCallerLocationFromString(stackString = 'unknown'): string {
+    const lines = stackString
+      .split(/^\s*(at\s)?/m)
+      .filter((s) => s?.includes(':'))
+    const i = lines.findIndex((l) => l.includes('Proxy.set'))
+    const offset = i < 0 ? i : i + 2
+
+    return (
+      lines.find((l) => l.includes('file://')) ||
+      lines[offset] ||
+      stackString
+    ).trim()
+  }
 
-export const formatErrorDetails = (lines: string[] = [], lim = 20): string => {
-  if (lines.length < lim) return lines.join('\n')
+  static getCallerLocation(err: Error = new Error('zx error')): string {
+    return Fail.getCallerLocationFromString(err.stack)
+  }
 
-  let errors = lines.filter((l) => /(fail|error|not ok|exception)/i.test(l))
-  if (errors.length === 0) errors = lines
-  return errors.slice(0, lim).join('\n') + (errors.length > lim ? '\n...' : '')
+  static getErrnoMessage(errno?: number): string {
+    return (
+      ERRNO_CODES[-(errno as number) as keyof typeof ERRNO_CODES] ||
+      'Unknown error'
+    )
+  }
 }
src/goods.ts
@@ -24,6 +24,7 @@ import {
   type ProcessPromise,
   path,
   os,
+  Fail,
 } from './core.ts'
 import {
   type Duration,
@@ -153,7 +154,7 @@ export function echo(pieces: TemplateStringsArray, ...args: any[]) {
   console.log(msg)
 }
 
-function stringify(arg: ProcessOutput | any) {
+function stringify(arg: any) {
   return arg instanceof ProcessOutput ? arg.toString().trimEnd() : `${arg}`
 }
 
@@ -193,8 +194,7 @@ export async function question(
 
 export async function stdin(stream: Readable = process.stdin): Promise<string> {
   let buf = ''
-  stream.setEncoding('utf8')
-  for await (const chunk of stream) {
+  for await (const chunk of stream.setEncoding('utf8')) {
     buf += chunk
   }
   return buf
@@ -212,7 +212,7 @@ export async function retry<T>(
   cb?: () => T
 ): Promise<T> {
   if (typeof d === 'function') return retry(count, 0, d)
-  if (!cb) throw new Error('Callback is required for retry')
+  if (!cb) throw new Fail('Callback is required for retry')
 
   const total = count
   const gen =
test/core.test.js
@@ -32,6 +32,7 @@ import {
   usePowerShell,
   usePwsh,
   useBash,
+  Fail,
 } from '../build/core.js'
 import {
   tempfile,
@@ -215,6 +216,14 @@ describe('core', () => {
 
     test('malformed cmd error', async () => {
       assert.throws(() => $`\033`, /malformed/i)
+
+      try {
+        $([null])
+        throw new Err('unreachable')
+      } catch (e) {
+        assert.ok(e instanceof Fail)
+        assert.match(e.message, /malformed/i)
+      }
     })
 
     test('snapshots works', async () => {
test/error.test.ts
@@ -14,7 +14,9 @@
 
 import assert from 'node:assert'
 import { test, describe } from 'node:test'
-import {
+import { Fail } from '../src/error.ts'
+
+const {
   getErrnoMessage,
   getExitCodeInfo,
   getCallerLocation,
@@ -22,7 +24,7 @@ import {
   formatExitMessage,
   formatErrorMessage,
   formatErrorDetails,
-} from '../src/error.ts'
+} = Fail
 
 describe('error', () => {
   test('getExitCodeInfo()', () => {
test/export.test.js
@@ -23,6 +23,10 @@ import * as vendor from '../build/vendor.js'
 describe('core', () => {
   test('exports', () => {
     assert.equal(typeof core.$, 'function', 'core.$')
+    assert.equal(typeof core.Fail, 'function', 'core.Fail')
+    assert.equal(typeof core.Fail.DOCS_URL, 'string', 'core.Fail.DOCS_URL')
+    assert.equal(typeof core.Fail.ERRNO_CODES, 'object', 'core.Fail.ERRNO_CODES')
+    assert.equal(typeof core.Fail.EXIT_CODES, 'object', 'core.Fail.EXIT_CODES')
     assert.equal(typeof core.ProcessOutput, 'function', 'core.ProcessOutput')
     assert.equal(typeof core.ProcessOutput.getErrorDetails, 'function', 'core.ProcessOutput.getErrorDetails')
     assert.equal(typeof core.ProcessOutput.getErrorMessage, 'function', 'core.ProcessOutput.getErrorMessage')
@@ -145,6 +149,10 @@ describe('cli', () => {
 describe('index', () => {
   test('exports', () => {
     assert.equal(typeof index.$, 'function', 'index.$')
+    assert.equal(typeof index.Fail, 'function', 'index.Fail')
+    assert.equal(typeof index.Fail.DOCS_URL, 'string', 'index.Fail.DOCS_URL')
+    assert.equal(typeof index.Fail.ERRNO_CODES, 'object', 'index.Fail.ERRNO_CODES')
+    assert.equal(typeof index.Fail.EXIT_CODES, 'object', 'index.Fail.EXIT_CODES')
     assert.equal(typeof index.ProcessOutput, 'function', 'index.ProcessOutput')
     assert.equal(typeof index.ProcessOutput.getErrorDetails, 'function', 'index.ProcessOutput.getErrorDetails')
     assert.equal(typeof index.ProcessOutput.getErrorMessage, 'function', 'index.ProcessOutput.getErrorMessage')
test/package.test.js
@@ -51,6 +51,7 @@ describe('package', () => {
         'build/deno.js',
         'build/deps.cjs',
         'build/deps.d.ts',
+        'build/error.d.ts',
         'build/esblib.cjs',
         'build/globals.cjs',
         'build/globals.d.ts',
.size-limit.json
@@ -15,7 +15,7 @@
       "README.md",
       "LICENSE"
     ],
-    "limit": "121.15 kB",
+    "limit": "121.301 kB",
     "brotli": false,
     "gzip": false
   },
@@ -29,14 +29,14 @@
       "build/globals.js",
       "build/deno.js"
     ],
-    "limit": "812.10 kB",
+    "limit": "812.40 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "libdefs",
     "path": "build/*.d.ts",
-    "limit": "39.00 kB",
+    "limit": "39.65 kB",
     "brotli": false,
     "gzip": false
   },
@@ -62,7 +62,7 @@
       "README.md",
       "LICENSE"
     ],
-    "limit": "867.95 kB",
+    "limit": "868.90 kB",
     "brotli": false,
     "gzip": false
   }
package.json
@@ -74,7 +74,7 @@
     "build:js": "node scripts/build-js.mjs --format=cjs --hybrid --entry=src/*.ts:!src/error.ts:!src/repl.ts:!src/md.ts:!src/log.ts:!src/globals-jsr.ts:!src/goods.ts && npm run build:vendor",
     "build:vendor": "node scripts/build-js.mjs --format=cjs --entry=src/vendor-*.ts --bundle=all --external='./internals.ts'",
     "build:tests": "node scripts/build-tests.mjs",
-    "build:dts": "tsc --project tsconfig.json && rm build/error.d.ts build/repl.d.ts build/globals-jsr.d.ts && node scripts/build-dts.mjs",
+    "build:dts": "tsc --project tsconfig.json && rm build/repl.d.ts build/globals-jsr.d.ts && node scripts/build-dts.mjs",
     "build:dcr": "docker build -f ./dcr/Dockerfile . -t zx",
     "build:jsr": "node scripts/build-jsr.mjs",
     "docs:dev": "vitepress dev docs",