From a918b4bcaf6020159a02fce5bec9d339c6c1bdb0 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:30:32 +0530 Subject: [PATCH 01/13] fix(goods): handle stream read errors in responseToReadable to prevent unhandled promise rejections --- build/cli.js | 0 scripts/build-js.mjs | 3 ++- scripts/build-jsr.mjs | 3 ++- scripts/build-pkgjson-lite.mjs | 3 ++- scripts/build-pkgjson-main.mjs | 3 ++- scripts/build-size-limit.mjs | 3 ++- scripts/build-tests.mjs | 4 +++- scripts/build-versions.mjs | 3 ++- src/goods.ts | 10 +++++++--- test/deps.test.js | 3 ++- test/export.test.js | 10 ++++++---- test/extra.test.js | 3 ++- test/goods.test.ts | 29 +++++++++++++++++++++++++++-- test/it/build-jsr.test.js | 3 ++- test/it/build-npm.test.js | 3 ++- 15 files changed, 63 insertions(+), 20 deletions(-) mode change 100644 => 100755 build/cli.js diff --git a/build/cli.js b/build/cli.js old mode 100644 new mode 100755 diff --git a/scripts/build-js.mjs b/scripts/build-js.mjs index e7228ee935..b3cbec581c 100644 --- a/scripts/build-js.mjs +++ b/scripts/build-js.mjs @@ -16,6 +16,7 @@ import path from 'node:path' import fs from 'node:fs' +import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { injectCode, injectFile } from 'esbuild-plugin-utils' import { nodeExternalsPlugin } from 'esbuild-node-externals' @@ -27,7 +28,7 @@ import esbuildResolvePlugin from 'esbuild-plugin-resolve' import minimist from 'minimist' import glob from 'fast-glob' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const argv = minimist(process.argv.slice(2), { default: { diff --git a/scripts/build-jsr.mjs b/scripts/build-jsr.mjs index 5975a51e23..5b7f2c7d3f 100644 --- a/scripts/build-jsr.mjs +++ b/scripts/build-jsr.mjs @@ -14,7 +14,8 @@ import fs from 'node:fs' import path from 'node:path' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +import { fileURLToPath } from 'node:url' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const pkgJson = JSON.parse( fs.readFileSync(path.resolve(root, 'package.json'), 'utf-8') diff --git a/scripts/build-pkgjson-lite.mjs b/scripts/build-pkgjson-lite.mjs index ec433df6c4..d866ff2604 100644 --- a/scripts/build-pkgjson-lite.mjs +++ b/scripts/build-pkgjson-lite.mjs @@ -16,9 +16,10 @@ import fs from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { depseekSync } from 'depseek' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const source = 'package.json' const dest = 'package-lite.json' diff --git a/scripts/build-pkgjson-main.mjs b/scripts/build-pkgjson-main.mjs index 4786832b55..645507e3d4 100644 --- a/scripts/build-pkgjson-main.mjs +++ b/scripts/build-pkgjson-main.mjs @@ -16,8 +16,9 @@ import fs from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const source = 'package.json' const dest = 'package-main.json' diff --git a/scripts/build-size-limit.mjs b/scripts/build-size-limit.mjs index f1ab224008..c9eaf943b9 100644 --- a/scripts/build-size-limit.mjs +++ b/scripts/build-size-limit.mjs @@ -16,9 +16,10 @@ import fs from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { spawnSync } from 'node:child_process' -const root = path.resolve(new URL(import.meta.url).pathname, '../..') +const root = path.resolve(fileURLToPath(import.meta.url), '../..') const configPath = path.join(root, '.size-limit.json') const original = fs.readFileSync(configPath, 'utf8') const config = JSON.parse(original) diff --git a/scripts/build-tests.mjs b/scripts/build-tests.mjs index 35a773e5f5..a46e536a30 100644 --- a/scripts/build-tests.mjs +++ b/scripts/build-tests.mjs @@ -20,13 +20,15 @@ import * as core from '../build/core.js' import * as cli from '../build/cli.js' import * as index from '../build/index.js' +import { fileURLToPath } from 'node:url' + // prettier-ignore const modules = [ ['core', core], ['cli', cli], ['index', index], ] -const root = path.resolve(new URL(import.meta.url).pathname, '../..') +const root = path.resolve(fileURLToPath(import.meta.url), '../..') const filePath = path.resolve(root, `test/export.test.js`) const copyright = fs.readFileSync( diff --git a/scripts/build-versions.mjs b/scripts/build-versions.mjs index aea3e2a6bf..41e2a954a3 100644 --- a/scripts/build-versions.mjs +++ b/scripts/build-versions.mjs @@ -16,9 +16,10 @@ import fs from 'fs-extra' import path from 'node:path' +import { fileURLToPath } from 'node:url' import minimist from 'minimist' -const root = path.resolve(new URL(import.meta.url).pathname, '../..') +const root = path.resolve(fileURLToPath(import.meta.url), '../..') const copyright = fs.readFileSync( path.resolve(root, 'test/fixtures/copyright.txt'), 'utf8' diff --git a/src/goods.ts b/src/goods.ts index 72fb8e8a27..69cb7749e9 100644 --- a/src/goods.ts +++ b/src/goods.ts @@ -104,15 +104,19 @@ export function sleep(duration: Duration): Promise { }) } -const responseToReadable = (response: Response, rs: Readable) => { +export const responseToReadable = (response: Response, rs: Readable) => { const reader = response.body?.getReader() if (!reader) { rs.push(null) return rs } rs._read = async () => { - const result = await reader.read() - rs.push(result.done ? null : Buffer.from(result.value)) + try { + const result = await reader.read() + rs.push(result.done ? null : Buffer.from(result.value)) + } catch (err) { + rs.destroy(err as Error) + } } return rs } diff --git a/test/deps.test.js b/test/deps.test.js index 1443867114..6ffc23b985 100644 --- a/test/deps.test.js +++ b/test/deps.test.js @@ -14,10 +14,11 @@ import assert from 'node:assert' import { test, describe } from 'node:test' +import { fileURLToPath } from 'node:url' import { $, tmpfile, tmpdir, fs, path } from '../build/index.js' import { installDeps, parseDeps } from '../build/deps.cjs' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const root = path.resolve(__dirname, '..') const cli = path.resolve(root, 'build/cli.js') diff --git a/test/export.test.js b/test/export.test.js index eea5abebe6..66ba76444c 100644 --- a/test/export.test.js +++ b/test/export.test.js @@ -40,7 +40,7 @@ describe('core', () => { assert.equal(typeof core.bus.wrap, 'function', 'core.bus.wrap') assert.equal(typeof core.cd, 'function', 'core.cd') assert.equal(typeof core.chalk, 'function', 'core.chalk') - assert.equal(typeof core.chalk.level, 'number', 'core.chalk.level') + assert.equal(typeof core.chalk.level, 'undefined', 'core.chalk.level') assert.equal(typeof core.defaults, 'object', 'core.defaults') assert.equal(typeof core.defaults.detached, 'boolean', 'core.defaults.detached') assert.equal(typeof core.defaults.env, 'object', 'core.defaults.env') @@ -210,7 +210,7 @@ describe('index', () => { assert.equal(typeof index.bus.wrap, 'function', 'index.bus.wrap') assert.equal(typeof index.cd, 'function', 'index.cd') assert.equal(typeof index.chalk, 'function', 'index.chalk') - assert.equal(typeof index.chalk.level, 'number', 'index.chalk.level') + assert.equal(typeof index.chalk.level, 'undefined', 'index.chalk.level') assert.equal(typeof index.defaults, 'object', 'index.defaults') assert.equal(typeof index.defaults.detached, 'boolean', 'index.defaults.detached') assert.equal(typeof index.defaults.env, 'object', 'index.defaults.env') @@ -242,12 +242,15 @@ describe('index', () => { assert.equal(typeof index.fs, 'object', 'index.fs') assert.equal(typeof index.fs.Dir, 'function', 'index.fs.Dir') assert.equal(typeof index.fs.Dirent, 'function', 'index.fs.Dirent') + assert.equal(typeof index.fs.F_OK, 'number', 'index.fs.F_OK') assert.equal(typeof index.fs.FileReadStream, 'function', 'index.fs.FileReadStream') assert.equal(typeof index.fs.FileWriteStream, 'function', 'index.fs.FileWriteStream') + assert.equal(typeof index.fs.R_OK, 'number', 'index.fs.R_OK') assert.equal(typeof index.fs.ReadStream, 'function', 'index.fs.ReadStream') assert.equal(typeof index.fs.Stats, 'function', 'index.fs.Stats') - assert.equal(typeof index.fs.Utf8Stream, 'function', 'index.fs.Utf8Stream') + assert.equal(typeof index.fs.W_OK, 'number', 'index.fs.W_OK') assert.equal(typeof index.fs.WriteStream, 'function', 'index.fs.WriteStream') + assert.equal(typeof index.fs.X_OK, 'number', 'index.fs.X_OK') assert.equal(typeof index.fs._toUnixTimestamp, 'function', 'index.fs._toUnixTimestamp') assert.equal(typeof index.fs.access, 'function', 'index.fs.access') assert.equal(typeof index.fs.accessSync, 'function', 'index.fs.accessSync') @@ -323,7 +326,6 @@ describe('index', () => { assert.equal(typeof index.fs.mkdirs, 'function', 'index.fs.mkdirs') assert.equal(typeof index.fs.mkdirsSync, 'function', 'index.fs.mkdirsSync') assert.equal(typeof index.fs.mkdtemp, 'function', 'index.fs.mkdtemp') - assert.equal(typeof index.fs.mkdtempDisposableSync, 'function', 'index.fs.mkdtempDisposableSync') assert.equal(typeof index.fs.mkdtempSync, 'function', 'index.fs.mkdtempSync') assert.equal(typeof index.fs.move, 'function', 'index.fs.move') assert.equal(typeof index.fs.moveSync, 'function', 'index.fs.moveSync') diff --git a/test/extra.test.js b/test/extra.test.js index f74d82fe33..d9270b1364 100644 --- a/test/extra.test.js +++ b/test/extra.test.js @@ -14,9 +14,10 @@ import assert from 'node:assert' import { test, describe } from 'node:test' +import { fileURLToPath } from 'node:url' import { globby, fs, path } from '../build/index.js' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) describe('extra', () => { test('every file should have a license', async () => { diff --git a/test/goods.test.ts b/test/goods.test.ts index 87dedae7bf..ca7213857e 100644 --- a/test/goods.test.ts +++ b/test/goods.test.ts @@ -33,11 +33,13 @@ import { tmpdir, tmpfile, versions, + responseToReadable, } from '../src/goods.ts' -import { Writable } from 'node:stream' +import { Writable, Readable } from 'node:stream' import process from 'node:process' +import { fileURLToPath } from 'node:url' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const root = path.resolve(__dirname, '..') describe('goods', () => { @@ -365,6 +367,29 @@ describe('goods', () => { assert(p3.includes('GitHub')) }) + test('responseToReadable handles stream error gracefully', async () => { + const mockResponse = { + body: { + getReader() { + return { + async read() { + throw new Error('Stream error') + }, + } + }, + }, + } as unknown as Response + + const rs = responseToReadable(mockResponse, new Readable()) + await assert.rejects( + new Promise((_, reject) => { + rs.on('error', reject) + rs._read() + }), + /Stream error/ + ) + }) + describe('dotenv', () => { test('parse()', () => { assert.deepEqual(dotenv.parse(''), {}) diff --git a/test/it/build-jsr.test.js b/test/it/build-jsr.test.js index 52bd1d6de9..de9dd8c04e 100644 --- a/test/it/build-jsr.test.js +++ b/test/it/build-jsr.test.js @@ -14,8 +14,9 @@ import { tempdir, $, path, fs } from '../../build/index.js' import { describe, before, after, it } from 'node:test' +import { fileURLToPath } from 'node:url' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '../../') describe('jsr artifact', () => { diff --git a/test/it/build-npm.test.js b/test/it/build-npm.test.js index f80026044f..2f03368508 100644 --- a/test/it/build-npm.test.js +++ b/test/it/build-npm.test.js @@ -14,6 +14,7 @@ import assert from 'node:assert' import { describe, test } from 'node:test' +import { fileURLToPath } from 'node:url' import { $, within, @@ -24,7 +25,7 @@ import { version, } from '../../build/index.js' -const __dirname = new URL('.', import.meta.url).pathname +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const root = path.resolve(__dirname, '../..') const sync = async (from, to, entries) => { for (const entry of entries) From baaffb0742225797a699c8d01fce5c77d6cf98a5 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:41:19 +0530 Subject: [PATCH 02/13] feat(core,goods,util): enhance JSON/JSONL stream helpers, retry options & control char quoter --- .size-limit.json | 10 ++++---- build/cli.js | 0 build/core.cjs | 47 ++++++++++++++++++++++++++++++++++- build/core.d.ts | 6 +++++ build/goods.d.ts | 9 +++++-- build/index.cjs | 42 +++++++++++++++++++++++++------ build/index.js | 2 ++ build/util.cjs | 18 +++++++++++--- build/util.d.ts | 2 +- src/core.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++- src/goods.ts | 57 ++++++++++++++++++++++++++++++++++-------- src/util.ts | 34 +++++++++++++++++++------ test/cli.test.js | 17 +++++++++---- test/core.test.js | 29 ++++++++++++++++++++-- test/export.test.js | 5 ++-- test/goods.test.ts | 26 ++++++++++++++++++++ test/util.test.js | 8 +++++- 17 files changed, 323 insertions(+), 49 deletions(-) mode change 100755 => 100644 build/cli.js diff --git a/.size-limit.json b/.size-limit.json index 84e5efbf88..f5bba23632 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -19,7 +19,7 @@ "README.md", "LICENSE" ], - "limit": "129.46 kB", + "limit": "132 kB", "brotli": false, "gzip": false }, @@ -33,21 +33,21 @@ "build/globals.js", "build/deno.js" ], - "limit": "881.85 kB", + "limit": "885 kB", "brotli": false, "gzip": false }, { "name": "libdefs", "path": "build/*.d.ts", - "limit": "45.68 kB", + "limit": "48 kB", "brotli": false, "gzip": false }, { "name": "vendor", "path": "build/vendor-*.{cjs,d.ts}", - "limit": "835.65 kB", + "limit": "840 kB", "brotli": false, "gzip": false }, @@ -66,7 +66,7 @@ "README.md", "LICENSE" ], - "limit": "944.60 kB", + "limit": "950 kB", "brotli": false, "gzip": false } diff --git a/build/cli.js b/build/cli.js old mode 100755 new mode 100644 diff --git a/build/core.cjs b/build/core.cjs index cd9f1f0706..fca1525c1f 100644 --- a/build/core.cjs +++ b/build/core.cjs @@ -753,6 +753,15 @@ var _ProcessPromise = class _ProcessPromise extends Promise { json() { return this.then((o) => o.json()); } + tryJson() { + return this.then((o) => o.tryJson()); + } + jsonl() { + return this.then((o) => o.jsonl()); + } + tryJsonl() { + return this.then((o) => o.tryJsonl()); + } text(encoding) { return this.then((o) => o.text(encoding)); } @@ -1029,7 +1038,43 @@ var _ProcessOutput = class _ProcessOutput extends Error { return !this._dto.error && this.exitCode === 0; } json() { - return JSON.parse(this.stdall); + try { + return JSON.parse(this.stdall); + } catch (e) { + const preview = this.stdall.length > 200 ? this.stdall.slice(0, 200) + "..." : this.stdall; + const formatted = preview.replace(/\n/g, "\u21B5"); + throw new SyntaxError( + `Failed to parse command output as JSON: ${formatted}` + ); + } + } + tryJson() { + try { + return JSON.parse(this.stdall); + } catch (e) { + return void 0; + } + } + jsonl() { + return this.lines().filter((line) => line.trim().length > 0).map((line, idx) => { + try { + return JSON.parse(line); + } catch (e) { + const preview = line.length > 100 ? line.slice(0, 100) + "..." : line; + throw new SyntaxError( + `Failed to parse JSONL line ${idx + 1}: ${preview}` + ); + } + }); + } + tryJsonl() { + return this.lines().filter((line) => line.trim().length > 0).map((line) => { + try { + return JSON.parse(line); + } catch (e) { + return void 0; + } + }); } buffer() { return import_node_buffer.Buffer.from(this.stdall); diff --git a/build/core.d.ts b/build/core.d.ts index d540fad7a2..2386390576 100644 --- a/build/core.d.ts +++ b/build/core.d.ts @@ -136,6 +136,9 @@ export declare class ProcessPromise extends Promise { get [Symbol.toStringTag](): string; [Symbol.toPrimitive](): string; json(): Promise; + tryJson(): Promise; + jsonl(): Promise; + tryJsonl(): Promise<(T | undefined)[]>; text(encoding?: Encoding): Promise; lines(delimiter?: Options['delimiter']): Promise; buffer(): Promise; @@ -189,6 +192,9 @@ export declare class ProcessOutput extends Error { get [Symbol.toStringTag](): string; get ok(): boolean; json(): T; + tryJson(): T | undefined; + jsonl(): T[]; + tryJsonl(): (T | undefined)[]; buffer(): Buffer; blob(type?: string): Blob; text(encoding?: Encoding): string; diff --git a/build/goods.d.ts b/build/goods.d.ts index d5fd7be828..c3bd7083a0 100644 --- a/build/goods.d.ts +++ b/build/goods.d.ts @@ -16,6 +16,7 @@ export declare const parseArgv: (args?: string[], opts?: ArgvOpts, defs?: Record export declare function updateArgv(args?: string[], opts?: ArgvOpts): void; export declare const argv: minimist.ParsedArgs; export declare function sleep(duration: Duration): Promise; +export declare const responseToReadable: (response: Response, rs: Readable) => Readable; export declare function fetch(url: RequestInfo, init?: RequestInit): Promise & { pipe: { (dest: TemplateStringsArray, ...args: any[]): ProcessPromise; @@ -29,8 +30,12 @@ export declare function question(query?: string, { choices, input, output, }?: { output?: NodeJS.WriteStream; }): Promise; export declare function stdin(stream?: Readable): Promise; +export interface RetryOptions { + delay?: Duration | Generator; + shouldRetry?: (err: unknown, attempt: number) => boolean; +} export declare function retry(count: number, callback: () => T): Promise; -export declare function retry(count: number, duration: Duration | Generator, callback: () => T): Promise; -export declare function expBackoff(max?: Duration, delay?: Duration): Generator; +export declare function retry(count: number, durationOrOptions: Duration | Generator | RetryOptions, callback: () => T): Promise; +export declare function expBackoff(max?: Duration, delay?: Duration, jitter?: boolean | number): Generator; export declare function spinner(callback: () => T): Promise; export declare function spinner(title: string, callback: () => T): Promise; diff --git a/build/index.cjs b/build/index.cjs index 01b6c7afb9..e8aadb218b 100644 --- a/build/index.cjs +++ b/build/index.cjs @@ -29,6 +29,7 @@ __export(index_exports, { parseArgv: () => parseArgv, question: () => question, quiet: () => quiet, + responseToReadable: () => responseToReadable, retry: () => retry, sleep: () => sleep, spinner: () => spinner, @@ -108,8 +109,12 @@ var responseToReadable = (response, rs) => { return rs; } rs._read = () => __async(null, null, function* () { - const result = yield reader.read(); - rs.push(result.done ? null : import_node_buffer.Buffer.from(result.value)); + try { + const result = yield reader.read(); + rs.push(result.done ? null : import_node_buffer.Buffer.from(result.value)); + } catch (err) { + rs.destroy(err); + } }); return rs; }; @@ -191,12 +196,22 @@ function stdin() { } function retry(count, d, cb) { return __async(this, null, function* () { + var _a, _b; if (typeof d === "function") return retry(count, 0, d); if (!cb) throw new import_core.Fail("Callback is required for retry"); + let duration = 0; + let shouldRetry; + if (typeof d === "object" && d !== null && !("next" in d) && !Array.isArray(d)) { + const opts = d; + duration = (_a = opts.delay) != null ? _a : 0; + shouldRetry = opts.shouldRetry; + } else { + duration = d; + } const total = count; - const gen = typeof d === "object" ? d : (function* (d2) { - while (true) yield d2; - })((0, import_util.parseDuration)(d)); + const gen = typeof duration === "object" && "next" in duration ? duration : (function* (dur) { + while (true) yield dur; + })((0, import_util.parseDuration)(duration)); let attempt = 0; let lastErr; while (count-- > 0) { @@ -205,7 +220,10 @@ function retry(count, d, cb) { return yield cb(); } catch (err) { lastErr = err; - const delay = gen.next().value; + if (shouldRetry && !shouldRetry(err, attempt)) { + throw err; + } + const delay = (_b = gen.next().value) != null ? _b : 0; import_core.$.log({ kind: "retry", total, @@ -222,12 +240,19 @@ function retry(count, d, cb) { throw lastErr; }); } -function* expBackoff(max = "60s", delay = "100ms") { +function* expBackoff(max = "60s", delay = "100ms", jitter = false) { const maxMs = (0, import_util.parseDuration)(max); const randMs = (0, import_util.parseDuration)(delay); + const jitterFactor = typeof jitter === "number" ? Math.min(Math.max(jitter, 0), 1) : jitter ? 0.2 : 0; let n = 0; while (true) { - yield Math.min(randMs * __pow(2, n++), maxMs); + const base = Math.min(randMs * __pow(2, n++), maxMs); + if (jitterFactor > 0) { + const delta = base * jitterFactor * (Math.random() * 2 - 1); + yield Math.max(0, Math.min(Math.round(base + delta), maxMs)); + } else { + yield base; + } } } function spinner(title, callback) { @@ -281,6 +306,7 @@ function quiet(promise) { parseArgv, question, quiet, + responseToReadable, retry, sleep, spinner, diff --git a/build/index.js b/build/index.js index 7d19c66ae2..e67ad56b1c 100644 --- a/build/index.js +++ b/build/index.js @@ -18,6 +18,7 @@ const { parseArgv, question, quiet, + responseToReadable, retry, sleep, spinner, @@ -69,6 +70,7 @@ export { parseArgv, question, quiet, + responseToReadable, retry, sleep, spinner, diff --git a/build/util.cjs b/build/util.cjs index 1df79370d1..3791ed1309 100644 --- a/build/util.cjs +++ b/build/util.cjs @@ -64,7 +64,7 @@ function preferLocalBin(env, ...dirs) { function quote(arg) { if (arg === "") return `$''`; if (/^[\w/.\-+@:=,%]+$/.test(arg)) return arg; - return `$'` + arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\f/g, "\\f").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/\v/g, "\\v").replace(/\0/g, "\\0") + `'`; + return `$'` + arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\f/g, "\\f").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/\v/g, "\\v").replace(/\0/g, "\\0").replace(/\x07/g, "\\a").replace(/\x08/g, "\\b").replace(/\x1b/g, "\\e") + `'`; } function quotePowerShell(arg) { if (arg === "") return `''`; @@ -72,13 +72,23 @@ function quotePowerShell(arg) { return `'` + arg.replace(/'/g, "''") + `'`; } function parseDuration(d) { + var _a; if (typeof d === "number") { if (isNaN(d) || d < 0) throw new Error(`Invalid duration: "${d}".`); return d; } - const [m, v, u] = d.match(/^(\d+)(m?s?)$/) || []; - if (!m) throw new Error(`Unknown duration: "${d}".`); - return +v * ({ s: 1e3, ms: 1, m: 6e4 }[u] || 1); + const match = String(d).match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/); + if (!match) throw new Error(`Unknown duration: "${d}".`); + const val = parseFloat(match[1]); + const unit = match[2] || "ms"; + const multipliers = { + ms: 1, + s: 1e3, + m: 6e4, + h: 36e5, + d: 864e5 + }; + return val * ((_a = multipliers[unit]) != null ? _a : 1); } var once = (fn) => { let called = false; diff --git a/build/util.d.ts b/build/util.d.ts index 0cbcdf197a..f0f5703e6f 100644 --- a/build/util.d.ts +++ b/build/util.d.ts @@ -16,7 +16,7 @@ export declare function preferLocalBin(env: NodeJS.ProcessEnv, ...dirs: (string }; export declare function quote(arg: string): string; export declare function quotePowerShell(arg: string): string; -export type Duration = number | `${number}` | `${number}m` | `${number}s` | `${number}ms`; +export type Duration = number | `${number}` | `${number}d` | `${number}h` | `${number}m` | `${number}s` | `${number}ms`; export declare function parseDuration(d: Duration): number; export declare const once: any>(fn: T) => (...args: Parameters) => ReturnType; export declare const proxyOverride: (origin: T, ...fallbacks: any) => T; diff --git a/src/core.ts b/src/core.ts index 39c320149f..87d0fcc78d 100644 --- a/src/core.ts +++ b/src/core.ts @@ -578,6 +578,18 @@ export class ProcessPromise extends Promise { return this.then((o) => o.json()) } + tryJson(): Promise { + return this.then((o) => o.tryJson()) + } + + jsonl(): Promise { + return this.then((o) => o.jsonl()) + } + + tryJsonl(): Promise<(T | undefined)[]> { + return this.then((o) => o.tryJsonl()) + } + text(encoding?: Encoding): Promise { return this.then((o) => o.text(encoding)) } @@ -934,7 +946,53 @@ export class ProcessOutput extends Error { } json(): T { - return JSON.parse(this.stdall) + try { + return JSON.parse(this.stdall) + } catch { + const preview = + this.stdall.length > 200 + ? this.stdall.slice(0, 200) + '...' + : this.stdall + const formatted = preview.replace(/\n/g, '↵') + throw new SyntaxError( + `Failed to parse command output as JSON: ${formatted}` + ) + } + } + + tryJson(): T | undefined { + try { + return JSON.parse(this.stdall) + } catch { + return undefined + } + } + + jsonl(): T[] { + return this.lines() + .filter((line) => line.trim().length > 0) + .map((line, idx) => { + try { + return JSON.parse(line) + } catch { + const preview = line.length > 100 ? line.slice(0, 100) + '...' : line + throw new SyntaxError( + `Failed to parse JSONL line ${idx + 1}: ${preview}` + ) + } + }) + } + + tryJsonl(): (T | undefined)[] { + return this.lines() + .filter((line) => line.trim().length > 0) + .map((line) => { + try { + return JSON.parse(line) + } catch { + return undefined + } + }) } buffer(): Buffer { diff --git a/src/goods.ts b/src/goods.ts index 69cb7749e9..701e328d8a 100644 --- a/src/goods.ts +++ b/src/goods.ts @@ -206,27 +206,48 @@ export async function stdin(stream: Readable = process.stdin): Promise { return buf } +export interface RetryOptions { + delay?: Duration | Generator + shouldRetry?: (err: unknown, attempt: number) => boolean +} + export async function retry(count: number, callback: () => T): Promise export async function retry( count: number, - duration: Duration | Generator, + durationOrOptions: Duration | Generator | RetryOptions, callback: () => T ): Promise export async function retry( count: number, - d: Duration | Generator | (() => T), + d: Duration | Generator | RetryOptions | (() => T), cb?: () => T ): Promise { if (typeof d === 'function') return retry(count, 0, d) if (!cb) throw new Fail('Callback is required for retry') + let duration: Duration | Generator = 0 + let shouldRetry: ((err: unknown, attempt: number) => boolean) | undefined + + if ( + typeof d === 'object' && + d !== null && + !('next' in d) && + !Array.isArray(d) + ) { + const opts = d as RetryOptions + duration = opts.delay ?? 0 + shouldRetry = opts.shouldRetry + } else { + duration = d as Duration | Generator + } + const total = count const gen = - typeof d === 'object' - ? d - : (function* (d) { - while (true) yield d - })(parseDuration(d)) + typeof duration === 'object' && 'next' in duration + ? duration + : (function* (dur) { + while (true) yield dur + })(parseDuration(duration as Duration)) let attempt = 0 let lastErr: unknown @@ -236,7 +257,10 @@ export async function retry( return await cb() } catch (err) { lastErr = err - const delay = gen.next().value + if (shouldRetry && !shouldRetry(err, attempt)) { + throw err + } + const delay = gen.next().value ?? 0 $.log({ kind: 'retry', @@ -255,13 +279,26 @@ export async function retry( export function* expBackoff( max: Duration = '60s', - delay: Duration = '100ms' + delay: Duration = '100ms', + jitter: boolean | number = false ): Generator { const maxMs = parseDuration(max) const randMs = parseDuration(delay) + const jitterFactor = + typeof jitter === 'number' + ? Math.min(Math.max(jitter, 0), 1) + : jitter + ? 0.2 + : 0 let n = 0 while (true) { - yield Math.min(randMs * 2 ** n++, maxMs) + const base = Math.min(randMs * 2 ** n++, maxMs) + if (jitterFactor > 0) { + const delta = base * jitterFactor * (Math.random() * 2 - 1) + yield Math.max(0, Math.min(Math.round(base + delta), maxMs)) + } else { + yield base + } } } diff --git a/src/util.ts b/src/util.ts index 1a272905ba..4596dc5055 100644 --- a/src/util.ts +++ b/src/util.ts @@ -86,7 +86,10 @@ export function quote(arg: string): string { .replace(/\r/g, '\\r') .replace(/\t/g, '\\t') .replace(/\v/g, '\\v') - .replace(/\0/g, '\\0') + + .replace(/\0/g, '\\0') + .replace(/\x07/g, '\\a') + .replace(/\x08/g, '\\b') + .replace(/\x1b/g, '\\e') + `'` ) } @@ -99,17 +102,34 @@ export function quotePowerShell(arg: string): string { } export type Duration = - number | `${number}` | `${number}m` | `${number}s` | `${number}ms` - -export function parseDuration(d: Duration) { + | number + | `${number}` + | `${number}d` + | `${number}h` + | `${number}m` + | `${number}s` + | `${number}ms` + +export function parseDuration(d: Duration): number { if (typeof d === 'number') { if (isNaN(d) || d < 0) throw new Error(`Invalid duration: "${d}".`) return d } - const [m, v, u] = d.match(/^(\d+)(m?s?)$/) || [] - if (!m) throw new Error(`Unknown duration: "${d}".`) + const match = String(d).match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/) + if (!match) throw new Error(`Unknown duration: "${d}".`) + + const val = parseFloat(match[1]) + const unit = match[2] || 'ms' + + const multipliers: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + } - return +v * ({ s: 1000, ms: 1, m: 60_000 }[u] || 1) + return val * (multipliers[unit] ?? 1) } export const once = any>(fn: T) => { diff --git a/test/cli.test.js b/test/cli.test.js index 848cade891..a9342638df 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -356,7 +356,8 @@ console.log(a); }) test('exceptions are caught', async () => { - const out1 = await $`node build/cli.js <<<${'await $`wtf`'}`.nothrow() + const out1 = + await $`node build/cli.js <<<${'await $`non_existent_cmd_zx`'}`.nothrow() const out2 = await $`node build/cli.js <<<'throw 42'`.nothrow() assert.match(out1.stderr, /Error:/) assert.match(out2.stderr, /42/) @@ -378,21 +379,27 @@ console.log(a); const toPOSIXPath = (_path) => _path.split(path.sep).join(path.posix.sep) const zxPath = path.resolve('./build/cli.js') - const zxLocation = isWindows ? toPOSIXPath(zxPath) : zxPath - const scriptCode = `#!/usr/bin/env ${zxLocation}\nconsole.log('The script from path runs.')` const scriptName = 'script-from-path' - const scriptFile = tmpfile(scriptName, scriptCode, 0o744) + const scriptFile = tmpfile(scriptName, '', 0o744) const scriptDir = path.dirname(scriptFile) + const zxWrapper = path.join(scriptDir, isWindows ? 'zx.cmd' : 'zx') + const zxWrapperCode = isWindows + ? `@node "${zxPath}" %*` + : `#!/bin/sh\nexec node "${zxPath}" "$@"` + await fs.writeFile(zxWrapper, zxWrapperCode, { mode: 0o755 }) + + const scriptCode = `#!/usr/bin/env zx\nconsole.log('The script from path runs.')` + await fs.writeFile(scriptFile, scriptCode, { mode: 0o744 }) const envPathSeparator = isWindows ? ';' : ':' process.env.PATH += envPathSeparator + scriptDir try { - await $`chmod +x ${zxLocation}` await $`${scriptName}` } finally { process.env.PATH = oldPath await fs.rm(scriptFile) + await fs.rm(zxWrapper) } }) diff --git a/test/core.test.js b/test/core.test.js index b80a2eda91..99841e51e1 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -253,14 +253,14 @@ describe('core', () => { test('$ thrown as error', async () => { let err try { - await $`wtf` + await $`non_existent_cmd_zx` } catch (p) { err = p } assert.ok(err.exitCode > 0) assert.match(err.toString(), /command not found/) assert.match(err.valueOf(), /command not found/) - assert.match(err.stderr, /wtf: command not found/) + assert.match(err.stderr, /non_existent_cmd_zx: command not found/) assert.match(err[inspect.custom](), /Command not found/) }) @@ -1413,6 +1413,31 @@ describe('core', () => { test('json()', async () => { assert.deepEqual(await $`echo '{"key":"value"}'`.json(), { key: 'value' }) + const err = await $`echo 'not json'`.nothrow() + assert.throws( + () => err.json(), + /Failed to parse command output as JSON: not json/ + ) + }) + + test('tryJson()', async () => { + assert.deepEqual(await $`echo '{"key":"value"}'`.tryJson(), { + key: 'value', + }) + const err = await $`echo 'not json'`.nothrow() + assert.equal(err.tryJson(), undefined) + }) + + test('jsonl()', async () => { + const res = await $`echo '{"a":1}\n{"b":2}'`.jsonl() + assert.deepEqual(res, [{ a: 1 }, { b: 2 }]) + const err = await $`echo '{"a":1}\ninvalid'`.nothrow() + assert.throws(() => err.jsonl(), /Failed to parse JSONL line 2: invalid/) + }) + + test('tryJsonl()', async () => { + const res = await $`echo '{"a":1}\ninvalid\n{"b":2}'`.tryJsonl() + assert.deepEqual(res, [{ a: 1 }, undefined, { b: 2 }]) }) test('text()', async () => { diff --git a/test/export.test.js b/test/export.test.js index 66ba76444c..7f10734573 100644 --- a/test/export.test.js +++ b/test/export.test.js @@ -40,7 +40,7 @@ describe('core', () => { assert.equal(typeof core.bus.wrap, 'function', 'core.bus.wrap') assert.equal(typeof core.cd, 'function', 'core.cd') assert.equal(typeof core.chalk, 'function', 'core.chalk') - assert.equal(typeof core.chalk.level, 'undefined', 'core.chalk.level') + assert.equal(typeof core.chalk.level, 'number', 'core.chalk.level') assert.equal(typeof core.defaults, 'object', 'core.defaults') assert.equal(typeof core.defaults.detached, 'boolean', 'core.defaults.detached') assert.equal(typeof core.defaults.env, 'object', 'core.defaults.env') @@ -210,7 +210,7 @@ describe('index', () => { assert.equal(typeof index.bus.wrap, 'function', 'index.bus.wrap') assert.equal(typeof index.cd, 'function', 'index.cd') assert.equal(typeof index.chalk, 'function', 'index.chalk') - assert.equal(typeof index.chalk.level, 'undefined', 'index.chalk.level') + assert.equal(typeof index.chalk.level, 'number', 'index.chalk.level') assert.equal(typeof index.defaults, 'object', 'index.defaults') assert.equal(typeof index.defaults.detached, 'boolean', 'index.defaults.detached') assert.equal(typeof index.defaults.env, 'object', 'index.defaults.env') @@ -473,6 +473,7 @@ describe('index', () => { assert.equal(typeof index.quote, 'function', 'index.quote') assert.equal(typeof index.quotePowerShell, 'function', 'index.quotePowerShell') assert.equal(typeof index.resolveDefaults, 'function', 'index.resolveDefaults') + assert.equal(typeof index.responseToReadable, 'function', 'index.responseToReadable') assert.equal(typeof index.retry, 'function', 'index.retry') assert.equal(typeof index.sleep, 'function', 'index.sleep') assert.equal(typeof index.spinner, 'function', 'index.spinner') diff --git a/test/goods.test.ts b/test/goods.test.ts index ca7213857e..590b34d6e2 100644 --- a/test/goods.test.ts +++ b/test/goods.test.ts @@ -172,6 +172,32 @@ describe('goods', () => { assert.equal(result, 'success') }) + test('supports expBackoff with jitter', () => { + const gen = expBackoff('100ms', '10ms', true) + const val1 = gen.next().value as number + assert.ok(val1 >= 0 && val1 <= 20) + }) + + test('supports RetryOptions with shouldRetry predicate', async () => { + let attempts = 0 + try { + await retry( + 5, + { + delay: '2ms', + shouldRetry: (err: any) => err.message !== 'fatal', + }, + () => { + attempts++ + throw new Error('fatal') + } + ) + } catch (err: any) { + assert.equal(err.message, 'fatal') + assert.equal(attempts, 1) + } + }) + test('integration', async () => { const now = Date.now() const p = await zx(` diff --git a/test/util.test.js b/test/util.test.js index fba510fcab..650e203016 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -70,7 +70,9 @@ describe('util', () => { test('quote()', () => { assert.ok(quote('string') === 'string') assert.ok(quote('') === `$''`) - assert.ok(quote(`'\f\n\r\t\v\0`) === `$'\\'\\f\\n\\r\\t\\v\\0'`) + assert.ok( + quote(`'\f\n\r\t\v\0\x07\x08\x1b`) === `$'\\'\\f\\n\\r\\t\\v\\0\\a\\b\\e'` + ) const allowed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_/.-+@:=,%' @@ -92,8 +94,12 @@ describe('util', () => { assert.equal(parseDuration(1000), 1000) assert.equal(parseDuration('100'), 100) assert.equal(parseDuration('2s'), 2000) + assert.equal(parseDuration('1.5s'), 1500) + assert.equal(parseDuration('0.5s'), 500) assert.equal(parseDuration('500ms'), 500) assert.equal(parseDuration('2m'), 120000) + assert.equal(parseDuration('1h'), 3600000) + assert.equal(parseDuration('1d'), 86400000) assert.throws(() => parseDuration('f2ms')) assert.throws(() => parseDuration('2mss')) assert.throws(() => parseDuration(NaN)) From defed1edd2603d71459651853c0a32f21679844b Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:43:39 +0530 Subject: [PATCH 03/13] fix(log): preserve escaped quotes in formatCmd syntax highlighting --- build/core.cjs | 5 ++++- src/log.ts | 4 +++- test/log.test.ts | 6 +++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/build/core.cjs b/build/core.cjs index fca1525c1f..8bcabca9b7 100644 --- a/build/core.cjs +++ b/build/core.cjs @@ -321,6 +321,7 @@ var RESERVED_WORDS = /* @__PURE__ */ new Set([ "EOF" ]); function formatCmd(cmd) { + var _a; if (cmd == void 0) return import_vendor_core.chalk.grey("undefined"); let q = ""; let out = "$ "; @@ -380,7 +381,9 @@ function formatCmd(cmd) { } } else { buf += c; - if (c === q) { + const backslashes = ((_a = buf.slice(0, -1).match(/\\+$/)) == null ? void 0 : _a[0].length) || 0; + const isEscaped = backslashes % 2 === 1; + if (c === q && !isEscaped) { cap(); q = ""; } diff --git a/src/log.ts b/src/log.ts index cb61d32175..79dfe14db1 100644 --- a/src/log.ts +++ b/src/log.ts @@ -211,7 +211,9 @@ export function formatCmd(cmd: string): string { } } else { buf += c - if (c === q) { + const backslashes = buf.slice(0, -1).match(/\\+$/)?.[0].length || 0 + const isEscaped = backslashes % 2 === 1 + if (c === q && !isEscaped) { cap() q = '' } diff --git a/test/log.test.ts b/test/log.test.ts index 7e1631a845..9595865efe 100644 --- a/test/log.test.ts +++ b/test/log.test.ts @@ -182,7 +182,11 @@ describe('log', () => { `echo '\n str\n'`, "$ \x1B[92mecho\x1B[39m \x1B[93m'\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m str\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m'\x1B[39m\n", ], - [`$'\\''`, "$ \x1B[93m$\x1B[39m\x1B[93m'\\'\x1B[39m\x1B[93m'\x1B[39m\n"], + [`$'\\''`, "$ \x1B[93m$\x1B[39m\x1B[93m'\\''\x1B[39m\n"], + [ + `echo "hello \\"world\\""`, + '$ \x1B[92mecho\x1B[39m \x1B[93m"hello \\"world\\""\x1B[39m\n', + ], [ 'sass-compiler --style=compressed src/static/bootstrap.scss > dist/static/bootstrap-v5.3.3.min.css', '$ \x1B[92msass-compiler\x1B[39m --style\x1B[31m=\x1B[39mcompressed src/static/bootstrap.scss \x1B[31m>\x1B[39m\x1B[92m dist/static/bootstrap-v5.3.3.min.css\x1B[39m\n', From 0c688224479c578449aba8346fe7f15c82f080f4 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:46:43 +0530 Subject: [PATCH 04/13] feat(util): enhance toCamelCase to support numeric suffixes and leading delimiters --- build/util.cjs | 2 +- src/util.ts | 3 ++- test/util.test.js | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/build/util.cjs b/build/util.cjs index 3791ed1309..a7a2b40196 100644 --- a/build/util.cjs +++ b/build/util.cjs @@ -101,7 +101,7 @@ var proxyOverride = (origin, ...fallbacks) => new Proxy(origin, { return (_b = (_a = fallbacks.find((f) => key in f)) == null ? void 0 : _a[key]) != null ? _b : Reflect.get(target, key); } }); -var toCamelCase = (str) => str.toLowerCase().replace(/([a-z])[_-]+([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase()); +var toCamelCase = (str) => str.toLowerCase().replace(/^[_-]+/, "").replace(/([a-z0-9])[_-]+([a-z0-9])/g, (_, p1, p2) => p1 + p2.toUpperCase()); var parseBool = (v) => v === "true" || v !== "false" && v; var getLines = (chunk, next, delimiter) => { const lines = ((next.pop() || "") + bufToString(chunk)).split(delimiter); diff --git a/src/util.ts b/src/util.ts index 4596dc5055..17af46ea68 100644 --- a/src/util.ts +++ b/src/util.ts @@ -155,7 +155,8 @@ export const proxyOverride = ( export const toCamelCase = (str: string) => str .toLowerCase() - .replace(/([a-z])[_-]+([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase()) + .replace(/^[_-]+/, '') + .replace(/([a-z0-9])[_-]+([a-z0-9])/g, (_, p1, p2) => p1 + p2.toUpperCase()) export const parseBool = (v: string): boolean | string => v === 'true' || (v !== 'false' && v) diff --git a/test/util.test.js b/test/util.test.js index 650e203016..37e18f61eb 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -129,6 +129,8 @@ describe('util', () => { assert.equal(toCamelCase('PREFER_LOCAL'), 'preferLocal') assert.equal(toCamelCase('SOME_MORE_BIG_STR'), 'someMoreBigStr') assert.equal(toCamelCase('kebab-input-str'), 'kebabInputStr') + assert.equal(toCamelCase('HTTP_PORT_8080'), 'httpPort8080') + assert.equal(toCamelCase('--foo-bar-123'), 'fooBar123') }) test('parseBool()', () => { From 928fa193030eca3dee876e88b125f55a2b0e6544 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:05:27 +0530 Subject: [PATCH 05/13] fix(goods): support piping fetch() directly to standard Writable streams --- build/index.cjs | 11 +++++++---- src/goods.ts | 14 ++++++++++++-- test/goods.test.ts | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/build/index.cjs b/build/index.cjs index e8aadb218b..50df51fa31 100644 --- a/build/index.cjs +++ b/build/index.cjs @@ -130,12 +130,15 @@ function fetch(url, init) { })(dest, ...args) : dest; p.then( (r) => { - var _a; - return responseToReadable(r, rs).pipe((_a = _dest.run) == null ? void 0 : _a.call(_dest)); + const destStream = typeof (_dest == null ? void 0 : _dest.run) === "function" ? _dest.run() : _dest; + return responseToReadable(r, rs).pipe(destStream); }, (err) => { - var _a; - return (_a = _dest.abort) == null ? void 0 : _a.call(_dest, err); + if (typeof (_dest == null ? void 0 : _dest.abort) === "function") { + _dest.abort(err); + } else if (typeof (_dest == null ? void 0 : _dest.destroy) === "function") { + _dest.destroy(err); + } } ); return _dest; diff --git a/src/goods.ts b/src/goods.ts index 701e328d8a..ab55c066c0 100644 --- a/src/goods.ts +++ b/src/goods.ts @@ -143,8 +143,18 @@ export function fetch( })(dest as TemplateStringsArray, ...args) : dest p.then( - (r) => responseToReadable(r, rs).pipe(_dest.run?.()), - (err) => _dest.abort?.(err) + (r) => { + const destStream = + typeof _dest?.run === 'function' ? _dest.run() : _dest + return responseToReadable(r, rs).pipe(destStream) + }, + (err) => { + if (typeof _dest?.abort === 'function') { + _dest.abort(err) + } else if (typeof _dest?.destroy === 'function') { + _dest.destroy(err) + } + } ) return _dest }, diff --git a/test/goods.test.ts b/test/goods.test.ts index 590b34d6e2..f2b606a24d 100644 --- a/test/goods.test.ts +++ b/test/goods.test.ts @@ -393,6 +393,21 @@ describe('goods', () => { assert(p3.includes('GitHub')) }) + test('fetch().pipe to Writable stream', async () => { + let data = '' + const dest = new Writable({ + write(chunk, _enc, cb) { + data += chunk.toString() + cb() + }, + }) + const req = fetch('https://github.com/') + req.pipe(dest) + await req + await new Promise((resolve) => dest.on('finish', resolve)) + assert(data.length > 0) + }) + test('responseToReadable handles stream error gracefully', async () => { const mockResponse = { body: { From 55bb328175836501ce0859a28d4038cb1367095f Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:06:31 +0530 Subject: [PATCH 06/13] fix(cli): detach process exit listener in runScript finally block --- build/cli.cjs | 1 + src/cli.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/build/cli.cjs b/build/cli.cjs index e494590738..a2e93a2b9e 100755 --- a/build/cli.cjs +++ b/build/cli.cjs @@ -250,6 +250,7 @@ function runScript(script, scriptPath, tempPath) { import_node_process2.default.once("exit", rmTemp); yield import(import_node_url.default.pathToFileURL(scriptPath).toString()); } finally { + import_node_process2.default.off("exit", rmTemp); rmTemp(); } }); diff --git a/src/cli.ts b/src/cli.ts index df1ea719f0..a0a0e7ddb5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -169,6 +169,7 @@ async function runScript( // TODO: fix unanalyzable-dynamic-import to work correctly with jsr.io await import(url.pathToFileURL(scriptPath).toString()) } finally { + process.off('exit', rmTemp) rmTemp() } } From 3441eab73c3f72725323154d05286a2bf702b62f Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:53:18 +0530 Subject: [PATCH 07/13] fix(goods): clear full spinner line width in non-TTY environments --- src/goods.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/goods.ts b/src/goods.ts index ab55c066c0..e743aed0a6 100644 --- a/src/goods.ts +++ b/src/goods.ts @@ -332,7 +332,11 @@ export async function spinner( return await callback!() } finally { clearInterval(id as ReturnType) - stream.write(' '.repeat((process.stdout.columns || 1) - 1) + '\r') + const cleanLen = Math.max( + process.stdout.columns || 80, + (title ? String(title).length : 0) + 10 + ) + stream.write(' '.repeat(cleanLen) + '\r') } }) } From 7d62ad6a6d15a43f1474eac7c675e37db606a949 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:01:00 +0530 Subject: [PATCH 08/13] feat(goods): pass attempt number and last error to retry() callback --- src/goods.ts | 17 ++++++++++++----- test/goods.test.ts | 20 ++++++++++++++++++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/goods.ts b/src/goods.ts index e743aed0a6..f2f4e0fa35 100644 --- a/src/goods.ts +++ b/src/goods.ts @@ -221,16 +221,23 @@ export interface RetryOptions { shouldRetry?: (err: unknown, attempt: number) => boolean } -export async function retry(count: number, callback: () => T): Promise +export async function retry( + count: number, + callback: (attempt: number, lastErr?: unknown) => T +): Promise export async function retry( count: number, durationOrOptions: Duration | Generator | RetryOptions, - callback: () => T + callback: (attempt: number, lastErr?: unknown) => T ): Promise export async function retry( count: number, - d: Duration | Generator | RetryOptions | (() => T), - cb?: () => T + d: + | Duration + | Generator + | RetryOptions + | ((attempt: number, lastErr?: unknown) => T), + cb?: (attempt: number, lastErr?: unknown) => T ): Promise { if (typeof d === 'function') return retry(count, 0, d) if (!cb) throw new Fail('Callback is required for retry') @@ -264,7 +271,7 @@ export async function retry( while (count-- > 0) { attempt++ try { - return await cb() + return await cb(attempt, lastErr) } catch (err) { lastErr = err if (shouldRetry && !shouldRetry(err, attempt)) { diff --git a/test/goods.test.ts b/test/goods.test.ts index f2b606a24d..876921984c 100644 --- a/test/goods.test.ts +++ b/test/goods.test.ts @@ -131,6 +131,22 @@ describe('goods', () => { assert.equal(count, 5) }) + test('passes attempt number and lastErr to callback', async () => { + const attempts: number[] = [] + const errors: (unknown | undefined)[] = [] + const result = await retry(3, '2ms', (attempt, lastErr) => { + attempts.push(attempt) + errors.push(lastErr) + if (attempt < 3) throw new Error(`fail-${attempt}`) + return 'done' + }) + assert.equal(result, 'done') + assert.deepEqual(attempts, [1, 2, 3]) + assert.equal(errors[0], undefined) + assert.match((errors[1] as Error).message, /fail-1/) + assert.match((errors[2] as Error).message, /fail-2/) + }) + test('works with custom delay and limit', async () => { const now = Date.now() let count = 0 @@ -218,7 +234,7 @@ describe('goods', () => { const now = Date.now() const p = await zx(` try { - await retry(5, expBackoff('60s', 0), () => $\`exit 123\`) + await retry(5, expBackoff('60s', '1ms'), () => $\`exit 123\`) } catch (e) { echo('exitCode:', e.exitCode) } @@ -226,7 +242,7 @@ describe('goods', () => { `) assert.ok(p.toString().includes('exitCode: 123')) assert.ok(p.toString().includes('success')) - assert.ok(Date.now() >= now + 2 + 4 + 8 + 16 + 32) + assert.ok(Date.now() >= now + 1 + 2 + 4 + 8 + 16) }) }) From 0dfe5b34a627f522b81f34e4f00d92c78a2b5cf4 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:03:12 +0530 Subject: [PATCH 09/13] fix(error): use first call-site frame when Proxy.set is absent in stack --- src/error.ts | 4 +++- test/error.test.ts | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/error.ts b/src/error.ts index 2425c4e779..c9e2296b2d 100644 --- a/src/error.ts +++ b/src/error.ts @@ -223,7 +223,9 @@ export class Fail extends Error { .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 + // When Proxy.set is not found, skip the error header (e.g. "Error: msg") + // and use the first actual frame line instead. + const offset = i < 0 ? lines.findIndex((l) => /:\d+/.test(l)) : i + 2 return ( lines.find((l) => l.includes('file://')) || diff --git a/test/error.test.ts b/test/error.test.ts index 50e6ed8e8a..e5dd926e41 100644 --- a/test/error.test.ts +++ b/test/error.test.ts @@ -38,10 +38,7 @@ describe('error', () => { }) test('getCallerLocation()', () => { - assert.match( - getCallerLocation(new Error('Foo')), - /TestContext\./ - ) + assert.match(getCallerLocation(new Error('Foo')), /error\.test\.ts:\d+:\d+/) }) describe('getCallerLocationFromString()', () => { @@ -56,6 +53,16 @@ describe('error', () => { ) }) + test('no Proxy.set falls back to first frame', () => { + const stack = ` + Error + at foo (/Users/user/test.js:5:10) + at bar (/Users/user/test.js:10:3) + at Object. (/Users/user/test.js:15:1) + ` + assert.match(getCallerLocationFromString(stack), /^.*:5:10.*$/) + }) + test(`getCallerLocationFromString-v8`, () => { const stack = ` Error From 2820c0ef0576c9bcca292846e1be66ae84148f38 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:04:31 +0530 Subject: [PATCH 10/13] feat(log): include error message in retry log output --- src/log.ts | 6 +++++- test/log.test.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/log.ts b/src/log.ts index 79dfe14db1..45ec8cf437 100644 --- a/src/log.ts +++ b/src/log.ts @@ -101,8 +101,12 @@ const formatters: LogFormatters = { retry(entry) { const attempt = `Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}` const delay = entry.delay > 0 ? `; next in ${entry.delay}ms` : '' + const reason = + entry.exception instanceof Error + ? ` — ${entry.exception.message.split('\n')[0]}` + : '' - return `${chalk.bgRed.white(' FAIL ')} ${attempt}${delay}\n` + return `${chalk.bgRed.white(' FAIL ')} ${attempt}${delay}${reason}\n` }, end() { return '' diff --git a/test/log.test.ts b/test/log.test.ts index 9595865efe..1c4685baab 100644 --- a/test/log.test.ts +++ b/test/log.test.ts @@ -109,7 +109,22 @@ describe('log', () => { }) assert.equal( data.join(''), - '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 1/3; next in 1000ms\n' + '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 1/3; next in 1000ms — foo\n' + ) + }) + + test('retry with non-Error exception omits reason', () => { + log({ + kind: 'retry', + attempt: 2, + total: 5, + delay: 0, + exception: 'string error', + verbose: true, + }) + assert.equal( + data.join(''), + '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 2/5\n' ) }) From 394b0284b0b686fff750cd3d05c4082b09997043 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:18:17 +0530 Subject: [PATCH 11/13] feat(util): support case-insensitive units and optional spaces in parseDuration --- src/util.ts | 6 ++++-- test/util.test.js | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/util.ts b/src/util.ts index 17af46ea68..9a630f9d91 100644 --- a/src/util.ts +++ b/src/util.ts @@ -115,11 +115,13 @@ export function parseDuration(d: Duration): number { if (isNaN(d) || d < 0) throw new Error(`Invalid duration: "${d}".`) return d } - const match = String(d).match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/) + const match = String(d) + .trim() + .match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i) if (!match) throw new Error(`Unknown duration: "${d}".`) const val = parseFloat(match[1]) - const unit = match[2] || 'ms' + const unit = (match[2] || 'ms').toLowerCase() const multipliers: Record = { ms: 1, diff --git a/test/util.test.js b/test/util.test.js index 37e18f61eb..8980dd3fc2 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -100,6 +100,9 @@ describe('util', () => { assert.equal(parseDuration('2m'), 120000) assert.equal(parseDuration('1h'), 3600000) assert.equal(parseDuration('1d'), 86400000) + assert.equal(parseDuration('1.5 s'), 1500) + assert.equal(parseDuration('500 MS'), 500) + assert.equal(parseDuration('2 H'), 7200000) assert.throws(() => parseDuration('f2ms')) assert.throws(() => parseDuration('2mss')) assert.throws(() => parseDuration(NaN)) From 915960a5e067034762644cd4e89c417ac3ca64a8 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:24:31 +0530 Subject: [PATCH 12/13] fix(md): require 4 spaces or tab for indented code blocks (resolves #1388) --- src/md.ts | 8 ++++---- test/md.test.ts | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/md.ts b/src/md.ts index 2b6932e8ad..9e159244cd 100644 --- a/src/md.ts +++ b/src/md.ts @@ -17,7 +17,7 @@ import { bufToString } from './util.ts' export function transformMarkdown(buf: Buffer | string): string { const out: string[] = [] - const tabRe = /^( +|\t)/ + const tabRe = /^( {4,}|\t)/ const fenceRe = /^(? {0,3})(?(`{3,20}|~{3,20}))(?:(?js|javascript|ts|typescript)|(?sh|shell|bash)|.*)$/ @@ -66,19 +66,19 @@ export function transformMarkdown(buf: Buffer | string): string { continue } - prevEmpty = line === '' + prevEmpty = line.trim() === '' out.push('// ' + line) continue } case 'tab': - if (line === '') out.push('') + if (line.trim() === '') out.push('') else if (tabRe.test(line)) out.push(line) else { out.push('// ' + line) state = 'root' } - prevEmpty = line === '' + prevEmpty = line.trim() === '' break case 'fence': diff --git a/test/md.test.ts b/test/md.test.ts index d37b9c9f6e..7e4e41f2a4 100644 --- a/test/md.test.ts +++ b/test/md.test.ts @@ -22,8 +22,18 @@ describe('transformMarkdown()', () => { assert.equal(transformMarkdown('\n'), '// \n// ') }) - test('preserves tab-indented blocks after a blank line (legacy behavior)', () => { - assert.equal(transformMarkdown(' \n '), ' \n ') + test('comments out 2-3 space indented text and list continuations', () => { + assert.equal( + transformMarkdown('\n Two spaces text\n Three spaces text'), + '// \n// Two spaces text\n// Three spaces text' + ) + }) + + test('preserves 4-space and tab-indented blocks after a blank line', () => { + assert.equal( + transformMarkdown('\n code\n\tcode'), + '// \n code\n\tcode' + ) }) test('does not treat a mid-paragraph fence as a fenced block (legacy behavior)', () => { From 2db5927fb1c8cc6b71f1b56e4ad8dab5ddab2fd1 Mon Sep 17 00:00:00 2001 From: Swaraj Jakanoor <169044732+swaraj792725@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:35:22 +0530 Subject: [PATCH 13/13] fix(goods): fallback stream/stderr columns in spinner cleanup --- build/cli.cjs | 8 ++++---- build/core.cjs | 5 +++-- build/goods.d.ts | 4 ++-- build/index.cjs | 7 +++++-- build/util.cjs | 4 ++-- src/goods.ts | 7 +++---- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/build/cli.cjs b/build/cli.cjs index a2e93a2b9e..533b232809 100755 --- a/build/cli.cjs +++ b/build/cli.cjs @@ -63,7 +63,7 @@ var import_util = require("./util.cjs"); function transformMarkdown(buf) { var _a2; const out = []; - const tabRe = /^( +|\t)/; + const tabRe = /^( {4,}|\t)/; const fenceRe = new RegExp("^(? {0,3})(?(`{3,20}|~{3,20}))(?:(?js|javascript|ts|typescript)|(?sh|shell|bash)|.*)$"); let state = "root"; let prevEmpty = true; @@ -103,18 +103,18 @@ function transformMarkdown(buf) { state = "tab"; continue; } - prevEmpty = line === ""; + prevEmpty = line.trim() === ""; out.push("// " + line); continue; } case "tab": - if (line === "") out.push(""); + if (line.trim() === "") out.push(""); else if (tabRe.test(line)) out.push(line); else { out.push("// " + line); state = "root"; } - prevEmpty = line === ""; + prevEmpty = line.trim() === ""; break; case "fence": if (isEnd(line)) { diff --git a/build/core.cjs b/build/core.cjs index 8bcabca9b7..17833141cc 100644 --- a/build/core.cjs +++ b/build/core.cjs @@ -238,7 +238,7 @@ ${details}`; 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; + const offset = i < 0 ? lines.findIndex((l) => /:\d+/.test(l)) : i + 2; return (lines.find((l) => l.includes("file://")) || lines[offset] || stackString).trim(); } static getCallerLocation(err = new Error("zx error")) { @@ -282,7 +282,8 @@ var formatters = { retry(entry) { const attempt = `Attempt: ${entry.attempt}${entry.total == Infinity ? "" : `/${entry.total}`}`; const delay = entry.delay > 0 ? `; next in ${entry.delay}ms` : ""; - return `${import_vendor_core.chalk.bgRed.white(" FAIL ")} ${attempt}${delay} + const reason = entry.exception instanceof Error ? ` \u2014 ${entry.exception.message.split("\n")[0]}` : ""; + return `${import_vendor_core.chalk.bgRed.white(" FAIL ")} ${attempt}${delay}${reason} `; }, end() { diff --git a/build/goods.d.ts b/build/goods.d.ts index c3bd7083a0..57a3f0a941 100644 --- a/build/goods.d.ts +++ b/build/goods.d.ts @@ -34,8 +34,8 @@ export interface RetryOptions { delay?: Duration | Generator; shouldRetry?: (err: unknown, attempt: number) => boolean; } -export declare function retry(count: number, callback: () => T): Promise; -export declare function retry(count: number, durationOrOptions: Duration | Generator | RetryOptions, callback: () => T): Promise; +export declare function retry(count: number, callback: (attempt: number, lastErr?: unknown) => T): Promise; +export declare function retry(count: number, durationOrOptions: Duration | Generator | RetryOptions, callback: (attempt: number, lastErr?: unknown) => T): Promise; export declare function expBackoff(max?: Duration, delay?: Duration, jitter?: boolean | number): Generator; export declare function spinner(callback: () => T): Promise; export declare function spinner(title: string, callback: () => T): Promise; diff --git a/build/index.cjs b/build/index.cjs index 50df51fa31..9fc54775ba 100644 --- a/build/index.cjs +++ b/build/index.cjs @@ -220,7 +220,7 @@ function retry(count, d, cb) { while (count-- > 0) { attempt++; try { - return yield cb(); + return yield cb(attempt, lastErr); } catch (err) { lastErr = err; if (shouldRetry && !shouldRetry(err, attempt)) { @@ -272,7 +272,10 @@ function spinner(title, callback) { return yield callback(); } finally { clearInterval(id); - stream.write(" ".repeat((import_node_process.default.stdout.columns || 1) - 1) + "\r"); + const c = stream.columns || import_node_process.default.stderr.columns || 80; + stream.write( + " ".repeat(Math.max(c, (title ? String(title).length : 0) + 10)) + "\r" + ); } })); }); diff --git a/build/util.cjs b/build/util.cjs index a7a2b40196..2da3d7ae67 100644 --- a/build/util.cjs +++ b/build/util.cjs @@ -77,10 +77,10 @@ function parseDuration(d) { if (isNaN(d) || d < 0) throw new Error(`Invalid duration: "${d}".`); return d; } - const match = String(d).match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/); + const match = String(d).trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i); if (!match) throw new Error(`Unknown duration: "${d}".`); const val = parseFloat(match[1]); - const unit = match[2] || "ms"; + const unit = (match[2] || "ms").toLowerCase(); const multipliers = { ms: 1, s: 1e3, diff --git a/src/goods.ts b/src/goods.ts index f2f4e0fa35..a0785d95a8 100644 --- a/src/goods.ts +++ b/src/goods.ts @@ -339,11 +339,10 @@ export async function spinner( return await callback!() } finally { clearInterval(id as ReturnType) - const cleanLen = Math.max( - process.stdout.columns || 80, - (title ? String(title).length : 0) + 10 + const c = (stream as any).columns || process.stderr.columns || 80 + stream.write( + ' '.repeat(Math.max(c, (title ? String(title).length : 0) + 10)) + '\r' ) - stream.write(' '.repeat(cleanLen) + '\r') } }) }