@hwfee/dsh-git-bash 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,558 @@
1
+ /**
2
+ * The shell this plugin runs commands in, and the tool that asks it to.
3
+ *
4
+ * THE SEAM, and why this plugin does not touch it. `@deepseek-ai/dsh-shell`
5
+ * declares `ctx.shell` as a capability seam with ONE implementation per context
6
+ * ("loading a second throws, which is cordis' standard duplicate-service
7
+ * behavior"). On Windows the base bundle composes `pwsh-sandbox`; on POSIX it
8
+ * composes `bash-sandbox`. Both are `ctx.shell`.
9
+ *
10
+ * So the default-shell switch itself is the executor's job. What this plugin
11
+ * owns is the other half of the contract:
12
+ *
13
+ * 1. DECIDE which shell to use, from the user's `auto`/`pwsh`/`gitbash` mode.
14
+ * 2. PUBLISH that decision to the settings row and to the model, with the path
15
+ * it resolved to, so "which shell am I talking to" is never a guess.
16
+ * 3. EXPOSE `run_shell` — one command in the resolved shell, reporting which
17
+ * one ran it.
18
+ *
19
+ * @module @hwfee/dsh-git-bash
20
+ */
21
+
22
+ import { execFile } from 'node:child_process'
23
+ import { DEFAULT_MODE, MODES, chooseShell, readMode } from './mode.js'
24
+ import { createResolver, execAsync } from './resolve.js'
25
+ import { createModeRoute } from './mode-route.js'
26
+ export const name = 'dsh-git-bash'
27
+
28
+ /** The `tools` registry is the only hard requirement. */
29
+ export const inject = ['tools']
30
+
31
+ /** The tool the model calls. */
32
+ export const TOOL_NAME = 'run_shell'
33
+
34
+ /** The page global carrying the resolved shell, read by the settings row. */
35
+ export const SHELL_GLOBAL = '__dshGitBash'
36
+
37
+ /** The Cordis event the browser half publishes its mode on. */
38
+ export const MODE_EVENT = 'git-bash/mode'
39
+
40
+ /**
41
+ * Register the tool, the mode listener, and the page injection.
42
+ *
43
+ * @param {object} ctx the host context
44
+ */
45
+ export function apply(ctx) {
46
+ const resolver = createResolver()
47
+
48
+ // The user's mode. The page owns the preference (it is the side with the
49
+ // settings UI), so it publishes; the host caches. Default `auto` covers the
50
+ // window before the first publish — and it is also the right answer for a
51
+ // first launch, since the page has no stored choice yet.
52
+ //
53
+ // TWO CHANNELS, ON PURPOSE:
54
+ // - the Cordis event reaches in-process callers (tests, an embedding host);
55
+ // - the HTTP route reaches the BROWSER, which lives in another process.
56
+ // Without it the host stays on `auto` forever and the row lies.
57
+ let mode = DEFAULT_MODE
58
+ const setMode = (next) => {
59
+ if (typeof next === 'string' && MODES.includes(next)) mode = next
60
+ }
61
+ ctx.on(MODE_EVENT, (next) => {
62
+ if (next !== null && typeof next === 'object') setMode(next.mode)
63
+ })
64
+ ctx.inject(['webServer'], (hostCtx) => {
65
+ const webServer = hostCtx.webServer
66
+ if (webServer === undefined || typeof webServer.register !== 'function') return
67
+ // `getOrigin()` throws before the listener binds; the fence reads it lazily.
68
+ const origin = () => webServer.getOrigin()
69
+ hostCtx.effect(
70
+ () => webServer.register(createModeRoute(setMode, { origin, readMode: () => mode })),
71
+ 'dsh-git-bash: mode route',
72
+ )
73
+ })
74
+
75
+ /**
76
+ * Resolve the current mode.
77
+ *
78
+ * Called on each use so a mode change applies to the NEXT call, but with a
79
+ * fresh memo per call: `auto` asks for two kinds, and both questions must see
80
+ * the same machine. A memo shared across calls would freeze the shell forever,
81
+ * which is the opposite of what a live setting needs.
82
+ */
83
+ const current = () => resolveShell(mode, { resolve: memoized(resolver), platform: process.platform })
84
+
85
+ ctx.effect(
86
+ () => ctx.tools.register(createRunShellTool(current, {
87
+ // OPTIONAL ON PURPOSE. The host composes exactly one `ctx.shell` executor
88
+ // (pwsh-sandbox on Windows), but a profile may leave it out. `ctx.get`
89
+ // reads without an inject requirement, so the plugin still mounts and
90
+ // `run_shell` falls back to direct execution.
91
+ getShell: () => (typeof ctx.get === 'function' ? ctx.get('shell') : undefined),
92
+ getWorkingDirectory: () => (typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined),
93
+ })),
94
+ 'dsh-git-bash: run_shell tool',
95
+ )
96
+
97
+ // PUBLISH EVERY MODE'S RESOLUTION TO THE PAGE.
98
+ //
99
+ // The settings row needs to name what `auto` chose, and it is a BROWSER
100
+ // component: it cannot probe the filesystem. `webserver/index-inject` is the
101
+ // host's seam for a value the page needs before its first paint, computed
102
+ // where the process lives — the same seam cds-theme uses for its fonts base.
103
+ //
104
+ // SYNCHRONOUS AT INJECT TIME, deliberately: the inject fires while the HTML is
105
+ // assembled, so an async resolution that has not settled yet would inject
106
+ // `null` and the row would say "not found" on every launch. The probe costs
107
+ // ~77 ms once per page load, and it is memoized across the three modes.
108
+ //
109
+ // ALL THREE RESOLUTIONS, not just the active one.
110
+ //
111
+ // The page needs to re-render the status line the moment the user picks a
112
+ // different mode, and it cannot probe the filesystem. Injecting only the
113
+ // ACTIVE choice made the row contradict itself: the trigger said "Windows
114
+ // PowerShell" while the line under it still named the Git Bash path the page
115
+ // had been booted with. It also means the row can say what `auto` WOULD pick
116
+ // on this machine, which is the question a user choosing between the modes
117
+ // actually has.
118
+ ctx.on('webserver/index-inject', (table) => {
119
+ const probe = memoized(resolver)
120
+ const byMode = {}
121
+ for (const candidate of MODES) {
122
+ byMode[candidate] = describe(resolveShell(candidate, { resolve: probe, platform: process.platform }), candidate)
123
+ }
124
+ table.push({
125
+ kind: 'global',
126
+ name: SHELL_GLOBAL,
127
+ value: { active: byMode[mode], byMode },
128
+ })
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Resolve one mode against this machine.
134
+ *
135
+ * THE HOST-SIDE ENTRY POINT. `mode.js` holds the decision table but is shared
136
+ * with the browser bundle, so it cannot import `node:child_process`; the
137
+ * executable probe lives in `resolve.js`. This is the one call that joins them,
138
+ * used by the tool and the injection alike so they can never disagree.
139
+ *
140
+ * The result is a VALUE, never a throw: `{ ok, mode, kind, path, source,
141
+ * reason }`. `source` explains WHICH branch ran — under `auto` that is the only
142
+ * evidence a fallback happened.
143
+ *
144
+ * @param {'auto' | 'pwsh' | 'gitbash'} [mode] the selected mode
145
+ * @param {object} [deps]
146
+ * @param {(kind: string) => object | null} [deps.resolve] a resolver, injected for tests
147
+ * @param {string} [deps.platform] `process.platform`, injected for tests
148
+ * @returns {{ ok: boolean, mode: string, kind: string | null, path: string | null, source: string, reason: string | null }}
149
+ */
150
+ export function resolveShell(mode = DEFAULT_MODE, deps = {}) {
151
+ const platform = deps.platform ?? process.platform
152
+ const resolver = deps.resolve ?? memoized(createResolver({ platform }))
153
+ const decision = chooseShell(mode, { resolve: resolver, platform })
154
+ if (decision.ok !== true) {
155
+ return { ok: false, mode, kind: null, path: null, source: '', reason: decision.reason }
156
+ }
157
+ return {
158
+ ok: true,
159
+ mode,
160
+ kind: decision.shell.kind,
161
+ path: decision.shell.path,
162
+ source: decision.shell.source,
163
+ reason: null,
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Turn a resolution into the shape the page reads.
169
+ *
170
+ * A failure is not an empty value: the row must be able to say WHY nothing runs,
171
+ * and `reason` is the only place that survives the JSON injection. `source` and
172
+ * `kind` are what make `auto` readable — "git --exec-path (auto)" versus
173
+ * "known path (auto fallback)" is the whole difference between "the setting
174
+ * worked" and "the setting quietly did something else".
175
+ *
176
+ * @param {{ ok: boolean, path: string | null, kind: string | null, source: string, reason: string | null }} result
177
+ * the resolution
178
+ * @param {string} mode the mode it was made under
179
+ * @returns {object} the page payload
180
+ */
181
+ export function describe(result, mode) {
182
+ if (result.ok !== true) {
183
+ return { mode, path: null, kind: null, source: null, reason: result.reason }
184
+ }
185
+ return {
186
+ mode,
187
+ path: result.path,
188
+ kind: result.kind,
189
+ source: result.source,
190
+ reason: null,
191
+ }
192
+ }
193
+
194
+ /**
195
+ * The model-facing tool.
196
+ *
197
+ * `current` is a FUNCTION, not a value: the user can change the mode while a
198
+ * session is open, and the next call must use the new shell.
199
+ *
200
+ * @param {() => ({ ok: boolean, path: string | null, kind: string | null, reason: string | null })} current
201
+ * resolves the shell under the active mode
202
+ * @param {object} [services] optional host services, read lazily
203
+ * @param {() => object | undefined} [services.getShell] `ctx.shell`, the host executor
204
+ * @param {() => object | undefined} [services.getWorkingDirectory] `ctx.workingDirectory`
205
+ * @returns the tool definition `ctx.tools.register` expects
206
+ */
207
+ export function createRunShellTool(current, services = {}) {
208
+ return {
209
+ name: TOOL_NAME,
210
+ description:
211
+ 'Run one shell command and return its output. '
212
+ + 'The interpreter is whatever the user configured: Git Bash (POSIX), '
213
+ + 'PowerShell, or automatic. The result names the executable that ran it, so '
214
+ + 'check that line before assuming which quoting and variable syntax apply. '
215
+ + 'Each call runs in a fresh shell in the working directory; pass an absolute '
216
+ + 'path instead of relying on a previous `cd`.',
217
+ // RAW JSON SCHEMA. `ctx.tools.register()` stores this as the wire schema and
218
+ // hands it to the provider verbatim; the `defineTool` author dialect
219
+ // (`{ name: { type, required: true } }`) is rejected at request time with
220
+ // `is not of type "object"`. See docs/genui-audit.md for that incident.
221
+ parameters: {
222
+ type: 'object',
223
+ additionalProperties: false,
224
+ required: ['command'],
225
+ properties: {
226
+ command: {
227
+ type: 'string',
228
+ description: 'The command to run. Passed to the resolved shell as a single command string.',
229
+ },
230
+ workdir: {
231
+ type: 'string',
232
+ description: 'Absolute directory to run in. Defaults to the session working directory.',
233
+ },
234
+ },
235
+ },
236
+ output: {
237
+ schema: {
238
+ type: 'object',
239
+ additionalProperties: false,
240
+ required: ['shell', 'exitCode', 'stdout'],
241
+ properties: {
242
+ shell: { type: 'string' },
243
+ kind: { type: 'string' },
244
+ exitCode: { type: 'integer' },
245
+ stdout: { type: 'string' },
246
+ stderr: { type: 'string' },
247
+ // WHERE THE COMMAND RAN. 'host' crossed ctx.shell (sandbox, approval
248
+ // and cancellation semantics apply); 'direct' used the plugin's own
249
+ // spawn because the host cannot start this shell. The renderer always
250
+ // spells this out — a silently unconfined command would be the worst
251
+ // possible failure mode.
252
+ via: { type: 'string' },
253
+ note: { type: 'string' },
254
+ },
255
+ },
256
+ render(_args, value) {
257
+ // THE PATH IS ALWAYS STATED. "host" means the command crossed
258
+ // `ctx.shell` (sandbox / cancellation apply); "direct" means the plugin
259
+ // spawned it itself because the host cannot start this shell. Leaving
260
+ // the host case unmarked made the two indistinguishable in the result.
261
+ const route = value.via === 'host' ? ' · host' : value.via === 'direct' ? ' · direct' : ''
262
+ const parts = [`[${value.kind} · ${value.shell}] exit ${value.exitCode}${route}`]
263
+ if (typeof value.note === 'string' && value.note !== '') parts.push(value.note)
264
+ if (value.stdout !== '') parts.push(value.stdout)
265
+ if (value.stderr !== '') parts.push('stderr: ' + value.stderr)
266
+ return [{ type: 'text', text: parts.join('\n') }]
267
+ },
268
+ },
269
+ async execute(args, exec) {
270
+ const resolved = current()
271
+ if (resolved.ok !== true) {
272
+ throw new Error(messageFor(resolved.reason))
273
+ }
274
+ const workdir = await resolveWorkdir(args, exec, services.getWorkingDirectory)
275
+ const shell = {
276
+ kind: resolved.kind,
277
+ path: resolved.path,
278
+ ...(workdir === undefined ? {} : { workdir }),
279
+ }
280
+ const platform = services.platform ?? process.platform
281
+ const hostShell = typeof services.getShell === 'function' ? services.getShell() : undefined
282
+ const hasHost = hostShell !== undefined
283
+ && typeof hostShell.execute === 'function'
284
+ && typeof hostShell.resolve === 'function'
285
+
286
+ // WHICH MODES MAY CROSS THE HOST BOUNDARY.
287
+ //
288
+ // PowerShell does: the host executor IS pwsh on Windows, and nesting a
289
+ // pwsh child through it works. Git Bash on Windows does NOT: the sandboxed
290
+ // pwsh cannot create the MSYS process (NtCreateDirectoryObject,
291
+ // 0xC0000022 — reproduced live), so delegating would turn every command
292
+ // into a spawn failure. POSIX has one shell, so it always delegates.
293
+ //
294
+ // The direct path is the pre-delegation behaviour. It is REPORTED (via
295
+ // 'direct' + a note), never silent: a user reading the tool result must be
296
+ // able to tell that this call did not cross the sandbox.
297
+ const delegate = shouldDelegate(shell.kind, platform, hasHost)
298
+ if (delegate) return runThroughHost(hostShell, shell, args, exec)
299
+
300
+ const direct = await runInShell({ kind: shell.kind, path: shell.path }, { ...args, workdir: shell.workdir })
301
+ const note = !hasHost
302
+ ? 'Ran directly: this profile has no ctx.shell executor.'
303
+ : 'Ran directly: the Windows host sandbox cannot start Git Bash (nested MSYS process creation is denied), so this command did not cross the sandbox.'
304
+ return { ...direct, via: 'direct', note }
305
+ },
306
+ presentCall: (args) => ({
307
+ card: 'generic',
308
+ title: typeof args?.command === 'string' ? args.command.slice(0, 80) : 'Run shell command',
309
+ kind: 'execute',
310
+ rawInput: args?.command,
311
+ }),
312
+ }
313
+ }
314
+
315
+ /**
316
+ * The sentence a user reads when the chosen shell is missing.
317
+ *
318
+ * Each reason names the fix, because the failure is always one of three
319
+ * configuration facts and never the command's fault.
320
+ *
321
+ * @param {string} reason the failure code
322
+ * @returns {string} the message
323
+ */
324
+ export function messageFor(reason) {
325
+ switch (reason) {
326
+ case 'gitbash-not-found':
327
+ return 'Git Bash is selected but was not found. Install Git for Windows, or set '
328
+ + 'DSH_GIT_BASH to its bash.exe. (The plugin will not silently switch to PowerShell: '
329
+ + 'POSIX commands would fail in confusing ways under it.)'
330
+ case 'pwsh-not-found':
331
+ return 'PowerShell is selected but neither pwsh.exe nor Windows PowerShell was found.'
332
+ case 'no-shell-for-platform':
333
+ return 'The selected shell mode is not available on this platform.'
334
+ case 'no-shell-found':
335
+ return 'No usable shell was found. Install Git for Windows, or set DSH_GIT_BASH.'
336
+ default:
337
+ return 'The configured shell could not be started: ' + reason
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Run one command in a resolved shell.
343
+ *
344
+ * HOW THE COMMAND IS PASSED depends on the family, and getting it wrong is
345
+ * silent: `-lc` is bash's "login shell, run this string", while PowerShell's
346
+ * equivalent is `-NoProfile -Command`. Handing `-lc` to PowerShell makes it read
347
+ * `-l` as a switch and complain about an unknown argument.
348
+ *
349
+ * A NONZERO EXIT IS NOT AN ERROR: that is the command's own result, and the
350
+ * model needs to see it (`grep` that matched nothing exits 1).
351
+ *
352
+ * @param {{ kind: string, path: string }} shell the executable
353
+ * @param {{ command: string, workdir?: string }} args the call arguments
354
+ * @param {(path: string, args: string[], cwd?: string) => Promise<object>} [run]
355
+ * the executor, injected for tests
356
+ * @returns {Promise<object>} the tool result
357
+ */
358
+ export async function runInShell(shell, args, run = defaultRun) {
359
+ const command = typeof args?.command === 'string' ? args.command : ''
360
+ if (command.trim() === '') throw new Error('run_shell needs a non-empty `command`')
361
+
362
+ const argv = shell.kind === 'gitbash' || shell.kind === 'posix-bash'
363
+ ? ['-lc', command]
364
+ : ['-NoProfile', '-NonInteractive', '-Command', command]
365
+
366
+ const outcome = await run(shell.path, argv, args?.workdir)
367
+ return {
368
+ shell: shell.path,
369
+ kind: shell.kind,
370
+ exitCode: outcome.exitCode,
371
+ stdout: outcome.stdout,
372
+ stderr: outcome.stderr,
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Quote one string for PowerShell's `-Command` argument.
378
+ *
379
+ * A single-quoted PowerShell string is literal; the ONLY escape is doubling an
380
+ * inner quote. Newlines are legal inside it, so an arbitrary command string
381
+ * survives without the parser re-reading `$`, `;` or `&`.
382
+ *
383
+ * @param {string} value the literal to carry
384
+ * @returns {string} a single-quoted PowerShell string
385
+ */
386
+ export function quotePowerShell(value) {
387
+ return "'" + String(value == null ? '' : value).replace(/'/g, "''") + "'"
388
+ }
389
+
390
+ /**
391
+ * Quote one string for a POSIX shell's `-lc` argument.
392
+ *
393
+ * @param {string} value the literal to carry
394
+ * @returns {string} a single-quoted shell word
395
+ */
396
+ export function quoteSh(value) {
397
+ return "'" + String(value == null ? '' : value).replace(/'/g, "'\\''") + "'"
398
+ }
399
+
400
+ /**
401
+ * Whether one resolved shell can run through the host executor.
402
+ *
403
+ * Extracted so the decision is testable without spawning anything. See the
404
+ * call site for why Git Bash on Windows is excluded: the sandbox cannot create
405
+ * the MSYS process, so delegation would fail every command.
406
+ *
407
+ * @param {string} kind the resolved shell kind
408
+ * @param {string} platform `process.platform`
409
+ * @param {boolean} hasHost whether a usable `ctx.shell` is composed
410
+ * @returns {boolean} whether to call `runThroughHost`
411
+ */
412
+ export function shouldDelegate(kind, platform, hasHost) {
413
+ if (hasHost !== true) return false
414
+ if (platform === 'win32') return kind === 'pwsh' || kind === 'windows-powershell'
415
+ return kind === 'posix-bash'
416
+ }
417
+
418
+ /**
419
+ * The command a HOST EXECUTOR runs to start the SELECTED interpreter.
420
+ *
421
+ * Why not just hand the user's command to `ctx.shell`: on Windows the executor
422
+ * is always pwsh, and the mode may have chosen Git Bash. The delegation has to
423
+ * name the executable explicitly, or "Git Bash mode" would quietly run
424
+ * PowerShell. The quoting above is what makes an arbitrary command (quotes,
425
+ * `$`, `&&`, newlines) survive the extra shell layer.
426
+ *
427
+ * @param {{ kind: string, path: string }} shell the selected interpreter
428
+ * @param {string} command the user's command
429
+ * @param {string} [platform] `process.platform`
430
+ * @returns {string} the host-executor command string
431
+ */
432
+ export function delegatedCommand(shell, command, platform = process.platform) {
433
+ const script = String(command == null ? '' : command)
434
+ if (platform === 'win32') {
435
+ const exe = quotePowerShell(shell.path)
436
+ if (shell.kind === 'gitbash' || shell.kind === 'posix-bash') {
437
+ return '& ' + exe + ' -lc ' + quotePowerShell(script)
438
+ }
439
+ return '& ' + exe + ' -NoProfile -NonInteractive -Command ' + quotePowerShell(script)
440
+ }
441
+ return quoteSh(shell.path) + ' -lc ' + quoteSh(script)
442
+ }
443
+
444
+ /**
445
+ * Run one command through the host's shell executor.
446
+ *
447
+ * The executor owns sandboxing, approval facts, cancellation and process
448
+ * lifetime; this function only translates the request and the result into
449
+ * `run_shell`'s shape. `result()` rejects on infrastructure failures, which
450
+ * propagate to the tool layer like any other tool error.
451
+ *
452
+ * @param {object} hostShell the `ctx.shell` service
453
+ * @param {{ kind: string, path: string, workdir?: string }} shell the selected interpreter
454
+ * @param {{ command?: string, workdir?: string }} args the call arguments
455
+ * @param {{ signal?: AbortSignal }} [exec] the tool execution context
456
+ * @returns {Promise<object>} the tool result
457
+ */
458
+ export async function runThroughHost(hostShell, shell, args, exec = {}) {
459
+ const command = typeof args?.command === 'string' ? args.command : ''
460
+ if (command.trim() === '') throw new Error('run_shell needs a non-empty `command`')
461
+ const workdir = typeof args?.workdir === 'string' && args.workdir !== ''
462
+ ? args.workdir
463
+ : shell.workdir
464
+ const request = {
465
+ command: delegatedCommand(shell, command, process.platform),
466
+ ...(workdir === undefined ? {} : { workdir }),
467
+ ...(exec.signal === undefined ? {} : { signal: exec.signal }),
468
+ }
469
+ const handle = await hostShell.execute(hostShell.resolve(request))
470
+ const result = await handle.result()
471
+ return {
472
+ shell: shell.path,
473
+ kind: shell.kind,
474
+ via: 'host',
475
+ // A signal kill has no exit code. Keep the integer contract the schema
476
+ // declares; the renderer states the signal separately when it matters.
477
+ exitCode: Number.isInteger(result?.exitCode) ? result.exitCode : -1,
478
+ stdout: typeof result?.stdout?.text === 'string' ? result.stdout.text : '',
479
+ stderr: typeof result?.stderr?.text === 'string' ? result.stderr.text : '',
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Resolve the working directory for one call.
485
+ *
486
+ * The description promises "the session working directory" when `workdir` is
487
+ * absent. The host's `workingDirectory.ensure()` is where that fact lives; it is
488
+ * optional (a minimal profile has no such service), in which case the executor
489
+ * keeps its own default.
490
+ *
491
+ * @param {{ workdir?: string }} args the call arguments
492
+ * @param {{ agent?: object, signal?: AbortSignal }} [exec] the tool execution context
493
+ * @param {() => object | undefined} [getService] reads `ctx.workingDirectory`
494
+ * @returns {Promise<string | undefined>} the directory, when one is known
495
+ */
496
+ export async function resolveWorkdir(args, exec, getService) {
497
+ if (typeof args?.workdir === 'string' && args.workdir !== '') return args.workdir
498
+ const service = typeof getService === 'function' ? getService() : undefined
499
+ if (service === undefined || exec?.agent === undefined || typeof service.ensure !== 'function') return undefined
500
+ try {
501
+ return await service.ensure(exec.agent, exec.signal)
502
+ } catch {
503
+ return undefined
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Run an argv and resolve the outcome instead of rejecting on a nonzero exit.
509
+ *
510
+ * @param {string} command the executable
511
+ * @param {string[]} args its arguments
512
+ * @param {string} [cwd] working directory
513
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
514
+ */
515
+ function defaultRun(command, args, cwd) {
516
+ return new Promise((resolve, reject) => {
517
+ execFile(command, args, {
518
+ cwd: cwd === undefined ? undefined : cwd,
519
+ windowsHide: true,
520
+ timeout: 120_000,
521
+ maxBuffer: 8 * 1024 * 1024,
522
+ }, (error, stdout, stderr) => {
523
+ // A spawn failure has no exit code and no output; that is the only case
524
+ // worth rejecting.
525
+ if (error !== null && typeof error.code !== 'number') {
526
+ reject(error)
527
+ return
528
+ }
529
+ resolve({
530
+ exitCode: error === null ? 0 : error.code,
531
+ stdout: String(stdout ?? ''),
532
+ stderr: String(stderr ?? ''),
533
+ })
534
+ })
535
+ })
536
+ }
537
+
538
+ export { execAsync, readMode }
539
+
540
+ /**
541
+ * Memoize a resolver for one resolution pass.
542
+ *
543
+ * The injection asks for all three modes, and `auto` itself asks for two kinds.
544
+ * Without this, one page load would spawn `git --exec-path` three times and
545
+ * `where pwsh` twice — the probe is the expensive part (~77 ms), not the choice.
546
+ * A FRESH memo per pass, not a module-level one: a cache that outlives the call
547
+ * would answer tomorrow's question with today's machine.
548
+ *
549
+ * @param {(kind: string) => object | null} resolve
550
+ * @returns {(kind: string) => object | null}
551
+ */
552
+ function memoized(resolve) {
553
+ const seen = new Map()
554
+ return (kind) => {
555
+ if (!seen.has(kind)) seen.set(kind, resolve(kind))
556
+ return seen.get(kind)
557
+ }
558
+ }