Commit 92227da

Anton Golub <antongolub@antongolub.com>
2025-04-13 22:24:18
refactor: goods refactoring (#1195)
* feat: make configurable `question()` I/O refactor: use `$.log.output` as default spinner output build: remove goods bundle * fix: fix `expBackoff()` * chore: update bundles * test: update size limits * docs: update usage examples * test: update pkg assets check * chore: enhance stacktrace parser * test: fix zx-lite size check condition * test: increase test cov * chore: test imprs
1 parent 7d7c237
build/core.cjs
@@ -235,8 +235,9 @@ function getCallerLocation(err = new Error("zx error")) {
 }
 function getCallerLocationFromString(stackString = "unknown") {
   const lines = stackString.split(/^\s*(at\s)?/m).filter((s) => s == null ? void 0 : s.includes(":"));
-  return (lines.find((l) => l.includes("file://")) || lines[3] || // skip getCallerLocation and Proxy.set
-  stackString).trim();
+  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();
 }
 function findErrors(lines = []) {
   if (lines.length < 20) return lines.join("\n");
build/deps.cjs
@@ -13,8 +13,7 @@ __export(deps_exports, {
   parseDeps: () => parseDeps
 });
 module.exports = __toCommonJS(deps_exports);
-var import_core = require("./core.cjs");
-var import_goods = require("./goods.cjs");
+var import_index = require("./index.cjs");
 var import_vendor = require("./vendor.cjs");
 function installDeps(dependencies, prefix, registry) {
   return __async(this, null, function* () {
@@ -26,9 +25,9 @@ function installDeps(dependencies, prefix, registry) {
     if (packages.length === 0) {
       return;
     }
-    yield (0, import_goods.spinner)(
+    yield (0, import_index.spinner)(
       `npm i ${packages.join(" ")}`,
-      () => import_core.$`npm install --no-save --no-audit --no-fund ${registryFlag} ${prefixFlag} ${packages}`.nothrow()
+      () => import_index.$`npm install --no-save --no-audit --no-fund ${registryFlag} ${prefixFlag} ${packages}`.nothrow()
     );
   });
 }
build/goods.cjs
@@ -1,238 +0,0 @@
-"use strict";
-const {
-  __pow,
-  __spreadValues,
-  __export,
-  __toESM,
-  __toCommonJS,
-  __async,
-  __forAwait
-} = require('./esblib.cjs');
-
-
-// src/goods.ts
-var goods_exports = {};
-__export(goods_exports, {
-  argv: () => argv,
-  echo: () => echo,
-  expBackoff: () => expBackoff,
-  fetch: () => fetch,
-  parseArgv: () => parseArgv,
-  question: () => question,
-  retry: () => retry,
-  sleep: () => sleep,
-  spinner: () => spinner,
-  stdin: () => stdin,
-  updateArgv: () => updateArgv
-});
-module.exports = __toCommonJS(goods_exports);
-var import_node_assert = __toESM(require("assert"), 1);
-var import_node_readline = require("readline");
-var import_node_stream = require("stream");
-var import_core = require("./core.cjs");
-var import_util = require("./util.cjs");
-var import_vendor = require("./vendor.cjs");
-var import_node_buffer = require("buffer");
-var import_node_process = __toESM(require("process"), 1);
-var parseArgv = (args = import_node_process.default.argv.slice(2), opts = {}, defs = {}) => Object.entries((0, import_vendor.minimist)(args, opts)).reduce(
-  (m, [k, v]) => {
-    const kTrans = opts.camelCase ? import_util.toCamelCase : import_util.identity;
-    const vTrans = opts.parseBoolean ? import_util.parseBool : import_util.identity;
-    const [_k, _v] = k === "--" || k === "_" ? [k, v] : [kTrans(k), vTrans(v)];
-    m[_k] = _v;
-    return m;
-  },
-  __spreadValues({}, defs)
-);
-function updateArgv(args, opts) {
-  for (const k in argv) delete argv[k];
-  Object.assign(argv, parseArgv(args, opts));
-}
-var argv = parseArgv();
-function sleep(duration) {
-  return new Promise((resolve) => {
-    setTimeout(resolve, (0, import_util.parseDuration)(duration));
-  });
-}
-var responseToReadable = (response, rs) => {
-  var _a;
-  const reader = (_a = response.body) == null ? void 0 : _a.getReader();
-  if (!reader) {
-    rs.push(null);
-    return rs;
-  }
-  rs._read = () => __async(void 0, null, function* () {
-    const result = yield reader.read();
-    if (!result.done) rs.push(import_node_buffer.Buffer.from(result.value));
-    else rs.push(null);
-  });
-  return rs;
-};
-function fetch(url, init) {
-  import_core.$.log({ kind: "fetch", url, init, verbose: !import_core.$.quiet && import_core.$.verbose });
-  const p = (0, import_vendor.nodeFetch)(url, init);
-  return Object.assign(p, {
-    pipe(dest, ...args) {
-      const rs = new import_node_stream.Readable();
-      const _dest = (0, import_util.isStringLiteral)(dest, ...args) ? (0, import_core.$)({
-        halt: true,
-        signal: init == null ? void 0 : init.signal
-      })(dest, ...args) : dest;
-      p.then(
-        (r) => {
-          var _a;
-          return responseToReadable(r, rs).pipe((_a = _dest.run) == null ? void 0 : _a.call(_dest));
-        },
-        (err) => {
-          var _a;
-          return (_a = _dest.abort) == null ? void 0 : _a.call(_dest, err);
-        }
-      );
-      return _dest;
-    }
-  });
-}
-function echo(pieces, ...args) {
-  const lastIdx = pieces.length - 1;
-  const msg = (0, import_util.isStringLiteral)(pieces, ...args) ? args.map((a, i) => pieces[i] + stringify(a)).join("") + pieces[lastIdx] : [pieces, ...args].map(stringify).join(" ");
-  console.log(msg);
-}
-function stringify(arg) {
-  return arg instanceof import_core.ProcessOutput ? arg.toString().trimEnd() : `${arg}`;
-}
-function question(query, options) {
-  return __async(this, null, function* () {
-    let completer = void 0;
-    if (options && Array.isArray(options.choices)) {
-      completer = function completer2(line) {
-        const completions = options.choices;
-        const hits = completions.filter((c) => c.startsWith(line));
-        return [hits.length ? hits : completions, line];
-      };
-    }
-    const rl = (0, import_node_readline.createInterface)({
-      input: import_node_process.default.stdin,
-      output: import_node_process.default.stdout,
-      terminal: true,
-      completer
-    });
-    return new Promise(
-      (resolve) => rl.question(query != null ? query : "", (answer) => {
-        rl.close();
-        resolve(answer);
-      })
-    );
-  });
-}
-function stdin() {
-  return __async(this, null, function* () {
-    let buf = "";
-    import_node_process.default.stdin.setEncoding("utf8");
-    try {
-      for (var iter = __forAwait(import_node_process.default.stdin), more, temp, error; more = !(temp = yield iter.next()).done; more = false) {
-        const chunk = temp.value;
-        buf += chunk;
-      }
-    } catch (temp) {
-      error = [temp];
-    } finally {
-      try {
-        more && (temp = iter.return) && (yield temp.call(iter));
-      } finally {
-        if (error)
-          throw error[0];
-      }
-    }
-    return buf;
-  });
-}
-function retry(count, a, b) {
-  return __async(this, null, function* () {
-    const total = count;
-    let callback;
-    let delayStatic = 0;
-    let delayGen;
-    if (typeof a === "function") {
-      callback = a;
-    } else {
-      if (typeof a === "object") {
-        delayGen = a;
-      } else {
-        delayStatic = (0, import_util.parseDuration)(a);
-      }
-      (0, import_node_assert.default)(b);
-      callback = b;
-    }
-    let lastErr;
-    let attempt = 0;
-    while (count-- > 0) {
-      attempt++;
-      try {
-        return yield callback();
-      } catch (err) {
-        let delay = 0;
-        if (delayStatic > 0) delay = delayStatic;
-        if (delayGen) delay = delayGen.next().value;
-        import_core.$.log({
-          kind: "retry",
-          total,
-          attempt,
-          delay,
-          exception: err,
-          verbose: !import_core.$.quiet && import_core.$.verbose,
-          error: `FAIL Attempt: ${attempt}/${total}, next: ${delay}`
-          // legacy
-        });
-        lastErr = err;
-        if (count == 0) break;
-        if (delay) yield sleep(delay);
-      }
-    }
-    throw lastErr;
-  });
-}
-function* expBackoff(max = "60s", rand = "100ms") {
-  const maxMs = (0, import_util.parseDuration)(max);
-  const randMs = (0, import_util.parseDuration)(rand);
-  let n = 1;
-  while (true) {
-    const ms = Math.floor(Math.random() * randMs);
-    yield Math.min(__pow(2, n++), maxMs) + ms;
-  }
-}
-function spinner(title, callback) {
-  return __async(this, null, function* () {
-    if (typeof title === "function") {
-      callback = title;
-      title = "";
-    }
-    if (import_core.$.quiet || import_node_process.default.env.CI) return callback();
-    let i = 0;
-    const spin = () => import_node_process.default.stderr.write(`  ${"\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F"[i++ % 10]} ${title}\r`);
-    return (0, import_core.within)(() => __async(this, null, function* () {
-      import_core.$.verbose = false;
-      const id = setInterval(spin, 100);
-      try {
-        return yield callback();
-      } finally {
-        clearInterval(id);
-        import_node_process.default.stderr.write(" ".repeat((import_node_process.default.stdout.columns || 1) - 1) + "\r");
-      }
-    }));
-  });
-}
-/* c8 ignore next 100 */
-// Annotate the CommonJS export names for ESM import in node:
-0 && (module.exports = {
-  argv,
-  echo,
-  expBackoff,
-  fetch,
-  parseArgv,
-  question,
-  retry,
-  sleep,
-  spinner,
-  stdin,
-  updateArgv
-});
\ No newline at end of file
build/goods.d.ts
@@ -1,3 +1,5 @@
+import { Readable } from 'node:stream';
+import { type ProcessPromise } from './core.js';
 import { type Duration } from './util.js';
 import { type RequestInfo, type RequestInit, minimist } from './vendor.js';
 type ArgvOpts = minimist.Opts & {
@@ -9,16 +11,21 @@ export declare function updateArgv(args?: string[], opts?: ArgvOpts): void;
 export declare const argv: minimist.ParsedArgs;
 export declare function sleep(duration: Duration): Promise<void>;
 export declare function fetch(url: RequestInfo, init?: RequestInit): Promise<Response> & {
-    pipe: <D>(dest: D) => D;
+    pipe: {
+        (dest: TemplateStringsArray, ...args: any[]): ProcessPromise;
+        <D>(dest: D): D;
+    };
 };
 export declare function echo(...args: any[]): void;
-export declare function question(query?: string, options?: {
-    choices: string[];
+export declare function question(query?: string, { choices, input, output, }?: {
+    choices?: string[];
+    input?: NodeJS.ReadStream;
+    output?: NodeJS.WriteStream;
 }): Promise<string>;
-export declare function stdin(): Promise<string>;
+export declare function stdin(stream?: Readable): Promise<string>;
 export declare function retry<T>(count: number, callback: () => T): Promise<T>;
 export declare function retry<T>(count: number, duration: Duration | Generator<number>, callback: () => T): Promise<T>;
-export declare function expBackoff(max?: Duration, rand?: Duration): Generator<number, void, unknown>;
+export declare function expBackoff(max?: Duration, delay?: Duration): Generator<number, void, unknown>;
 export declare function spinner<T>(callback: () => T): Promise<T>;
 export declare function spinner<T>(title: string, callback: () => T): Promise<T>;
 export {};
build/goods.js
@@ -1,30 +0,0 @@
-"use strict";
-import "./deno.js"
-import * as __module__ from "./goods.cjs"
-const {
-  argv,
-  echo,
-  expBackoff,
-  fetch,
-  parseArgv,
-  question,
-  retry,
-  sleep,
-  spinner,
-  stdin,
-  updateArgv
-} = globalThis.Deno ? globalThis.require("./goods.cjs") : __module__
-export {
-  argv,
-  echo,
-  expBackoff,
-  fetch,
-  parseArgv,
-  question,
-  retry,
-  sleep,
-  spinner,
-  stdin,
-  updateArgv
-}
-
build/index.cjs
@@ -1,8 +1,13 @@
 "use strict";
 const {
+  __pow,
+  __spreadValues,
   __export,
   __reExport,
-  __toCommonJS
+  __toESM,
+  __toCommonJS,
+  __async,
+  __forAwait
 } = require('./esblib.cjs');
 
 const import_meta_url =
@@ -16,31 +21,243 @@ const import_meta_url =
 var index_exports = {};
 __export(index_exports, {
   VERSION: () => VERSION,
-  YAML: () => import_vendor2.YAML,
-  dotenv: () => import_vendor2.dotenv,
-  fs: () => import_vendor2.fs,
-  glob: () => import_vendor2.glob,
-  globby: () => import_vendor2.glob,
-  minimist: () => import_vendor2.minimist,
+  YAML: () => import_vendor3.YAML,
+  argv: () => argv,
+  dotenv: () => import_vendor3.dotenv,
+  echo: () => echo,
+  expBackoff: () => expBackoff,
+  fetch: () => fetch,
+  fs: () => import_vendor3.fs,
+  glob: () => import_vendor3.glob,
+  globby: () => import_vendor3.glob,
+  minimist: () => import_vendor3.minimist,
   nothrow: () => nothrow,
+  parseArgv: () => parseArgv,
+  question: () => question,
   quiet: () => quiet,
-  quote: () => import_util.quote,
-  quotePowerShell: () => import_util.quotePowerShell,
-  tempdir: () => import_util.tempdir,
-  tempfile: () => import_util.tempfile,
-  tmpdir: () => import_util.tempdir,
-  tmpfile: () => import_util.tempfile,
+  quote: () => import_util2.quote,
+  quotePowerShell: () => import_util2.quotePowerShell,
+  retry: () => retry,
+  sleep: () => sleep,
+  spinner: () => spinner,
+  stdin: () => stdin,
+  tempdir: () => import_util2.tempdir,
+  tempfile: () => import_util2.tempfile,
+  tmpdir: () => import_util2.tempdir,
+  tmpfile: () => import_util2.tempfile,
+  updateArgv: () => updateArgv,
   version: () => version
 });
 module.exports = __toCommonJS(index_exports);
-var import_vendor = require("./vendor.cjs");
-__reExport(index_exports, require("./core.cjs"), module.exports);
-__reExport(index_exports, require("./goods.cjs"), module.exports);
 var import_vendor2 = require("./vendor.cjs");
+__reExport(index_exports, require("./core.cjs"), module.exports);
+
+// src/goods.ts
+var import_node_assert = __toESM(require("assert"), 1);
+var import_node_readline = require("readline");
+var import_node_stream = require("stream");
+var import_core = require("./core.cjs");
 var import_util = require("./util.cjs");
+var import_vendor = require("./vendor.cjs");
+var import_node_buffer = require("buffer");
+var import_node_process = __toESM(require("process"), 1);
+var parseArgv = (args = import_node_process.default.argv.slice(2), opts = {}, defs = {}) => Object.entries((0, import_vendor.minimist)(args, opts)).reduce(
+  (m, [k, v]) => {
+    const kTrans = opts.camelCase ? import_util.toCamelCase : import_util.identity;
+    const vTrans = opts.parseBoolean ? import_util.parseBool : import_util.identity;
+    const [_k, _v] = k === "--" || k === "_" ? [k, v] : [kTrans(k), vTrans(v)];
+    m[_k] = _v;
+    return m;
+  },
+  __spreadValues({}, defs)
+);
+function updateArgv(args, opts) {
+  for (const k in argv) delete argv[k];
+  Object.assign(argv, parseArgv(args, opts));
+}
+var argv = parseArgv();
+function sleep(duration) {
+  return new Promise((resolve) => {
+    setTimeout(resolve, (0, import_util.parseDuration)(duration));
+  });
+}
+var responseToReadable = (response, rs) => {
+  var _a2;
+  const reader = (_a2 = response.body) == null ? void 0 : _a2.getReader();
+  if (!reader) {
+    rs.push(null);
+    return rs;
+  }
+  rs._read = () => __async(void 0, null, function* () {
+    const result = yield reader.read();
+    if (!result.done) rs.push(import_node_buffer.Buffer.from(result.value));
+    else rs.push(null);
+  });
+  return rs;
+};
+function fetch(url, init) {
+  import_core.$.log({ kind: "fetch", url, init, verbose: !import_core.$.quiet && import_core.$.verbose });
+  const p = (0, import_vendor.nodeFetch)(url, init);
+  return Object.assign(p, {
+    pipe(dest, ...args) {
+      const rs = new import_node_stream.Readable();
+      const _dest = (0, import_util.isStringLiteral)(dest, ...args) ? (0, import_core.$)({
+        halt: true,
+        signal: init == null ? void 0 : init.signal
+      })(dest, ...args) : dest;
+      p.then(
+        (r) => {
+          var _a2;
+          return responseToReadable(r, rs).pipe((_a2 = _dest.run) == null ? void 0 : _a2.call(_dest));
+        },
+        (err) => {
+          var _a2;
+          return (_a2 = _dest.abort) == null ? void 0 : _a2.call(_dest, err);
+        }
+      );
+      return _dest;
+    }
+  });
+}
+function echo(pieces, ...args) {
+  const lastIdx = pieces.length - 1;
+  const msg = (0, import_util.isStringLiteral)(pieces, ...args) ? args.map((a, i) => pieces[i] + stringify(a)).join("") + pieces[lastIdx] : [pieces, ...args].map(stringify).join(" ");
+  console.log(msg);
+}
+function stringify(arg) {
+  return arg instanceof import_core.ProcessOutput ? arg.toString().trimEnd() : `${arg}`;
+}
+function question(_0) {
+  return __async(this, arguments, function* (query, {
+    choices,
+    input = import_node_process.default.stdin,
+    output = import_node_process.default.stdout
+  } = {}) {
+    let completer = void 0;
+    if (Array.isArray(choices)) {
+      completer = function completer2(line) {
+        const hits = choices.filter((c) => c.startsWith(line));
+        return [hits.length ? hits : choices, line];
+      };
+    }
+    const rl = (0, import_node_readline.createInterface)({
+      input,
+      output,
+      terminal: true,
+      completer
+    });
+    return new Promise(
+      (resolve) => rl.question(query != null ? query : "", (answer) => {
+        rl.close();
+        resolve(answer);
+      })
+    );
+  });
+}
+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) {
+        const chunk = temp.value;
+        buf += chunk;
+      }
+    } catch (temp) {
+      error = [temp];
+    } finally {
+      try {
+        more && (temp = iter.return) && (yield temp.call(iter));
+      } finally {
+        if (error)
+          throw error[0];
+      }
+    }
+    return buf;
+  });
+}
+function retry(count, a, b) {
+  return __async(this, null, function* () {
+    const total = count;
+    let callback;
+    let delayStatic = 0;
+    let delayGen;
+    if (typeof a === "function") {
+      callback = a;
+    } else {
+      if (typeof a === "object") {
+        delayGen = a;
+      } else {
+        delayStatic = (0, import_util.parseDuration)(a);
+      }
+      (0, import_node_assert.default)(b);
+      callback = b;
+    }
+    let lastErr;
+    let attempt = 0;
+    while (count-- > 0) {
+      attempt++;
+      try {
+        return yield callback();
+      } catch (err) {
+        let delay = 0;
+        if (delayStatic > 0) delay = delayStatic;
+        if (delayGen) delay = delayGen.next().value;
+        import_core.$.log({
+          kind: "retry",
+          total,
+          attempt,
+          delay,
+          exception: err,
+          verbose: !import_core.$.quiet && import_core.$.verbose,
+          error: `FAIL Attempt: ${attempt}/${total}, next: ${delay}`
+          // legacy
+        });
+        lastErr = err;
+        if (count == 0) break;
+        if (delay) yield sleep(delay);
+      }
+    }
+    throw lastErr;
+  });
+}
+function* expBackoff(max = "60s", delay = "100ms") {
+  const maxMs = (0, import_util.parseDuration)(max);
+  const randMs = (0, import_util.parseDuration)(delay);
+  let n = 0;
+  while (true) {
+    yield Math.min(randMs * __pow(2, n++), maxMs);
+  }
+}
+function spinner(title, callback) {
+  return __async(this, null, function* () {
+    if (typeof title === "function") {
+      callback = title;
+      title = "";
+    }
+    if (import_core.$.quiet || import_node_process.default.env.CI) return callback();
+    let i = 0;
+    const stream = import_core.$.log.output || import_node_process.default.stderr;
+    const spin = () => stream.write(`  ${"\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F"[i++ % 10]} ${title}\r`);
+    return (0, import_core.within)(() => __async(this, null, function* () {
+      import_core.$.verbose = false;
+      const id = setInterval(spin, 100);
+      try {
+        return yield callback();
+      } finally {
+        clearInterval(id);
+        stream.write(" ".repeat((import_node_process.default.stdout.columns || 1) - 1) + "\r");
+      }
+    }));
+  });
+}
+
+// src/index.ts
+var import_vendor3 = require("./vendor.cjs");
+var import_util2 = require("./util.cjs");
 var import_meta = {};
 var _a;
-var VERSION = ((_a = import_vendor.fs.readJsonSync(new URL("../package.json", import_meta_url), {
+var VERSION = ((_a = import_vendor2.fs.readJsonSync(new URL("../package.json", import_meta_url), {
   throws: false
 })) == null ? void 0 : _a.version) || URL.parse(import_meta_url).pathname.split("/")[3];
 var version = VERSION;
@@ -55,20 +272,30 @@ function quiet(promise) {
 0 && (module.exports = {
   VERSION,
   YAML,
+  argv,
   dotenv,
+  echo,
+  expBackoff,
+  fetch,
   fs,
   glob,
   globby,
   minimist,
   nothrow,
+  parseArgv,
+  question,
   quiet,
   quote,
   quotePowerShell,
+  retry,
+  sleep,
+  spinner,
+  stdin,
   tempdir,
   tempfile,
   tmpdir,
   tmpfile,
+  updateArgv,
   version,
-  ...require("./core.cjs"),
-  ...require("./goods.cjs")
+  ...require("./core.cjs")
 });
\ No newline at end of file
build/index.js
@@ -4,19 +4,30 @@ import * as __module__ from "./index.cjs"
 const {
   VERSION,
   YAML,
+  argv,
   dotenv,
+  echo,
+  expBackoff,
+  fetch,
   fs,
   glob,
   globby,
   minimist,
   nothrow,
+  parseArgv,
+  question,
   quiet,
   quote,
   quotePowerShell,
+  retry,
+  sleep,
+  spinner,
+  stdin,
   tempdir,
   tempfile,
   tmpdir,
   tmpfile,
+  updateArgv,
   version,
   $,
   ProcessOutput,
@@ -35,35 +46,35 @@ const {
   usePowerShell,
   usePwsh,
   which,
-  within,
-  argv,
-  echo,
-  expBackoff,
-  fetch,
-  parseArgv,
-  question,
-  retry,
-  sleep,
-  spinner,
-  stdin,
-  updateArgv
+  within
 } = globalThis.Deno ? globalThis.require("./index.cjs") : __module__
 export {
   VERSION,
   YAML,
+  argv,
   dotenv,
+  echo,
+  expBackoff,
+  fetch,
   fs,
   glob,
   globby,
   minimist,
   nothrow,
+  parseArgv,
+  question,
   quiet,
   quote,
   quotePowerShell,
+  retry,
+  sleep,
+  spinner,
+  stdin,
   tempdir,
   tempfile,
   tmpdir,
   tmpfile,
+  updateArgv,
   version,
   $,
   ProcessOutput,
@@ -82,17 +93,6 @@ export {
   usePowerShell,
   usePwsh,
   which,
-  within,
-  argv,
-  echo,
-  expBackoff,
-  fetch,
-  parseArgv,
-  question,
-  retry,
-  sleep,
-  spinner,
-  stdin,
-  updateArgv
+  within
 }
 
docs/api.md
@@ -153,10 +153,13 @@ const p2 = fetch('https://example.com').pipe`cat`
 
 ## `question()`
 
-A wrapper around the [readline](https://nodejs.org/api/readline.html) package.
+A wrapper around the [readline](https://nodejs.org/api/readline.html) API.
 
 ```js
 const bear = await question('What kind of bear is best? ')
+const selected = await question('Select an option:', {
+  choices: ['A', 'B', 'C'],
+})
 ```
 
 ## `sleep()`
src/deps.ts
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { $ } from './core.ts'
-import { spinner } from './goods.ts'
+import { $, spinner } from './index.ts'
 import { depseek } from './vendor.ts'
 
 /**
src/error.ts
@@ -224,10 +224,12 @@ 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[3] || // skip getCallerLocation and Proxy.set
+    lines[offset] ||
     stackString
   ).trim()
 }
src/goods.ts
@@ -15,7 +15,7 @@
 import assert from 'node:assert'
 import { createInterface } from 'node:readline'
 import { Readable } from 'node:stream'
-import { $, within, ProcessOutput } from './core.ts'
+import { $, within, ProcessOutput, type ProcessPromise } from './core.ts'
 import {
   type Duration,
   identity,
@@ -81,7 +81,12 @@ const responseToReadable = (response: Response, rs: Readable) => {
 export function fetch(
   url: RequestInfo,
   init?: RequestInit
-): Promise<Response> & { pipe: <D>(dest: D) => D } {
+): Promise<Response> & {
+  pipe: {
+    (dest: TemplateStringsArray, ...args: any[]): ProcessPromise
+    <D>(dest: D): D
+  }
+} {
   $.log({ kind: 'fetch', url, init, verbose: !$.quiet && $.verbose })
   const p = nodeFetch(url, init)
 
@@ -119,20 +124,27 @@ function stringify(arg: ProcessOutput | any) {
 
 export async function question(
   query?: string,
-  options?: { choices: string[] }
+  {
+    choices,
+    input = process.stdin,
+    output = process.stdout,
+  }: {
+    choices?: string[]
+    input?: NodeJS.ReadStream
+    output?: NodeJS.WriteStream
+  } = {}
 ): Promise<string> {
   let completer = undefined
-  if (options && Array.isArray(options.choices)) {
+  if (Array.isArray(choices)) {
     /* c8 ignore next 5 */
     completer = function completer(line: string) {
-      const completions = options.choices
-      const hits = completions.filter((c) => c.startsWith(line))
-      return [hits.length ? hits : completions, line]
+      const hits = choices.filter((c) => c.startsWith(line))
+      return [hits.length ? hits : choices, line]
     }
   }
   const rl = createInterface({
-    input: process.stdin,
-    output: process.stdout,
+    input,
+    output,
     terminal: true,
     completer,
   })
@@ -145,10 +157,10 @@ export async function question(
   )
 }
 
-export async function stdin(): Promise<string> {
+export async function stdin(stream: Readable = process.stdin): Promise<string> {
   let buf = ''
-  process.stdin.setEncoding('utf8')
-  for await (const chunk of process.stdin) {
+  stream.setEncoding('utf8')
+  for await (const chunk of stream) {
     buf += chunk
   }
   return buf
@@ -209,14 +221,13 @@ export async function retry<T>(
 
 export function* expBackoff(
   max: Duration = '60s',
-  rand: Duration = '100ms'
+  delay: Duration = '100ms'
 ): Generator<number, void, unknown> {
   const maxMs = parseDuration(max)
-  const randMs = parseDuration(rand)
-  let n = 1
+  const randMs = parseDuration(delay)
+  let n = 0
   while (true) {
-    const ms = Math.floor(Math.random() * randMs)
-    yield Math.min(2 ** n++, maxMs) + ms
+    yield Math.min(randMs * 2 ** n++, maxMs)
   }
 }
 
@@ -233,8 +244,8 @@ export async function spinner<T>(
   if ($.quiet || process.env.CI) return callback!()
 
   let i = 0
-  const spin = () =>
-    process.stderr.write(`  ${'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[i++ % 10]} ${title}\r`)
+  const stream = $.log.output || process.stderr
+  const spin = () => stream.write(`  ${'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[i++ % 10]} ${title}\r`)
   return within(async () => {
     $.verbose = false
     const id = setInterval(spin, 100)
@@ -243,7 +254,7 @@ export async function spinner<T>(
       return await callback!()
     } finally {
       clearInterval(id as ReturnType<typeof setTimeout>)
-      process.stderr.write(' '.repeat((process.stdout.columns || 1) - 1) + '\r')
+      stream.write(' '.repeat((process.stdout.columns || 1) - 1) + '\r')
     }
   })
 }
test/all.test.js
@@ -18,7 +18,7 @@ import './deps.test.js'
 import './error.test.ts'
 import './export.test.js'
 import './global.test.js'
-import './goods.test.js'
+import './goods.test.ts'
 import './index.test.js'
 import './log.test.ts'
 import './md.test.ts'
test/goods.test.js
@@ -1,263 +0,0 @@
-// Copyright 2021 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import assert from 'node:assert'
-import { test, describe, after } from 'node:test'
-import { $, chalk, fs, tempfile, dotenv } from '../build/index.js'
-import { echo, sleep, parseArgv } from '../build/goods.js'
-
-describe('goods', () => {
-  function zx(script) {
-    return $`node build/cli.js --eval ${script}`.nothrow().timeout('5s')
-  }
-
-  test('question() works', async () => {
-    const p = $`node build/cli.js --eval "
-  let answer = await question('foo or bar? ', { choices: ['foo', 'bar'] })
-  echo('Answer is', answer)
-"`
-    p.stdin.write('foo\n')
-    p.stdin.end()
-    assert.match((await p).stdout, /Answer is foo/)
-  })
-
-  test('echo() works', async () => {
-    const log = console.log
-    let stdout = ''
-    console.log = (...args) => {
-      stdout += args.join(' ')
-    }
-    echo(chalk.cyan('foo'), chalk.green('bar'), chalk.bold('baz'))
-    echo`${chalk.cyan('foo')} ${chalk.green('bar')} ${chalk.bold('baz')}`
-    echo(
-      await $`echo ${chalk.cyan('foo')}`,
-      await $`echo ${chalk.green('bar')}`,
-      await $`echo ${chalk.bold('baz')}`
-    )
-    console.log = log
-    assert.match(stdout, /foo/)
-  })
-
-  test('sleep() works', async () => {
-    const now = Date.now()
-    await sleep(100)
-    assert.ok(Date.now() >= now + 99)
-  })
-
-  test('retry() works', async () => {
-    const now = Date.now()
-    const p = await zx(`
-    try {
-      await retry(5, '50ms', () => $\`exit 123\`)
-    } catch (e) {
-      echo('exitCode:', e.exitCode)
-    }
-    await retry(5, () => $\`exit 0\`)
-    echo('success')
-`)
-    assert.ok(p.toString().includes('exitCode: 123'))
-    assert.ok(p.toString().includes('success'))
-    assert.ok(Date.now() >= now + 50 * (5 - 1))
-  })
-
-  test('retry() with expBackoff() works', async () => {
-    const now = Date.now()
-    const p = await zx(`
-    try {
-      await retry(5, expBackoff('60s', 0), () => $\`exit 123\`)
-    } catch (e) {
-      echo('exitCode:', e.exitCode)
-    }
-    echo('success')
-`)
-    assert.ok(p.toString().includes('exitCode: 123'))
-    assert.ok(p.toString().includes('success'))
-    assert.ok(Date.now() >= now + 2 + 4 + 8 + 16 + 32)
-  })
-
-  describe('spinner()', () => {
-    test('works', async () => {
-      const out = await zx(
-        `
-    process.env.CI = ''
-    echo(await spinner(async () => {
-      await sleep(100)
-      await $\`echo hidden\`
-      return $\`echo result\`
-    }))
-  `
-      )
-      assert(out.stdout.includes('result'))
-      assert(out.stderr.includes('⠋'))
-      assert(!out.stderr.includes('result'))
-      assert(!out.stderr.includes('hidden'))
-    })
-
-    test('with title', async () => {
-      const out = await zx(
-        `
-    process.env.CI = ''
-    await spinner('processing', () => sleep(100))
-  `
-      )
-      assert.match(out.stderr, /processing/)
-    })
-
-    test('disabled in CI', async () => {
-      const out = await zx(
-        `
-    process.env.CI = 'true'
-    await spinner('processing', () => sleep(100))
-  `
-      )
-      assert.doesNotMatch(out.stderr, /processing/)
-    })
-
-    test('stops on throw', async () => {
-      const out = await zx(`
-    await spinner('processing', () => $\`wtf-cmd\`)
-  `)
-      assert.match(out.stderr, /Error:/)
-      assert(out.exitCode !== 0)
-    })
-  })
-
-  test('parseArgv() works', () => {
-    assert.deepEqual(
-      parseArgv(
-        // prettier-ignore
-        [
-          '--foo-bar', 'baz',
-          '-a', '5',
-          '-a', '42',
-          '--aaa', 'AAA',
-          '--force',
-          './some.file',
-          '--b1', 'true',
-          '--b2', 'false',
-          '--b3',
-          '--b4', 'false',
-          '--b5', 'true',
-          '--b6', 'str'
-        ],
-        {
-          boolean: ['force', 'b3', 'b4', 'b5', 'b6'],
-          camelCase: true,
-          parseBoolean: true,
-          alias: { a: 'aaa' },
-        },
-        {
-          def: 'def',
-        }
-      ),
-      {
-        a: [5, 42, 'AAA'],
-        aaa: [5, 42, 'AAA'],
-        fooBar: 'baz',
-        force: true,
-        _: ['./some.file', 'str'],
-        b1: true,
-        b2: false,
-        b3: true,
-        b4: false,
-        b5: true,
-        b6: true,
-        def: 'def',
-      }
-    )
-  })
-
-  describe('dotenv', () => {
-    test('parse()', () => {
-      assert.deepEqual(dotenv.parse(''), {})
-      assert.deepEqual(
-        dotenv.parse('ENV=v1\nENV2=v2\n\n\n  ENV3  =    v3   \nexport ENV4=v4'),
-        {
-          ENV: 'v1',
-          ENV2: 'v2',
-          ENV3: 'v3',
-          ENV4: 'v4',
-        }
-      )
-
-      const multiline = `SIMPLE=xyz123
-# comment ###
-NON_INTERPOLATED='raw text without variable interpolation' 
-MULTILINE = """
-long text here, # not-comment
-e.g. a private SSH key
-"""
-ENV=v1\nENV2=v2\n\n\n\t\t  ENV3  =    v3   \n   export ENV4=v4
-ENV5=v5 # comment
-`
-      assert.deepEqual(dotenv.parse(multiline), {
-        SIMPLE: 'xyz123',
-        NON_INTERPOLATED: 'raw text without variable interpolation',
-        MULTILINE: 'long text here, # not-comment\ne.g. a private SSH key',
-        ENV: 'v1',
-        ENV2: 'v2',
-        ENV3: 'v3',
-        ENV4: 'v4',
-        ENV5: 'v5',
-      })
-    })
-
-    describe('load()', () => {
-      const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
-      const file2 = tempfile('.env.2', 'ENV2=value222\nENV3=value3')
-      after(() => Promise.all([fs.remove(file1), fs.remove(file2)]))
-
-      test('loads env from files', () => {
-        const env = dotenv.load(file1, file2)
-        assert.equal(env.ENV1, 'value1')
-        assert.equal(env.ENV2, 'value2')
-        assert.equal(env.ENV3, 'value3')
-      })
-
-      test('throws error on ENOENT', () => {
-        try {
-          dotenv.load('./.env')
-          assert.throw()
-        } catch (e) {
-          assert.equal(e.code, 'ENOENT')
-          assert.equal(e.errno, -2)
-        }
-      })
-    })
-
-    describe('loadSafe()', () => {
-      const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
-      const file2 = '.env.notexists'
-
-      after(() => fs.remove(file1))
-
-      test('loads env from files', () => {
-        const env = dotenv.loadSafe(file1, file2)
-        assert.equal(env.ENV1, 'value1')
-        assert.equal(env.ENV2, 'value2')
-      })
-    })
-
-    describe('config()', () => {
-      test('updates process.env', () => {
-        const file1 = tempfile('.env.1', 'ENV1=value1')
-
-        assert.equal(process.env.ENV1, undefined)
-        dotenv.config(file1)
-        assert.equal(process.env.ENV1, 'value1')
-        delete process.env.ENV1
-      })
-    })
-  })
-})
test/goods.test.ts
@@ -0,0 +1,423 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import assert from 'node:assert'
+import { test, describe, after } from 'node:test'
+import { Duplex } from 'node:stream'
+import { $, chalk, fs, path, tempfile, dotenv } from '../src/index.ts'
+import {
+  echo,
+  sleep,
+  argv,
+  parseArgv,
+  updateArgv,
+  stdin,
+  spinner,
+  fetch,
+  retry,
+  question,
+  expBackoff,
+} from '../src/goods.ts'
+import { Writable } from 'node:stream'
+import process from 'node:process'
+
+const __dirname = new URL('.', import.meta.url).pathname
+const root = path.resolve(__dirname, '..')
+
+describe('goods', () => {
+  function zx(script) {
+    return $`node build/cli.js --eval ${script}`.nothrow().timeout('5s')
+  }
+
+  describe('question()', async () => {
+    test('works', async () => {
+      let contents = ''
+      class Input extends Duplex {
+        constructor() {
+          super()
+        }
+        _read() {}
+        _write(chunk, encoding, callback) {
+          this.push(chunk)
+          callback()
+        }
+        _final() {
+          this.push(null)
+        }
+      }
+      const input = new Input() as any
+      const output = new Writable({
+        write: function (chunk, encoding, next) {
+          contents += chunk.toString()
+          next()
+        },
+      }) as NodeJS.WriteStream
+
+      setTimeout(() => {
+        input.write('foo\n')
+        input.end()
+      }, 10)
+      const result = await question('foo or bar? ', {
+        choices: ['foo', 'bar'],
+        input,
+        output,
+      })
+
+      assert.equal(result, 'foo')
+      assert(contents.includes('foo or bar? '))
+    })
+
+    test('integration', async () => {
+      const p = $`node build/cli.js --eval "
+  let answer = await question('foo or bar? ', { choices: ['foo', 'bar'] })
+  echo('Answer is', answer)
+"`
+      p.stdin.write('foo\n')
+      p.stdin.end()
+      assert.match((await p).stdout, /Answer is foo/)
+    })
+  })
+
+  test('echo() works', async () => {
+    const log = console.log
+    let stdout = ''
+    console.log = (...args) => {
+      stdout += args.join(' ')
+    }
+    echo(chalk.cyan('foo'), chalk.green('bar'), chalk.bold('baz'))
+    echo`${chalk.cyan('foo')} ${chalk.green('bar')} ${chalk.bold('baz')}`
+    echo(
+      await $`echo ${chalk.cyan('foo')}`,
+      await $`echo ${chalk.green('bar')}`,
+      await $`echo ${chalk.bold('baz')}`
+    )
+    console.log = log
+    assert.match(stdout, /foo/)
+  })
+
+  test('sleep() works', async () => {
+    const now = Date.now()
+    await sleep(100)
+    assert.ok(Date.now() >= now + 99)
+  })
+
+  describe('retry()', () => {
+    test('works', async () => {
+      let count = 0
+      const result = await retry(5, () => {
+        count++
+        if (count < 5) throw new Error('fail')
+        return 'success'
+      })
+      assert.equal(result, 'success')
+      assert.equal(count, 5)
+    })
+
+    test('works with custom delay and limit', async () => {
+      try {
+        await retry(3, '2ms', () => {
+          throw new Error('fail')
+        })
+      } catch (e) {
+        assert.match(e.message, /fail/)
+      }
+    })
+
+    test('supports expBackoff', async () => {
+      const result = await retry(5, expBackoff('10ms'), () => {
+        if (Math.random() < 0.1) throw new Error('fail')
+        return 'success'
+      })
+
+      assert.equal(result, 'success')
+    })
+
+    test('integration', async () => {
+      const now = Date.now()
+      const p = await zx(`
+    try {
+      await retry(5, '50ms', () => $\`exit 123\`)
+    } catch (e) {
+      echo('exitCode:', e.exitCode)
+    }
+    await retry(5, () => $\`exit 0\`)
+    echo('success')
+`)
+      assert.ok(p.toString().includes('exitCode: 123'))
+      assert.ok(p.toString().includes('success'))
+      assert.ok(Date.now() >= now + 50 * (5 - 1))
+    })
+
+    test('integration with expBackoff', async () => {
+      const now = Date.now()
+      const p = await zx(`
+    try {
+      await retry(5, expBackoff('60s', 0), () => $\`exit 123\`)
+    } catch (e) {
+      echo('exitCode:', e.exitCode)
+    }
+    echo('success')
+`)
+      assert.ok(p.toString().includes('exitCode: 123'))
+      assert.ok(p.toString().includes('success'))
+      assert.ok(Date.now() >= now + 2 + 4 + 8 + 16 + 32)
+    })
+  })
+
+  test('expBackoff()', async () => {
+    const g = expBackoff('10s', '100ms')
+
+    const [a, b, c] = [
+      g.next().value,
+      g.next().value,
+      g.next().value,
+    ] as number[]
+
+    assert.equal(a, 100)
+    assert.equal(b, 200)
+    assert.equal(c, 400)
+  })
+
+  describe('spinner()', () => {
+    test('works', async () => {
+      let contents = ''
+      const { CI } = process.env
+      const output = new Writable({
+        write: function (chunk, encoding, next) {
+          contents += chunk.toString()
+          next()
+        },
+      })
+
+      delete process.env.CI
+      $.log.output = output as NodeJS.WriteStream
+
+      const p = spinner(() => sleep(100))
+
+      delete $.log.output
+      process.env.CI = CI
+
+      await p
+      assert(contents.includes('⠋'))
+    })
+
+    describe('integration', () => {
+      test('works', async () => {
+        const out = await zx(
+          `
+    process.env.CI = ''
+    echo(await spinner(async () => {
+      await sleep(100)
+      await $\`echo hidden\`
+      return $\`echo result\`
+    }))
+  `
+        )
+        assert(out.stdout.includes('result'))
+        assert(out.stderr.includes('⠋'))
+        assert(!out.stderr.includes('result'))
+        assert(!out.stderr.includes('hidden'))
+      })
+
+      test('with title', async () => {
+        const out = await zx(
+          `
+    process.env.CI = ''
+    await spinner('processing', () => sleep(100))
+  `
+        )
+        assert.match(out.stderr, /processing/)
+      })
+
+      test('disabled in CI', async () => {
+        const out = await zx(
+          `
+    process.env.CI = 'true'
+    await spinner('processing', () => sleep(100))
+  `
+        )
+        assert.doesNotMatch(out.stderr, /processing/)
+      })
+
+      test('stops on throw', async () => {
+        const out = await zx(`
+    await spinner('processing', () => $\`wtf-cmd\`)
+  `)
+        assert.match(out.stderr, /Error:/)
+        assert(out.exitCode !== 0)
+      })
+    })
+  })
+
+  describe('args', () => {
+    test('parseArgv() works', () => {
+      assert.deepEqual(
+        parseArgv(
+          // prettier-ignore
+          [
+          '--foo-bar', 'baz',
+          '-a', '5',
+          '-a', '42',
+          '--aaa', 'AAA',
+          '--force',
+          './some.file',
+          '--b1', 'true',
+          '--b2', 'false',
+          '--b3',
+          '--b4', 'false',
+          '--b5', 'true',
+          '--b6', 'str'
+        ],
+          {
+            boolean: ['force', 'b3', 'b4', 'b5', 'b6'],
+            camelCase: true,
+            parseBoolean: true,
+            alias: { a: 'aaa' },
+          },
+          {
+            def: 'def',
+          }
+        ),
+        {
+          a: [5, 42, 'AAA'],
+          aaa: [5, 42, 'AAA'],
+          fooBar: 'baz',
+          force: true,
+          _: ['./some.file', 'str'],
+          b1: true,
+          b2: false,
+          b3: true,
+          b4: false,
+          b5: true,
+          b6: true,
+          def: 'def',
+        }
+      )
+    })
+
+    test('updateArgv() works', () => {
+      updateArgv(['--foo', 'bar'])
+      assert.deepEqual(argv, {
+        _: [],
+        foo: 'bar',
+      })
+    })
+  })
+
+  test('stdin()', async () => {
+    const stream = fs.createReadStream(path.resolve(root, 'package.json'))
+    const input = await stdin(stream)
+    assert.match(input, /"name": "zx"/)
+  })
+
+  test('fetch()', async () => {
+    const req1 = fetch('https://example.com/')
+    const req2 = fetch('https://example.com/')
+    const req3 = fetch('https://example.com/', { method: 'OPTIONS' })
+
+    const p1 = (await req1.pipe`cat`).stdout
+    const p2 = (await req2.pipe($`cat`)).stdout
+    const p3 = (await req3.pipe`cat`).stdout
+
+    assert.equal((await req1).status, 200)
+    assert.equal((await req2).status, 200)
+    assert.equal((await req3).status, 501)
+    assert(p1.includes('Example Domain'))
+    assert(p2.includes('Example Domain'))
+    assert(!p3.includes('Example Domain'))
+  })
+
+  describe('dotenv', () => {
+    test('parse()', () => {
+      assert.deepEqual(dotenv.parse(''), {})
+      assert.deepEqual(
+        dotenv.parse('ENV=v1\nENV2=v2\n\n\n  ENV3  =    v3   \nexport ENV4=v4'),
+        {
+          ENV: 'v1',
+          ENV2: 'v2',
+          ENV3: 'v3',
+          ENV4: 'v4',
+        }
+      )
+
+      const multiline = `SIMPLE=xyz123
+# comment ###
+NON_INTERPOLATED='raw text without variable interpolation' 
+MULTILINE = """
+long text here, # not-comment
+e.g. a private SSH key
+"""
+ENV=v1\nENV2=v2\n\n\n\t\t  ENV3  =    v3   \n   export ENV4=v4
+ENV5=v5 # comment
+`
+      assert.deepEqual(dotenv.parse(multiline), {
+        SIMPLE: 'xyz123',
+        NON_INTERPOLATED: 'raw text without variable interpolation',
+        MULTILINE: 'long text here, # not-comment\ne.g. a private SSH key',
+        ENV: 'v1',
+        ENV2: 'v2',
+        ENV3: 'v3',
+        ENV4: 'v4',
+        ENV5: 'v5',
+      })
+    })
+
+    describe('load()', () => {
+      const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
+      const file2 = tempfile('.env.2', 'ENV2=value222\nENV3=value3')
+      after(() => Promise.all([fs.remove(file1), fs.remove(file2)]))
+
+      test('loads env from files', () => {
+        const env = dotenv.load(file1, file2)
+        assert.equal(env.ENV1, 'value1')
+        assert.equal(env.ENV2, 'value2')
+        assert.equal(env.ENV3, 'value3')
+      })
+
+      test('throws error on ENOENT', () => {
+        try {
+          dotenv.load('./.env')
+          throw new Error('unreachable')
+        } catch (e) {
+          assert.equal(e.code, 'ENOENT')
+          assert.equal(e.errno, -2)
+        }
+      })
+    })
+
+    describe('loadSafe()', () => {
+      const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
+      const file2 = '.env.notexists'
+
+      after(() => fs.remove(file1))
+
+      test('loads env from files', () => {
+        const env = dotenv.loadSafe(file1, file2)
+        assert.equal(env.ENV1, 'value1')
+        assert.equal(env.ENV2, 'value2')
+      })
+    })
+
+    describe('config()', () => {
+      test('updates process.env', () => {
+        const file1 = tempfile('.env.1', 'ENV1=value1')
+
+        assert.equal(process.env.ENV1, undefined)
+        dotenv.config(file1)
+        assert.equal(process.env.ENV1, 'value1')
+        delete process.env.ENV1
+      })
+    })
+  })
+})
test/package.test.js
@@ -56,9 +56,7 @@ describe('package', () => {
         'build/globals.cjs',
         'build/globals.d.ts',
         'build/globals.js',
-        'build/goods.cjs',
         'build/goods.d.ts',
-        'build/goods.js',
         'build/index.cjs',
         'build/index.d.ts',
         'build/index.js',
.nycrc
@@ -10,6 +10,9 @@
     "build/esblib.cjs",
     "test/**",
     "scripts",
-    "src/util.ts"
+    "src/util.ts",
+    "src/core.ts",
+    "src/index.ts",
+    "src/vendor-extra.ts"
   ]
 }
.size-limit.json
@@ -7,7 +7,7 @@
       "build/core.js",
       "build/core.d.ts",
       "build/deno.js",
-      "build/esblib.js",
+      "build/esblib.cjs",
       "build/util.cjs",
       "build/util.js",
       "build/util.d.ts",
@@ -17,35 +17,35 @@
       "README.md",
       "LICENSE"
     ],
-    "limit": "115.10 kB",
+    "limit": "121.7 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "js parts",
     "path": "build/*.{js,cjs}",
-    "limit": "817.10 kB",
+    "limit": "816.30 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "libdefs",
     "path": "build/*.d.ts",
-    "limit": "39.75 kB",
+    "limit": "40.00 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "vendor",
     "path": "build/vendor-*",
-    "limit": "769.20 kB",
+    "limit": "769.15 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "all",
     "path": ["build/*", "man/*", "README.md", "LICENSE"],
-    "limit": "873.20 kB",
+    "limit": "872.70 kB",
     "brotli": false,
     "gzip": false
   }
package.json
@@ -64,7 +64,7 @@
     "fmt:check": "prettier --check .",
     "prebuild": "rm -rf build",
     "build": "npm run build:js && npm run build:dts && npm run build:tests",
-    "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 && npm run build:vendor",
+    "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",
     "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",
@@ -78,7 +78,7 @@
     "test:it": "node ./test/it/build.test.js",
     "test:jsr": "node ./test/it/build-jsr.test.js",
     "test:dcr": "node ./test/it/build-dcr.test.js",
-    "test:unit": "node --experimental-strip-types ./test/all.test.js",
+    "test:unit": "node --experimental-transform-types ./test/all.test.js",
     "test:coverage": "c8 -c .nycrc --check-coverage npm run test:unit",
     "test:circular": "madge --circular src/*",
     "test:types": "tsd",