@osdlc/command-seam 0.0.0-stage → 0.4.0
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/CHANGELOG.md +315 -0
- package/CONTRIBUTING.md +61 -0
- package/LICENSE +21 -0
- package/README.md +741 -2
- package/SECURITY.md +23 -0
- package/async.ts +466 -0
- package/defaults.ts +83 -0
- package/dist/async.d.ts +146 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +368 -0
- package/dist/defaults.d.ts +52 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +51 -0
- package/dist/groups.d.ts +155 -0
- package/dist/groups.d.ts.map +1 -0
- package/dist/groups.js +331 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/process.d.ts +146 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +175 -0
- package/dist/process.test.d.ts +1 -0
- package/dist/process.test.d.ts.map +1 -0
- package/dist/process.test.js +1 -0
- package/dist/quote.d.ts +18 -0
- package/dist/quote.d.ts.map +1 -0
- package/dist/quote.js +28 -0
- package/dist/result.d.ts +211 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +219 -0
- package/dist/shell.d.ts +84 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +83 -0
- package/dist/stream.d.ts +90 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +73 -0
- package/dist/sync.d.ts +311 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +406 -0
- package/dist/testing.d.ts +158 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +173 -0
- package/groups.ts +360 -0
- package/index.ts +98 -0
- package/package.json +94 -3
- package/process.ts +270 -0
- package/quote.ts +31 -0
- package/result.ts +403 -0
- package/shell.ts +131 -0
- package/smoke.mjs +61 -0
- package/stream.ts +134 -0
- package/sync.ts +604 -0
- package/testing.ts +263 -0
package/package.json
CHANGED
|
@@ -1,6 +1,97 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osdlc/command-seam",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Injectable subprocess runner for Node: capture, stream and managed-process runners, hard timeouts, failures as results.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "SK Sizer",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/osdlc/osdlc.git",
|
|
10
|
+
"directory": "packages/ts/command-seam"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/osdlc/osdlc/tree/main/packages/ts/command-seam#readme",
|
|
13
|
+
"bugs": "https://github.com/osdlc/osdlc/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"subprocess",
|
|
16
|
+
"spawn",
|
|
17
|
+
"exec",
|
|
18
|
+
"child-process",
|
|
19
|
+
"timeout",
|
|
20
|
+
"dependency-injection",
|
|
21
|
+
"test-seam",
|
|
22
|
+
"testing",
|
|
23
|
+
"mock",
|
|
24
|
+
"cli",
|
|
25
|
+
"process-group",
|
|
26
|
+
"abort",
|
|
27
|
+
"stdio",
|
|
28
|
+
"typescript"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"type": "module",
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"bun": "./index.ts",
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"default": "./dist/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./testing": {
|
|
45
|
+
"bun": "./testing.ts",
|
|
46
|
+
"types": "./dist/testing.d.ts",
|
|
47
|
+
"default": "./dist/testing.js"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist/",
|
|
52
|
+
"smoke.mjs",
|
|
53
|
+
"index.ts",
|
|
54
|
+
"sync.ts",
|
|
55
|
+
"async.ts",
|
|
56
|
+
"result.ts",
|
|
57
|
+
"stream.ts",
|
|
58
|
+
"process.ts",
|
|
59
|
+
"groups.ts",
|
|
60
|
+
"quote.ts",
|
|
61
|
+
"shell.ts",
|
|
62
|
+
"defaults.ts",
|
|
63
|
+
"testing.ts",
|
|
64
|
+
"README.md",
|
|
65
|
+
"CHANGELOG.md",
|
|
66
|
+
"LICENSE",
|
|
67
|
+
"SECURITY.md",
|
|
68
|
+
"CONTRIBUTING.md"
|
|
69
|
+
],
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"@types/node": "*"
|
|
72
|
+
},
|
|
73
|
+
"peerDependenciesMeta": {
|
|
74
|
+
"@types/node": {
|
|
75
|
+
"optional": true
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@types/bun": "^1.1.14",
|
|
80
|
+
"@types/node": "^26.1.0",
|
|
81
|
+
"oxlint": "^1.65.0",
|
|
82
|
+
"prettier": "3.9.6",
|
|
83
|
+
"typescript": "~6.0.3",
|
|
84
|
+
"@osdlc/prettier-config": "^0.2.3",
|
|
85
|
+
"@osdlc/tsconfig": "^0.3.3"
|
|
86
|
+
},
|
|
87
|
+
"scripts": {
|
|
88
|
+
"build": "tsc -p tsconfig.build.json",
|
|
89
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
90
|
+
"test": "bun test",
|
|
91
|
+
"smoke": "node smoke.mjs",
|
|
92
|
+
"format": "prettier --write .",
|
|
93
|
+
"format:check": "prettier --check .",
|
|
94
|
+
"lint": "oxlint .",
|
|
95
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit"
|
|
96
|
+
}
|
|
6
97
|
}
|
package/process.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The managed-process runner: start a long-running child and get a handle to
|
|
3
|
+
* it, for a process the caller watches and stops rather than waits on.
|
|
4
|
+
*
|
|
5
|
+
* Every other runner in this package waits for the child to exit
|
|
6
|
+
* (synchronously or through a promise) and reports how it went. This one
|
|
7
|
+
* returns as soon as the child exists, so the caller can do other work while
|
|
8
|
+
* it runs: enforce its own timeout, respond to signals, stop it later.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process'
|
|
12
|
+
import { constants } from 'node:os'
|
|
13
|
+
|
|
14
|
+
import { groupStartRefusal, releaseWhenEmpty, settleGroup, spawnTrackedGroup } from './groups.ts'
|
|
15
|
+
import { cwdError, mergedEnv, timerMs, type EnvOverrides, type NeverExitedReason } from './sync.ts'
|
|
16
|
+
|
|
17
|
+
/** Per-call options for a {@link ManagedProcessRunner}. */
|
|
18
|
+
export interface ManagedProcessRunOptions {
|
|
19
|
+
/** Working directory for the child. */
|
|
20
|
+
cwd: string
|
|
21
|
+
/**
|
|
22
|
+
* Variables merged over the parent's environment. An `undefined` value
|
|
23
|
+
* removes that variable from the child's environment.
|
|
24
|
+
*/
|
|
25
|
+
env?: EnvOverrides | undefined
|
|
26
|
+
/**
|
|
27
|
+
* Run the child as its own process-group leader. {@link
|
|
28
|
+
* ManagedProcessHandle.kill} then signals the whole group instead of just
|
|
29
|
+
* the direct child, so processes the child started die with it. The group
|
|
30
|
+
* no longer gets the terminal's Ctrl-C. POSIX only.
|
|
31
|
+
* `installGroupSignalForwarding` kills live groups when the host is
|
|
32
|
+
* signalled, and once it is stopping the host, a new group run is refused
|
|
33
|
+
* (`reason: 'abort'`, pid `-1`).
|
|
34
|
+
*/
|
|
35
|
+
processGroup?: boolean | undefined
|
|
36
|
+
/**
|
|
37
|
+
* Let the parent exit while the child runs: the child's handle stops
|
|
38
|
+
* holding the parent's event loop open. Meant for a `processGroup` child
|
|
39
|
+
* that is supposed to outlive the call that started it (a loop tick that
|
|
40
|
+
* starts a run and returns); {@link ManagedProcessHandle.exited} then only
|
|
41
|
+
* settles while the parent is still alive. An `unref`'d group is not tracked
|
|
42
|
+
* by `installGroupSignalForwarding`: a signal to the parent does not reach it,
|
|
43
|
+
* and the parent's exit does not `SIGKILL` it.
|
|
44
|
+
*/
|
|
45
|
+
unref?: boolean | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How a managed process ended. */
|
|
49
|
+
export interface ManagedProcessExit {
|
|
50
|
+
/** The child's exit code, or `1` when it never reported one (see `neverExited`). */
|
|
51
|
+
exitCode: number
|
|
52
|
+
/** The signal that ended the child, or `null` when it exited on its own or never started. */
|
|
53
|
+
signal: NodeJS.Signals | null
|
|
54
|
+
/**
|
|
55
|
+
* No exit code was reported: the spawn failed (e.g. a missing binary) or a
|
|
56
|
+
* signal killed the process (see `signal`). Same meaning as
|
|
57
|
+
* `CommandResult.neverExited`.
|
|
58
|
+
*/
|
|
59
|
+
neverExited?: boolean | undefined
|
|
60
|
+
/**
|
|
61
|
+
* Why no exit code was reported. Set whenever `neverExited` is, absent
|
|
62
|
+
* otherwise. `spawn`: the process never started (see `error`). `abort`: a
|
|
63
|
+
* signal ended it after the caller's own `kill()`, or a `processGroup` run
|
|
64
|
+
* was refused because signal forwarding is stopping the host (it never
|
|
65
|
+
* started; see `error`). `signal`: a signal from
|
|
66
|
+
* elsewhere ended it. There is no `timeout` or `output`: this runner has
|
|
67
|
+
* neither a timeout nor captured output.
|
|
68
|
+
*/
|
|
69
|
+
reason?: Exclude<NeverExitedReason, 'timeout' | 'output'> | undefined
|
|
70
|
+
/**
|
|
71
|
+
* The spawn error's message, such as `spawn node ENOENT`, when `reason` is
|
|
72
|
+
* `spawn`, or the refusal message for a refused `processGroup` run.
|
|
73
|
+
*/
|
|
74
|
+
error?: string | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A running child: its pid, a way to kill it, and a promise settling once it's gone. */
|
|
78
|
+
export interface ManagedProcessHandle {
|
|
79
|
+
/** The child's pid, or `-1` when it could not be spawned at all. */
|
|
80
|
+
readonly pid: number
|
|
81
|
+
/**
|
|
82
|
+
* Send `signal` (default `SIGTERM`) to the child, or to its whole process
|
|
83
|
+
* group when started with `processGroup: true`. Does nothing when `pid` is
|
|
84
|
+
* `-1`. Without `processGroup`, it also does nothing once the child has
|
|
85
|
+
* exited, so a reused pid is never signalled. With `processGroup`, it still
|
|
86
|
+
* signals any member of the group that is left. If the process dies of a
|
|
87
|
+
* signal sent here, `exited` reports `reason: 'abort'`. A signal the child
|
|
88
|
+
* handles and survives does not count: a later death by another signal is
|
|
89
|
+
* `'signal'`.
|
|
90
|
+
*/
|
|
91
|
+
kill(signal?: NodeJS.Signals | number): void
|
|
92
|
+
/** Settles once the child is gone, including when the spawn failed. Never rejects. */
|
|
93
|
+
readonly exited: Promise<ManagedProcessExit>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Starts a long-running process and returns a handle instead of waiting for it. */
|
|
97
|
+
export interface ManagedProcessRunner {
|
|
98
|
+
/**
|
|
99
|
+
* Start the binary with `argv` (without the binary name) and return at
|
|
100
|
+
* once. A failed spawn is reported through `exited`, not thrown. A NUL
|
|
101
|
+
* byte in argv or env is a programmer error and throws.
|
|
102
|
+
*/
|
|
103
|
+
run(argv: string[], opts: ManagedProcessRunOptions): ManagedProcessHandle
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The default {@link ManagedProcessRunner}: starts `binary` with `spawn`.
|
|
108
|
+
* Stdio is always inherited, so the child's output reaches the terminal. It
|
|
109
|
+
* does not capture output and has no timeout of its own.
|
|
110
|
+
*
|
|
111
|
+
* @param binary - A name looked up on `PATH`, or a path.
|
|
112
|
+
* @returns A runner for that binary.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```ts
|
|
116
|
+
* const server = managedProcessRunner('node').run(['server.js'], {
|
|
117
|
+
* cwd: '.',
|
|
118
|
+
* processGroup: true,
|
|
119
|
+
* })
|
|
120
|
+
* setTimeout(() => server.kill(), 5_000)
|
|
121
|
+
* const { exitCode, signal } = await server.exited
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export function managedProcessRunner(binary: string): ManagedProcessRunner {
|
|
125
|
+
return {
|
|
126
|
+
run(argv: string[], opts: ManagedProcessRunOptions): ManagedProcessHandle {
|
|
127
|
+
const cwdMessage = cwdError(`spawn ${binary}`, binary, argv, opts)
|
|
128
|
+
if (cwdMessage !== undefined) {
|
|
129
|
+
return { pid: -1, kill: () => {}, exited: Promise.resolve(spawnExit(cwdMessage)) }
|
|
130
|
+
}
|
|
131
|
+
const group = opts.processGroup === true
|
|
132
|
+
const refusal = group ? groupStartRefusal() : undefined
|
|
133
|
+
if (refusal !== undefined) {
|
|
134
|
+
return { pid: -1, kill: () => {}, exited: Promise.resolve(refusedExit(refusal)) }
|
|
135
|
+
}
|
|
136
|
+
const start = () =>
|
|
137
|
+
spawn(binary, argv, {
|
|
138
|
+
cwd: opts.cwd,
|
|
139
|
+
detached: group,
|
|
140
|
+
stdio: 'inherit',
|
|
141
|
+
...mergedEnv(opts),
|
|
142
|
+
})
|
|
143
|
+
// An `unref`'d group is meant to outlive the host, so forwarding must not reap it.
|
|
144
|
+
const { child, untrack: untrackGroup } =
|
|
145
|
+
group && opts.unref !== true
|
|
146
|
+
? spawnTrackedGroup(start)
|
|
147
|
+
: { child: start(), untrack: undefined }
|
|
148
|
+
const pid = child.pid ?? -1
|
|
149
|
+
if (opts.unref === true) child.unref()
|
|
150
|
+
// The signals `kill()` has sent. A death by one of them is the caller's
|
|
151
|
+
// own stop; a death by any other signal came from elsewhere.
|
|
152
|
+
const sent = new Set<NodeJS.Signals>()
|
|
153
|
+
|
|
154
|
+
const exited = new Promise<ManagedProcessExit>((resolve) => {
|
|
155
|
+
let settled = false
|
|
156
|
+
const finish = (result: ManagedProcessExit): void => {
|
|
157
|
+
if (settled) return
|
|
158
|
+
settled = true
|
|
159
|
+
resolve(result)
|
|
160
|
+
}
|
|
161
|
+
// A spawn failure (e.g. ENOENT): no exit was ever reported.
|
|
162
|
+
child.on('error', (err) => {
|
|
163
|
+
untrackGroup?.()
|
|
164
|
+
finish(spawnExit(err.message))
|
|
165
|
+
})
|
|
166
|
+
child.on('exit', (code, signal) => {
|
|
167
|
+
// The leader is gone but the group may not be: stay registered until
|
|
168
|
+
// no member is left, so a signal to the host still reaches the rest.
|
|
169
|
+
// Unless `kill()` is stopping it, the host's exit leaves it running.
|
|
170
|
+
if (untrackGroup !== undefined) {
|
|
171
|
+
if (sent.size === 0) settleGroup(pid)
|
|
172
|
+
releaseWhenEmpty(pid, untrackGroup)
|
|
173
|
+
}
|
|
174
|
+
finish({
|
|
175
|
+
exitCode: code ?? 1,
|
|
176
|
+
signal,
|
|
177
|
+
...(code === null
|
|
178
|
+
? {
|
|
179
|
+
neverExited: true as const,
|
|
180
|
+
reason:
|
|
181
|
+
signal !== null && sent.has(signal) ? ('abort' as const) : ('signal' as const),
|
|
182
|
+
}
|
|
183
|
+
: {}),
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
pid,
|
|
190
|
+
kill(signal: NodeJS.Signals | number = 'SIGTERM'): void {
|
|
191
|
+
if (pid === -1) return
|
|
192
|
+
try {
|
|
193
|
+
// A negative pid signals the whole process group — see
|
|
194
|
+
// `processGroup`'s doc above. The direct child goes through
|
|
195
|
+
// `child.kill`, which does nothing once the child has been
|
|
196
|
+
// reaped, so a reused pid is never signalled.
|
|
197
|
+
const delivered = group ? process.kill(-pid, signal) : child.kill(signal)
|
|
198
|
+
const name = typeof signal === 'number' ? signalName(signal) : signal
|
|
199
|
+
if (delivered && name !== undefined) sent.add(name)
|
|
200
|
+
} catch {
|
|
201
|
+
// Already gone — nothing to reap.
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
exited,
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Stop a managed process: send `SIGTERM`, wait up to `graceMs` for it to
|
|
212
|
+
* exit, then send `SIGKILL`. A plain `kill()` sends one signal, so a process
|
|
213
|
+
* that ignores `SIGTERM` would leave `await handle.exited` waiting forever.
|
|
214
|
+
*
|
|
215
|
+
* Works with any {@link ManagedProcessHandle}, fakes included. It waits for
|
|
216
|
+
* the handle's own process only: with `processGroup`, a group member that
|
|
217
|
+
* outlives the leader is not sent `SIGKILL`.
|
|
218
|
+
*
|
|
219
|
+
* @param handle - The process to stop.
|
|
220
|
+
* @param opts - `graceMs`: how long to wait after `SIGTERM`. Default `5_000`.
|
|
221
|
+
* `0` sends `SIGKILL` right after `SIGTERM`; `Infinity` never sends it. A
|
|
222
|
+
* value above 2^31 - 1, the largest timer delay, is clamped to it. A
|
|
223
|
+
* negative value or `NaN` rejects with `RangeError`, a non-number with
|
|
224
|
+
* `TypeError`.
|
|
225
|
+
* @returns How the process ended. `reason` is `'abort'` when one of the two
|
|
226
|
+
* signals ended it.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```ts
|
|
230
|
+
* const server = managedProcessRunner('node').run(['server.js'], { cwd: '.' })
|
|
231
|
+
* const exit = await stopProcess(server, { graceMs: 2_000 })
|
|
232
|
+
* ```
|
|
233
|
+
*
|
|
234
|
+
* @experimental
|
|
235
|
+
*/
|
|
236
|
+
export async function stopProcess(
|
|
237
|
+
handle: ManagedProcessHandle,
|
|
238
|
+
opts: { graceMs?: number | undefined } = {},
|
|
239
|
+
): Promise<ManagedProcessExit> {
|
|
240
|
+
const graceMs = timerMs('graceMs', opts.graceMs ?? 5_000, 'zero')
|
|
241
|
+
handle.kill()
|
|
242
|
+
if (graceMs === undefined) return handle.exited
|
|
243
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
244
|
+
const grace = new Promise<undefined>((resolve) => {
|
|
245
|
+
timer = setTimeout(resolve, graceMs, undefined)
|
|
246
|
+
})
|
|
247
|
+
const exit = await Promise.race([handle.exited, grace])
|
|
248
|
+
clearTimeout(timer)
|
|
249
|
+
if (exit !== undefined) return exit
|
|
250
|
+
handle.kill('SIGKILL')
|
|
251
|
+
return handle.exited
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The exit of a `processGroup` run refused because signal forwarding is stopping the host. */
|
|
255
|
+
function refusedExit(error: string): ManagedProcessExit {
|
|
256
|
+
return { exitCode: 1, signal: null, neverExited: true, reason: 'abort', error }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The exit of a process that never started. */
|
|
260
|
+
function spawnExit(error: string): ManagedProcessExit {
|
|
261
|
+
return { exitCode: 1, signal: null, neverExited: true, reason: 'spawn', error }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The name of signal number `n`, or `undefined` for 0 (a probe) and unknown numbers. */
|
|
265
|
+
export function signalName(n: number): NodeJS.Signals | undefined {
|
|
266
|
+
for (const [name, value] of Object.entries(constants.signals)) {
|
|
267
|
+
if (value === n) return name as NodeJS.Signals
|
|
268
|
+
}
|
|
269
|
+
return undefined
|
|
270
|
+
}
|
package/quote.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX shell quoting, for a command line printed for a person to paste or
|
|
3
|
+
* passed to `sh -c`. The argv runners never need it: they pass argv straight
|
|
4
|
+
* to the binary.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Characters no POSIX shell treats specially. Same set as Python's `shlex.quote`. */
|
|
8
|
+
const INERT = /^[A-Za-z0-9_@%+=:,./-]+$/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `word` as one shell word: unquoted when it holds only characters no POSIX
|
|
12
|
+
* shell treats specially (letters, digits and `_@%+=:,./-`), single-quoted
|
|
13
|
+
* otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export function shellQuote(word: string): string {
|
|
16
|
+
if (INERT.test(word)) return word
|
|
17
|
+
return quoted(word)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `argv` as one shell command line. A first word containing `=` is always
|
|
22
|
+
* quoted: bare, the shell would read it as a variable assignment, not a
|
|
23
|
+
* command.
|
|
24
|
+
*/
|
|
25
|
+
export function shellJoin(argv: readonly string[]): string {
|
|
26
|
+
return argv.map((w, i) => (i === 0 && w.includes('=') ? quoted(w) : shellQuote(w))).join(' ')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function quoted(word: string): string {
|
|
30
|
+
return `'${word.replaceAll("'", `'\\''`)}'`
|
|
31
|
+
}
|