dollar-shell 1.1.14 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -219,6 +219,40 @@ the spawn options with the following properties:
219
219
 
220
220
  The rest is identical to `$`: `$sh`, `$sh.from`, `$sh.to` and `$sh.io`/`$sh.through`.
221
221
 
222
+ ## Forcing the Node backend
223
+
224
+ Each runtime uses its own backend by default (`node:child_process` on Node, `Bun.spawn` on Bun,
225
+ `Deno.Command` on Deno). Set the **`DSH_FORCE_NODE` environment variable** (e.g. `DSH_FORCE_NODE=1`) to
226
+ make every runtime spawn through the Node backend — Bun and Deno then run `node:child_process` on
227
+ their compatibility layer. This swaps **only the spawn mechanism**: the runtime that runs your code and how
228
+ it's re-launched stay native, so a forced child of Bun/Deno is still `bun run …` / `deno run …`, never a
229
+ bare `node`. Handy for sidestepping runtime-specific quirks (e.g. Bun intermittently dropping the last
230
+ chunk of a child's piped output).
231
+
232
+ Because dollar-shell spawns children with `env` defaulting to `process.env`, the variable is inherited by
233
+ those children — so it forces the Node backend across the whole process tree. To force **only the
234
+ current process** (no leak to spawned children), set `globalThis.DSH_FORCE_NODE = true` before importing
235
+ instead; it's process-local, but requires a dynamic `import()` (the backend is chosen once, at import time).
236
+ See the [Cross-runtime notes](https://github.com/uhop/dollar-shell/wiki/Cross-runtime-notes) for details.
237
+
238
+ ## Node streams (`dollar-shell/node`)
239
+
240
+ The default entry exposes [web streams](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) on
241
+ `stdin`/`stdout`/`stderr`. If you'd rather work with Node streams — to pipe straight into `fs`/`zlib`/etc.
242
+ with no Web↔Node adapter, or to skip the conversion — import from `dollar-shell/node` instead:
243
+
244
+ ```js
245
+ import {spawn} from 'dollar-shell/node';
246
+
247
+ const sp = spawn(['cat', 'file.txt'], {stdout: 'pipe'});
248
+ sp.stdout.pipe(process.stdout); // sp.stdout is a Node Readable
249
+ ```
250
+
251
+ The API is identical to the main entry — same `$`, `$$`, `$sh`, `shell`, helpers, and
252
+ `.from`/`.to`/`.through`/`.io` — only the stream types differ (`stdin` is a Node `Writable`, `stdout`/`stderr`
253
+ are Node `Readable`s, and `asDuplex` / `.io` / `.through` return a Node `Duplex`). It always spawns through the Node backend, so it also runs on Bun and Deno through their
254
+ `node:child_process` compatibility layer (only the spawn mechanism changes — the runtime launch stays native).
255
+
222
256
  ## For AI Agents
223
257
 
224
258
  This package ships with files to help AI coding agents and LLMs find, understand, and use it:
@@ -229,7 +263,7 @@ This package ships with files to help AI coding agents and LLMs find, understand
229
263
  - **[llms.txt](./llms.txt)** — Concise project overview following the [llms.txt standard](https://llmstxt.org/).
230
264
  - **[llms-full.txt](./llms-full.txt)** — Self-contained complete API reference (no external links needed).
231
265
 
232
- All files are included in the npm package.
266
+ The machine-readable `llms.txt` and `llms-full.txt` ship inside the npm package, so AI tools can read them straight from `node_modules`. `AGENTS.md` and `CLAUDE.md` are authoring-side docs kept in the repository.
233
267
 
234
268
  ## License
235
269
 
@@ -237,6 +271,8 @@ BSD-3-Clause
237
271
 
238
272
  ## Release History
239
273
 
274
+ - 1.2.1 _Bugfix: `DSH_FORCE_NODE` and `dollar-shell/node` now switch only the spawn mechanism — spawned children stay native (`bun run …` / `deno run …`)._
275
+ - 1.2.0 _Added `dollar-shell/node` with Node streams and a `DSH_FORCE_NODE` flag to force the Node backend on any runtime._
240
276
  - 1.1.14 _Fixed Bun stdin abort path, added js-check, Bun + Deno wired into CI._
241
277
  - 1.1.13 _Updated dev dependencies._
242
278
  - 1.1.12 _Consolidated TypeScript tests into `tests/`, removed `ts-check/`, added CJS test, improved test coverage and documentation._
package/llms-full.txt CHANGED
@@ -13,7 +13,7 @@ npm i --save dollar-shell
13
13
  ## Usage
14
14
 
15
15
  ```js
16
- import $, {$$, $sh, shell, sh, spawn} from 'dollar-shell';
16
+ import {$, $$, $sh, shell, sh, spawn} from 'dollar-shell';
17
17
  ```
18
18
 
19
19
  ## spawn()
@@ -315,3 +315,36 @@ Full TypeScript declarations are provided in `src/index.d.ts`. The package uses
315
315
  ## Platform Support
316
316
 
317
317
  Works on Node.js, Deno, and Bun. The appropriate spawn implementation is selected automatically at import time. All streams use the web streams API (ReadableStream/WritableStream).
318
+
319
+ ### Forcing the Node backend
320
+
321
+ By default each runtime uses its own backend (`node:child_process` on Node, `Bun.spawn` on Bun, `Deno.Command` on Deno). Make every runtime spawn through the Node backend — so Bun and Deno run `node:child_process` on their compatibility layer — with the **`DSH_FORCE_NODE` environment variable** (any value except `''`, `0`, `false`):
322
+
323
+ ```bash
324
+ DSH_FORCE_NODE=1 node app.js
325
+ ```
326
+
327
+ This swaps **only the spawn mechanism**. The runtime that executes your scripts and the way dollar-shell re-launches the current runtime (`currentExecPath` / `runFileArgs` / `cwd`) stay native — a forced child of Bun or Deno is still launched as `bun run …` / `deno run …`, never a bare `node`.
328
+
329
+ dollar-shell's `env` option defaults to `process.env`, so spawned children inherit the variable — the environment variable therefore forces the Node backend across the **whole process tree** (this process and every dollar-shell child it spawns), which is usually what you want for a build script or wrapper.
330
+
331
+ To force **only the current process** without leaking into the children it spawns, set the process-local flag instead. It requires a dynamic `import()`, because the backend is chosen once, when the module first loads:
332
+
333
+ ```js
334
+ globalThis.DSH_FORCE_NODE = true;
335
+ const {$} = await import('dollar-shell');
336
+ ```
337
+
338
+ (Or keep the environment variable and pass an explicit `env` to the children you don't want affected.)
339
+
340
+ This is useful to sidestep runtime-specific quirks (e.g. Bun intermittently dropping the final chunk of a child's piped Web-Stream output; the Node backend delivers it reliably). On Deno, **reading** the env var needs `--allow-env` (the read is permission-guarded, so a plain import never prompts) and **writing** `process.env` in-process needs it too, whereas the `globalThis` flag needs no permission. Forcing the Node backend on Deno makes `stdout` a regular (non-byte) ReadableStream, so BYOB readers are unavailable there.
341
+
342
+ ### Node streams (`dollar-shell/node`)
343
+
344
+ The package also ships a Node-streams facade at `dollar-shell/node`:
345
+
346
+ ```js
347
+ import {$, $$, $sh, shell, sh, spawn} from 'dollar-shell/node';
348
+ ```
349
+
350
+ The API is identical to the main entry, except the streams are Node streams (from `node:stream`) instead of Web streams: `stdin`/`stdout`/`stderr` are a Node `Writable`/`Readable`, and `.io`/`.through`/`asDuplex` return a Node `Duplex` (so a process drops straight into a `.pipe()` chain or `stream.pipeline()`). This is convenient for direct Node-ecosystem interop — pipe straight into `fs`/`zlib`/etc. with no Web↔Node adapter, and skip the conversion. It always spawns through the Node backend, so on Bun and Deno it runs `node:child_process` through their compatibility layer; like `DSH_FORCE_NODE`, this changes only the spawn mechanism — the runtime that executes your scripts and how it is re-launched stay native. Type declarations live in `src/node/index.d.ts`.
package/llms.txt CHANGED
@@ -37,7 +37,7 @@ Utilities: `raw()`, `winCmdEscape()`, `isWindows`, `cwd()`, `currentExecPath()`,
37
37
  ## Common patterns
38
38
 
39
39
  ```js
40
- import $, {$$, $sh, raw} from 'dollar-shell';
40
+ import {$, $$, $sh, raw} from 'dollar-shell';
41
41
 
42
42
  // Simple command
43
43
  const result = await $`echo hello`;
@@ -63,6 +63,14 @@ $.from`ls -l .`
63
63
  await $sh({stdout: 'inherit'})`echo ${raw('"hello"')}`;
64
64
  ```
65
65
 
66
+ ## Forcing the Node backend
67
+
68
+ By default each runtime uses its native backend. Set the `DSH_FORCE_NODE` environment variable (e.g. `DSH_FORCE_NODE=1`) to make every runtime spawn through the Node backend (`node:child_process`; Bun and Deno run it on their Node compat). This swaps **only the spawn mechanism** — the runtime that runs your code and how dollar-shell re-launches it stay native, so a forced child of Bun/Deno is still `bun run …` / `deno run …`, never a bare `node`. Because dollar-shell's `env` option defaults to `process.env`, spawned children inherit the variable, so it forces the whole process tree; to force only the current process (no leak to children), set `globalThis.DSH_FORCE_NODE = true` before a dynamic `import()` instead (process-local). Useful to sidestep runtime-specific quirks. The backend is selected once, at import time. Treated as off: unset, `''`, `0`, `false`.
69
+
70
+ ## Node streams (`dollar-shell/node`)
71
+
72
+ `import {$, $$, $sh, shell, spawn} from 'dollar-shell/node'` gives the identical API, but `stdin`/`stdout`/`stderr` are Node `Readable`/`Writable` (and `.io`/`.through`/`asDuplex` a Node `Duplex`) instead of Web streams — for direct Node-ecosystem piping with no adapter. Always spawns through the Node backend (`node:child_process`, run on Bun/Deno via their Node compat); only the spawn mechanism changes — the runtime launch stays native.
73
+
66
74
  ## Docs
67
75
 
68
76
  - [$ (dollar)](https://github.com/uhop/dollar-shell/wiki/$): Spawn processes with simple return values, includes $.from, $.to, $.io/$.through
package/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "dollar-shell",
3
3
  "description": "Run OS and shell commands using template tag functions. Same API in Node, Deno, and Bun. Web streams, TypeScript typings, zero dependencies.",
4
- "version": "1.1.14",
4
+ "version": "1.2.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./src/index.d.ts",
8
8
  "exports": {
9
- ".": {
10
- "types": "./src/index.d.ts",
11
- "default": "./src/index.js"
12
- },
9
+ ".": "./src/index.js",
10
+ "./node": "./src/node/index.js",
13
11
  "./*": "./src/*"
14
12
  },
15
13
  "scripts": {
@@ -31,9 +29,6 @@
31
29
  "/src",
32
30
  "LICENSE",
33
31
  "README.md",
34
- "AGENTS.md",
35
- "CLAUDE.md",
36
- "CONTRIBUTING.md",
37
32
  "llms.txt",
38
33
  "llms-full.txt"
39
34
  ],
@@ -57,6 +52,7 @@
57
52
  "exec",
58
53
  "command",
59
54
  "web-streams",
55
+ "node-streams",
60
56
  "template-tag",
61
57
  "pipeline",
62
58
  "typescript",
@@ -73,12 +69,15 @@
73
69
  "url": "https://github.com/sponsors/uhop"
74
70
  },
75
71
  "license": "BSD-3-Clause",
72
+ "engines": {
73
+ "node": ">=22"
74
+ },
76
75
  "devDependencies": {
77
- "@types/bun": "^1.3.13",
78
- "@types/deno": "^2.5.0",
79
- "@types/node": "^25.6.0",
76
+ "@types/bun": "^1.3.14",
77
+ "@types/deno": "^2.7.0",
78
+ "@types/node": "^25.9.1",
80
79
  "prettier": "^3.8.3",
81
- "tape-six": "^1.9.0",
80
+ "tape-six": "^1.10.1",
82
81
  "typescript": "^6.0.3"
83
82
  },
84
83
  "tape6": {
package/src/bq-shell.js CHANGED
@@ -1,4 +1,4 @@
1
- import {isRawValue, getRawValue, verifyStrings} from './utils.js';
1
+ import {isRawValue, getRawValue, verifyStrings, bqTagSymbol, isBqTag} from './utils.js';
2
2
 
3
3
  const impl =
4
4
  (shellEscape, shell, options) =>
@@ -28,10 +28,11 @@ const bqShell = (shellEscape, shell, options = {}) => {
28
28
  if (verifyStrings(strings)) return impl(shellEscape, shell, options)(strings, ...args);
29
29
  const derived = bqShell(shellEscape, shell, {...options, ...strings});
30
30
  for (const [key, value] of Object.entries(bq)) {
31
- derived[key] = typeof value === 'function' ? value(strings) : value;
31
+ derived[key] = isBqTag(value) ? value(strings) : value;
32
32
  }
33
33
  return derived;
34
34
  };
35
+ /** @type {any} */ (bq)[bqTagSymbol] = true;
35
36
  return bq;
36
37
  };
37
38
 
package/src/bq-spawn.js CHANGED
@@ -1,4 +1,4 @@
1
- import {verifyStrings, isRawValue, getRawValue} from './utils.js';
1
+ import {verifyStrings, isRawValue, getRawValue, bqTagSymbol, isBqTag} from './utils.js';
2
2
 
3
3
  const appendString = (s, previousSpace, result) => {
4
4
  previousSpace ||= /^\s/.test(s);
@@ -69,10 +69,11 @@ const bqSpawn = (spawn, options = {}) => {
69
69
  if (verifyStrings(strings)) return impl(spawn, options)(strings, ...args);
70
70
  const derived = bqSpawn(spawn, {...options, ...strings});
71
71
  for (const [key, value] of Object.entries(bq)) {
72
- derived[key] = typeof value === 'function' ? value(strings) : value;
72
+ derived[key] = isBqTag(value) ? value(strings) : value;
73
73
  }
74
74
  return derived;
75
75
  };
76
+ /** @type {any} */ (bq)[bqTagSymbol] = true;
76
77
  return bq;
77
78
  };
78
79
 
package/src/build.js ADDED
@@ -0,0 +1,113 @@
1
+ // Shared API builder. Given a spawn backend (`spawn`, `cwd`, `currentExecPath`,
2
+ // `runFileArgs`), wires up the shell helpers and all tag functions ($, $$, $sh,
3
+ // shell, with from/to/through/io). Both entry points reuse this — `index.js`
4
+ // passes a Web-Streams backend, `node/index.js` passes the raw-Node-streams one.
5
+
6
+ import bqSpawn from './bq-spawn.js';
7
+ import bqShell from './bq-shell.js';
8
+
9
+ import {isWindows} from './utils.js';
10
+
11
+ export const buildApi = async ({spawn, cwd, currentExecPath, runFileArgs}) => {
12
+ let modShell;
13
+ if (isWindows) {
14
+ modShell = await import('./shell/windows.js');
15
+ } else {
16
+ modShell = await import('./shell/unix.js');
17
+ }
18
+ const {shellEscape, currentShellPath, buildShellCommand} = modShell;
19
+
20
+ // spawn functions
21
+
22
+ const $$ = bqSpawn(spawn);
23
+
24
+ const $ = bqSpawn((command, options) => {
25
+ const sp = spawn(command, options);
26
+ return sp.exited.then(() => ({code: sp.exitCode, signal: sp.signalCode, killed: sp.killed}));
27
+ });
28
+
29
+ const fromProcess = bqSpawn((command, options) => {
30
+ const sp = spawn(command, {...options, stdout: 'pipe'});
31
+ return sp.stdout;
32
+ });
33
+
34
+ const toProcess = bqSpawn((command, options) => {
35
+ const sp = spawn(command, {...options, stdin: 'pipe'});
36
+ return sp.stdin;
37
+ });
38
+
39
+ const throughProcess = bqSpawn((command, options) => {
40
+ const sp = spawn(command, {...options, stdin: 'pipe', stdout: 'pipe'});
41
+ return sp.asDuplex;
42
+ });
43
+
44
+ const $impl = /** @type {import('./index.d.ts').DollarImpl} */ ($);
45
+ $impl.from = fromProcess;
46
+ $impl.to = toProcess;
47
+ $impl.through = $impl.io = throughProcess;
48
+
49
+ // shell functions
50
+
51
+ const shell = bqShell(shellEscape, (command, options) =>
52
+ spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
53
+ ...options,
54
+ windowsVerbatimArguments: true
55
+ })
56
+ );
57
+
58
+ const $sh = bqShell(shellEscape, (command, options) => {
59
+ const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
60
+ ...options,
61
+ windowsVerbatimArguments: true
62
+ });
63
+ return sp.exited.then(() => ({code: sp.exitCode, signal: sp.signalCode, killed: sp.killed}));
64
+ });
65
+
66
+ const fromShell = bqShell(shellEscape, (command, options) => {
67
+ const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
68
+ ...options,
69
+ stdout: 'pipe',
70
+ windowsVerbatimArguments: true
71
+ });
72
+ return sp.stdout;
73
+ });
74
+
75
+ const toShell = bqShell(shellEscape, (command, options) => {
76
+ const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
77
+ ...options,
78
+ stdin: 'pipe',
79
+ windowsVerbatimArguments: true
80
+ });
81
+ return sp.stdin;
82
+ });
83
+
84
+ const throughShell = bqShell(shellEscape, (command, options) => {
85
+ const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
86
+ ...options,
87
+ stdin: 'pipe',
88
+ stdout: 'pipe',
89
+ windowsVerbatimArguments: true
90
+ });
91
+ return sp.asDuplex;
92
+ });
93
+
94
+ const $shImpl = /** @type {import('./index.d.ts').ShellImpl} */ ($sh);
95
+ $shImpl.from = fromShell;
96
+ $shImpl.to = toShell;
97
+ $shImpl.through = $shImpl.io = throughShell;
98
+
99
+ return {
100
+ spawn,
101
+ cwd,
102
+ currentExecPath,
103
+ runFileArgs,
104
+ shellEscape,
105
+ currentShellPath,
106
+ buildShellCommand,
107
+ $$,
108
+ $,
109
+ shell,
110
+ sh: shell,
111
+ $sh
112
+ };
113
+ };
package/src/index.d.ts CHANGED
@@ -215,7 +215,7 @@ export interface DollarResult {
215
215
  /**
216
216
  * The type of the {@link $} function.
217
217
  */
218
- interface DollarImpl<R = any> extends Dollar<Promise<DollarResult>> {
218
+ export interface DollarImpl<R = any> extends Dollar<Promise<DollarResult>> {
219
219
  /**
220
220
  * The tag function that can be used as a template string.
221
221
  * It can take an options object and return self with updated defaults.
@@ -297,7 +297,7 @@ export declare const sh: typeof shell;
297
297
  /**
298
298
  * The type of the $sh function.
299
299
  */
300
- interface ShellImpl<R = any> extends Dollar<Promise<DollarResult>, ShellOptions> {
300
+ export interface ShellImpl<R = any> extends Dollar<Promise<DollarResult>, ShellOptions> {
301
301
  /**
302
302
  * The tag function that can be used as a template string.
303
303
  * It can take an options object and return self with updated defaults.
package/src/index.js CHANGED
@@ -2,109 +2,73 @@
2
2
 
3
3
  // load dependencies
4
4
 
5
- import {isWindows, raw, winCmdEscape} from './utils.js';
6
-
7
- import bqSpawn from './bq-spawn.js';
8
- import bqShell from './bq-shell.js';
9
-
10
- export {isWindows, raw, winCmdEscape};
11
-
12
- let modSpawn;
5
+ import {getEnv} from './utils.js';
6
+
7
+ import {buildApi} from './build.js';
8
+
9
+ export {isWindows, raw, winCmdEscape} from './utils.js';
10
+
11
+ // Force the Node backend on every runtime with the `DSH_FORCE_NODE` environment
12
+ // variable (e.g. DSH_FORCE_NODE=1) — Bun/Deno then run on their Node compat (e.g. to
13
+ // sidestep Bun's Web-Stream child-pipe tail-drop). The env var is inherited by spawned
14
+ // children; to force only this process, set `globalThis.DSH_FORCE_NODE` before a dynamic
15
+ // import instead. Treated as off: unset, '', '0', 'false'.
16
+ const isFlagOn = value =>
17
+ value != null &&
18
+ value !== '' &&
19
+ value !== '0' &&
20
+ value !== 0 &&
21
+ String(value).toLowerCase() !== 'false';
22
+
23
+ const envForceNode = () => {
24
+ if (typeof Deno !== 'undefined') {
25
+ const status = Deno.permissions?.querySync?.({name: 'env', variable: 'DSH_FORCE_NODE'});
26
+ if (status && status.state !== 'granted') return undefined;
27
+ }
28
+ try {
29
+ return getEnv('DSH_FORCE_NODE');
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ };
34
+
35
+ const forceNode =
36
+ isFlagOn(/** @type {any} */ (globalThis).DSH_FORCE_NODE) || isFlagOn(envForceNode());
37
+
38
+ // The runtime-native backend defines how to launch *this* runtime — `currentExecPath`,
39
+ // `runFileArgs`, `cwd`. DSH_FORCE_NODE forces only the spawn *implementation*
40
+ // (`node:child_process`); it must not change which runtime we target or how it is
41
+ // invoked, so a forced child of Bun/Deno is still `bun run …` / `deno run …`, never a
42
+ // bare `node`. (`process.execPath` is already the real runtime under Bun/Deno node
43
+ // compat; `runFileArgs` is what actually differs — `[]` for node vs `['run']`.)
44
+ let modRuntime;
13
45
  if (typeof Deno !== 'undefined') {
14
- modSpawn = await import('./spawn/deno.js');
46
+ modRuntime = await import('./spawn/deno.js');
15
47
  } else if (typeof Bun !== 'undefined') {
16
- modSpawn = await import('./spawn/bun.js');
48
+ modRuntime = await import('./spawn/bun.js');
17
49
  } else {
18
- modSpawn = await import('./spawn/node.js');
50
+ modRuntime = await import('./spawn/node.js');
19
51
  }
20
- export const {spawn, cwd, currentExecPath, runFileArgs} = modSpawn;
21
-
22
- let modShell;
23
- if (isWindows) {
24
- modShell = await import('./shell/windows.js');
25
- } else {
26
- modShell = await import('./shell/unix.js');
27
- }
28
- export const {shellEscape, currentShellPath, buildShellCommand} = modShell;
29
-
30
- // define spawn functions
31
-
32
- export const $$ = bqSpawn(spawn);
33
-
34
- export const $ = bqSpawn((command, options) => {
35
- const sp = spawn(command, options);
36
- return sp.exited.then(() => ({code: sp.exitCode, signal: sp.signalCode, killed: sp.killed}));
37
- });
38
-
39
- const fromProcess = bqSpawn((command, options) => {
40
- const sp = spawn(command, {...options, stdout: 'pipe'});
41
- return sp.stdout;
52
+ const modSpawn = forceNode ? await import('./spawn/node.js') : modRuntime;
53
+
54
+ export const {
55
+ spawn,
56
+ cwd,
57
+ currentExecPath,
58
+ runFileArgs,
59
+ shellEscape,
60
+ currentShellPath,
61
+ buildShellCommand,
62
+ $$,
63
+ $,
64
+ shell,
65
+ sh,
66
+ $sh
67
+ } = await buildApi({
68
+ spawn: modSpawn.spawn,
69
+ cwd: modRuntime.cwd,
70
+ currentExecPath: modRuntime.currentExecPath,
71
+ runFileArgs: modRuntime.runFileArgs
42
72
  });
43
73
 
44
- const toProcess = bqSpawn((command, options) => {
45
- const sp = spawn(command, {...options, stdin: 'pipe'});
46
- return sp.stdin;
47
- });
48
-
49
- const throughProcess = bqSpawn((command, options) => {
50
- const sp = spawn(command, {...options, stdin: 'pipe', stdout: 'pipe'});
51
- return sp.asDuplex;
52
- });
53
-
54
- const $any = /** @type {any} */ ($);
55
- $any.from = fromProcess;
56
- $any.to = toProcess;
57
- $any.through = $any.io = throughProcess;
58
-
59
- // define shell functions
60
-
61
- export const shell = bqShell(shellEscape, (command, options) =>
62
- spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
63
- ...options,
64
- windowsVerbatimArguments: true
65
- })
66
- );
67
- export {shell as sh};
68
-
69
- export const $sh = bqShell(shellEscape, (command, options) => {
70
- const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
71
- ...options,
72
- windowsVerbatimArguments: true
73
- });
74
- return sp.exited.then(() => ({code: sp.exitCode, signal: sp.signalCode, killed: sp.killed}));
75
- });
76
-
77
- const fromShell = bqShell(shellEscape, (command, options) => {
78
- const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
79
- ...options,
80
- stdout: 'pipe',
81
- windowsVerbatimArguments: true
82
- });
83
- return sp.stdout;
84
- });
85
-
86
- const toShell = bqShell(shellEscape, (command, options) => {
87
- const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
88
- ...options,
89
- stdin: 'pipe',
90
- windowsVerbatimArguments: true
91
- });
92
- return sp.stdin;
93
- });
94
-
95
- const throughShell = bqShell(shellEscape, (command, options) => {
96
- const sp = spawn(buildShellCommand(options?.shellPath, options?.shellArgs, command), {
97
- ...options,
98
- stdin: 'pipe',
99
- stdout: 'pipe',
100
- windowsVerbatimArguments: true
101
- });
102
- return sp.asDuplex;
103
- });
104
-
105
- const $shAny = /** @type {any} */ ($sh);
106
- $shAny.from = fromShell;
107
- $shAny.to = toShell;
108
- $shAny.through = $shAny.io = throughShell;
109
-
110
74
  export default $;
@@ -0,0 +1,172 @@
1
+ /// <reference types="node" />
2
+ import type {Readable, Writable, Duplex} from 'node:stream';
3
+ import type {
4
+ SpawnStreamState,
5
+ SpawnOptions,
6
+ ShellEscapeOptions,
7
+ DollarResult,
8
+ ShellOptions
9
+ } from '../index.js';
10
+
11
+ // The stream-free half of the public API is identical to the main entry — reuse it verbatim.
12
+ export type {SpawnStreamState, SpawnOptions, ShellEscapeOptions, DollarResult, ShellOptions};
13
+ export {
14
+ cwd,
15
+ currentExecPath,
16
+ runFileArgs,
17
+ isWindows,
18
+ raw,
19
+ winCmdEscape,
20
+ shellEscape,
21
+ currentShellPath,
22
+ buildShellCommand
23
+ } from '../index.js';
24
+
25
+ /**
26
+ * Sub-process object. Identical to the main entry's `Subprocess`, except the standard
27
+ * streams are Node streams ([`Readable`]/[`Writable`]) instead of Web streams.
28
+ */
29
+ export interface Subprocess {
30
+ /**
31
+ * The raw command that was run as an array of strings.
32
+ */
33
+ readonly command: string[];
34
+ /**
35
+ * The options that were passed to `spawn()`.
36
+ */
37
+ readonly options: SpawnOptions | undefined;
38
+ /**
39
+ * The promise that will be resolved with the exit code when the process exits.
40
+ */
41
+ readonly exited: Promise<number>;
42
+ /**
43
+ * Whether the process has finished running.
44
+ */
45
+ readonly finished: boolean;
46
+ /**
47
+ * Whether the process was killed when finished.
48
+ */
49
+ readonly killed: boolean;
50
+ /**
51
+ * The exit code of the process when it was finished.
52
+ */
53
+ readonly exitCode: number | null;
54
+ /**
55
+ * The signal code of the process when it was finished.
56
+ */
57
+ readonly signalCode: string | null;
58
+ /**
59
+ * The standard input stream as a Node `Writable`.
60
+ * It is `null` if `options.stdin` was not set to `'pipe'`.
61
+ */
62
+ readonly stdin: Writable | null;
63
+ /**
64
+ * The standard output stream as a Node `Readable`.
65
+ * It is `null` if `options.stdout` was not set to `'pipe'`.
66
+ */
67
+ readonly stdout: Readable | null;
68
+ /**
69
+ * The standard error stream as a Node `Readable`.
70
+ * It is `null` if `options.stderr` was not set to `'pipe'`.
71
+ */
72
+ readonly stderr: Readable | null;
73
+ /**
74
+ * The process as a Node `Duplex` stream: writes go to `stdin`, reads come from `stdout`.
75
+ * Built lazily on first access (and cached). Requires both `stdin` and `stdout` piped.
76
+ */
77
+ readonly asDuplex: Duplex;
78
+ /**
79
+ * Kill the process.
80
+ */
81
+ kill(): void;
82
+ }
83
+
84
+ /**
85
+ * Spawn a process with advanced ways to configure and control it.
86
+ */
87
+ export declare function spawn(command: string[], options?: SpawnOptions): Subprocess;
88
+
89
+ /**
90
+ * Backticks (tag) function.
91
+ */
92
+ type Backticks<R> = (strings: TemplateStringsArray, ...args: unknown[]) => R;
93
+
94
+ /**
95
+ * The type of the $ (tag) function. It can be used as a tag function for a template string,
96
+ * or it can take an options object and return self with updated defaults.
97
+ */
98
+ interface Dollar<R, O = SpawnOptions> extends Backticks<R> {
99
+ (options: O): Dollar<R, O>;
100
+ }
101
+
102
+ /**
103
+ * `$$` (tag) function: spawns a process and returns a {@link Subprocess}.
104
+ */
105
+ export declare const $$: Dollar<Subprocess>;
106
+
107
+ /**
108
+ * The type of the {@link $} function.
109
+ */
110
+ export interface DollarImpl extends Dollar<Promise<DollarResult>> {
111
+ /**
112
+ * Returns the `stdout` of the process as a Node `Readable`.
113
+ */
114
+ from: Dollar<Readable>;
115
+ /**
116
+ * Returns the `stdin` of the process as a Node `Writable`.
117
+ */
118
+ to: Dollar<Writable>;
119
+ /**
120
+ * Returns the process as a Node `Duplex` stream. Alias of `io`.
121
+ */
122
+ through: Dollar<Duplex>;
123
+ /**
124
+ * Returns the process as a Node `Duplex` stream. Alias of `through`.
125
+ */
126
+ io: Dollar<Duplex>;
127
+ }
128
+
129
+ /**
130
+ * The `$` function with custom properties (`from`, `to`, `through`, `io`). Used as a tag
131
+ * function it spawns a process and resolves to a simplified result. It is the default export.
132
+ */
133
+ export declare const $: DollarImpl;
134
+
135
+ /**
136
+ * The shell tag function: runs a command through a shell and returns a {@link Subprocess}.
137
+ */
138
+ export declare const shell: Dollar<Subprocess, ShellOptions>;
139
+ /**
140
+ * An alias of `shell`.
141
+ */
142
+ export declare const sh: typeof shell;
143
+
144
+ /**
145
+ * The type of the `$sh` function.
146
+ */
147
+ export interface ShellImpl extends Dollar<Promise<DollarResult>, ShellOptions> {
148
+ /**
149
+ * Returns the `stdout` of the shell process as a Node `Readable`.
150
+ */
151
+ from: Dollar<Readable, ShellOptions>;
152
+ /**
153
+ * Returns the `stdin` of the shell process as a Node `Writable`.
154
+ */
155
+ to: Dollar<Writable, ShellOptions>;
156
+ /**
157
+ * Returns the shell process as a Node `Duplex` stream. Alias of `io`.
158
+ */
159
+ through: Dollar<Duplex, ShellOptions>;
160
+ /**
161
+ * Returns the shell process as a Node `Duplex` stream. Alias of `through`.
162
+ */
163
+ io: Dollar<Duplex, ShellOptions>;
164
+ }
165
+
166
+ /**
167
+ * The `$sh` function with custom properties (`from`, `to`, `through`, `io`). Used as a tag
168
+ * function it runs a shell command and resolves to a simplified result.
169
+ */
170
+ export declare const $sh: ShellImpl;
171
+
172
+ export default $;
@@ -0,0 +1,45 @@
1
+ // @ts-self-types="./index.d.ts"
2
+
3
+ // The Node-streams facade: identical API to the main entry, but stdin/stdout/stderr
4
+ // (and .from/.to/.through/.io/asDuplex) are Node streams instead of Web streams.
5
+ // Spawning always goes through node:child_process (on Bun/Deno via their node compat) —
6
+ // that is the point of this entry. But, like DSH_FORCE_NODE on the main entry, this must
7
+ // affect only the spawn implementation: `currentExecPath` / `runFileArgs` / `cwd` stay
8
+ // runtime-native, so a child of Bun/Deno is still launched as `bun run …` / `deno run …`,
9
+ // never a bare `node`.
10
+
11
+ import {buildApi} from '../build.js';
12
+ import * as backend from '../spawn/node.js';
13
+
14
+ export {isWindows, raw, winCmdEscape} from '../utils.js';
15
+
16
+ let modRuntime;
17
+ if (typeof Deno !== 'undefined') {
18
+ modRuntime = await import('../spawn/deno.js');
19
+ } else if (typeof Bun !== 'undefined') {
20
+ modRuntime = await import('../spawn/bun.js');
21
+ } else {
22
+ modRuntime = backend;
23
+ }
24
+
25
+ export const {
26
+ spawn,
27
+ cwd,
28
+ currentExecPath,
29
+ runFileArgs,
30
+ shellEscape,
31
+ currentShellPath,
32
+ buildShellCommand,
33
+ $$,
34
+ $,
35
+ shell,
36
+ sh,
37
+ $sh
38
+ } = await buildApi({
39
+ spawn: backend.nodeStreamSpawn,
40
+ cwd: modRuntime.cwd,
41
+ currentExecPath: modRuntime.currentExecPath,
42
+ runFileArgs: modRuntime.runFileArgs
43
+ });
44
+
45
+ export default $;
package/src/spawn/node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import {spawn} from 'node:child_process';
2
- import {Readable, Writable} from 'node:stream';
2
+ import {Readable, Writable, Duplex} from 'node:stream';
3
3
 
4
4
  const setStdio = (stdio, fd, value) => {
5
5
  switch (value) {
@@ -18,7 +18,10 @@ const setStdio = (stdio, fd, value) => {
18
18
  };
19
19
 
20
20
  class Subprocess {
21
- constructor(command, options) {
21
+ #nodeStreams;
22
+ #asDuplex;
23
+
24
+ constructor(command, options, nodeStreams) {
22
25
  this.command = command;
23
26
  this.options = options;
24
27
 
@@ -67,13 +70,28 @@ class Subprocess {
67
70
  this.reject(error);
68
71
  });
69
72
 
70
- this.stdin = this.childProcess.stdin && Writable.toWeb(this.childProcess.stdin);
71
- this.stdout = this.childProcess.stdout && Readable.toWeb(this.childProcess.stdout);
72
- this.stderr = this.childProcess.stderr && Readable.toWeb(this.childProcess.stderr);
73
+ this.#nodeStreams = nodeStreams;
74
+ if (nodeStreams) {
75
+ this.stdin = this.childProcess.stdin || null;
76
+ this.stdout = this.childProcess.stdout || null;
77
+ this.stderr = this.childProcess.stderr || null;
78
+ } else {
79
+ this.stdin = this.childProcess.stdin && Writable.toWeb(this.childProcess.stdin);
80
+ this.stdout = this.childProcess.stdout && Readable.toWeb(this.childProcess.stdout);
81
+ this.stderr = this.childProcess.stderr && Readable.toWeb(this.childProcess.stderr);
82
+ }
73
83
  }
74
84
 
75
85
  get asDuplex() {
76
- return {readable: this.stdout, writable: this.stdin};
86
+ // Web-streams mode keeps the `{readable, writable}` pair (the pipeThrough shape).
87
+ // Node-streams mode returns a real Node `Duplex` (built lazily, once) so the process
88
+ // drops straight into `.pipe()` chains and `stream.pipeline()`.
89
+ if (!this.#nodeStreams) return {readable: this.stdout, writable: this.stdin};
90
+ // `@types/node` types `Duplex.from({readable, writable})` for Web streams only,
91
+ // but at runtime it accepts a Node readable + writable just fine.
92
+ return (this.#asDuplex ??= Duplex.from(
93
+ /** @type {any} */ ({readable: this.stdout, writable: this.stdin})
94
+ ));
77
95
  }
78
96
 
79
97
  kill() {
@@ -88,4 +106,7 @@ export const runFileArgs = [];
88
106
  export const cwd = () => process.cwd();
89
107
 
90
108
  const nodeSpawn = (command, options = {}) => new Subprocess(command, options);
91
- export {nodeSpawn as spawn};
109
+ // Variant that keeps raw Node streams (no Web Streams conversion) — backs the
110
+ // `dollar-shell/node` facade. Internal: reached only through that entry.
111
+ const nodeStreamSpawn = (command, options = {}) => new Subprocess(command, options, true);
112
+ export {nodeSpawn as spawn, nodeStreamSpawn};
package/src/utils.js CHANGED
@@ -33,6 +33,9 @@ export const raw = value => ({[rawValueSymbol]: value});
33
33
  export const isRawValue = value => value && typeof value === 'object' && rawValueSymbol in value;
34
34
  export const getRawValue = value => value[rawValueSymbol];
35
35
 
36
+ export const bqTagSymbol = Symbol.for('dollar-shell.bq-tag');
37
+ export const isBqTag = value => typeof value === 'function' && value[bqTagSymbol] === true;
38
+
36
39
  export const winCmdEscape = isWindows
37
40
  ? value => raw(String(value).replace(/./g, '^$&'))
38
41
  : value => String(value);
package/AGENTS.md DELETED
@@ -1,99 +0,0 @@
1
- # AGENTS.md — dollar-shell
2
-
3
- > `dollar-shell` is a micro-library for running OS and shell commands from JavaScript/TypeScript using template tag functions. It works on Node, Deno, and Bun with the same API. All streams are web streams. Zero dependencies.
4
-
5
- For detailed usage docs and API references see the [wiki](https://github.com/uhop/dollar-shell/wiki).
6
-
7
- ## Setup
8
-
9
- ```bash
10
- git clone --recursive git@github.com:uhop/dollar-shell.git
11
- cd dollar-shell
12
- npm install
13
- ```
14
-
15
- The wiki is a git submodule in `wiki/`.
16
-
17
- ## Commands
18
-
19
- - **Test (Node):** `npm test` (runs `tape6 --flags FO`)
20
- - **Test (Bun):** `npm run test:bun`
21
- - **Test (Deno):** `npm run test:deno`
22
- - **TypeScript check:** `npm run ts-check` (`tsc --noEmit`, validates the `.d.ts` sidecars)
23
- - **JavaScript check:** `npm run js-check` (`tsc --project tsconfig.check.json`, lints the `.js` sources for unused vars / undeclared refs)
24
- - **TypeScript tests:** `npm run ts-test` (run `.ts` test files with tape6)
25
- - **Lint:** `npm run lint` (Prettier check)
26
- - **Lint fix:** `npm run lint:fix` (Prettier write)
27
-
28
- ## Project structure
29
-
30
- ```
31
- dollar-shell/
32
- ├── package.json # Package config
33
- ├── tsconfig.json # Strict TS config — checks the .d.ts sidecars
34
- ├── tsconfig.check.json # Lint config — checkJs on .js sources, with @types/{node,bun,deno} for cross-runtime globals
35
- ├── src/ # Source code
36
- │ ├── index.js # Main entry point, wires everything together
37
- │ ├── index.d.ts # TypeScript declarations for the full public API
38
- │ ├── bq-spawn.js # Template tag factory for spawn-based functions ($, $$)
39
- │ ├── bq-shell.js # Template tag factory for shell-based functions ($sh, shell)
40
- │ ├── utils.js # Shared utilities (raw, isWindows, winCmdEscape, etc.)
41
- │ ├── spawn/ # Runtime-specific Subprocess implementations
42
- │ │ ├── node.js # Node.js: uses child_process + stream.Writable/Readable
43
- │ │ ├── deno.js # Deno: uses Deno.Command
44
- │ │ └── bun.js # Bun: uses Bun.spawn
45
- │ └── shell/ # Platform-specific shell escaping and command building
46
- │ ├── unix.js # Unix: single-quote escaping, $SHELL detection
47
- │ └── windows.js # Windows: cmd.exe and PowerShell escaping
48
- ├── tests/ # Automated tests (tape-six): .js, .cjs, .ts
49
- ├── tests/manual/ # Manual verification scripts
50
- └── wiki/ # GitHub wiki documentation (git submodule)
51
- ```
52
-
53
- ## Quick reference
54
-
55
- ```js
56
- import $, {$$, $sh, shell, sh, spawn} from 'dollar-shell';
57
-
58
- // Run a command, get exit info
59
- const result = await $`echo hello`;
60
- // result: {code: 0, signal: null, killed: false}
61
-
62
- // Run a command, get full Subprocess
63
- const sp = $$`sleep 5`;
64
- sp.kill();
65
- await sp.exited;
66
-
67
- // Shell command (supports pipes, aliases)
68
- await $sh`ls -l . | grep LICENSE | wc`;
69
-
70
- // Stream pipelines
71
- $.from`ls -l .`.pipeThrough($.io`grep LIC`).pipeTo($.to({stdout: 'inherit'})`wc`);
72
-
73
- // Custom options (returns new tag function with updated defaults)
74
- const $verbose = $({stdout: 'inherit', stderr: 'inherit'});
75
- await $verbose`ls -l .`;
76
- ```
77
-
78
- ## Code style
79
-
80
- - **ES modules** throughout (`"type": "module"` in package.json).
81
- - **No transpilation** — code runs directly in all target runtimes.
82
- - **Prettier** for formatting — run `npm run lint:fix` before committing.
83
- - Imports at the top of files, using `import` syntax.
84
-
85
- ## Architecture
86
-
87
- - `src/index.js` is the main entry point. It dynamically imports the correct `spawn` implementation (Node/Deno/Bun) and the correct `shell` implementation (Unix/Windows) at import time.
88
- - All tag functions (`$`, `$$`, `$sh`, `shell`) are built by factory functions in `bq-spawn.js` and `bq-shell.js`.
89
- - **Tag function + options pattern**: calling a tag function with an options object returns a new tag function with updated defaults while preserving `.from`, `.to`, `.io`, `.through` properties.
90
- - **raw()**: wraps a value to bypass escaping (shell) or argument splitting (spawn).
91
- - **Platform detection**: `isWindows` boolean is exported. Runtime detection (Node/Deno/Bun) happens at import via dynamic imports.
92
-
93
- ## Key conventions
94
-
95
- - Do not add runtime dependencies — the library is intentionally zero-dependency. DevDeps for tooling (`@types/node`, `@types/bun`, `@types/deno`, `prettier`, `tape-six`, `typescript`) are fine.
96
- - All public API is exported from `src/index.js` and typed in `src/index.d.ts`. Keep them in sync.
97
- - Wiki documentation lives in the `wiki/` submodule — update it alongside code changes. Cross-runtime behavior asymmetries (e.g. BYOB readers — only Deno supports them) are documented at `wiki/Cross-runtime-notes.md`.
98
- - Tests are in `tests/` (automated, tape-six) and `tests/manual/` (manual verification scripts).
99
- - TypeScript typing tests (`.ts`) are in `tests/` and checked by `npm run ts-check`. They can also be run as tests via `npm run ts-test`.
package/CLAUDE.md DELETED
@@ -1,3 +0,0 @@
1
- <!-- Claude Code project instructions — canonical source is AGENTS.md -->
2
-
3
- See [AGENTS.md](./AGENTS.md) for all AI agent rules and project conventions.
package/CONTRIBUTING.md DELETED
@@ -1,34 +0,0 @@
1
- # Contributing to dollar-shell
2
-
3
- Thank you for your interest in contributing!
4
-
5
- ## Getting started
6
-
7
- This project uses git submodules. Clone and set up:
8
-
9
- ```bash
10
- git clone --recursive git@github.com:uhop/dollar-shell.git
11
- cd dollar-shell
12
- npm install
13
- ```
14
-
15
- See the [wiki](https://github.com/uhop/dollar-shell/wiki) for API documentation.
16
-
17
- ## Development workflow
18
-
19
- 1. Make your changes.
20
- 2. Format: `npm run lint:fix`
21
- 3. Test: `npm test`
22
- 4. Type-check: `npm run ts-check`
23
-
24
- ## Code style
25
-
26
- - ES modules (`import`/`export`), no CommonJS in source.
27
- - Formatted with Prettier — run `npm run lint:fix` before committing.
28
- - No dependencies — the library is intentionally zero-dependency.
29
- - Keep `src/index.js` and `src/index.d.ts` in sync.
30
- - Update wiki documentation alongside code changes.
31
-
32
- ## AI agents
33
-
34
- If you are an AI coding agent, see [AGENTS.md](./AGENTS.md) for detailed project conventions, commands, and architecture.