dsh-gitbash-shell 0.42.2 → 0.43.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 +183 -0
- package/dsh.plugin.json +1 -1
- package/package.json +3 -3
- package/src/bash-render.js +1 -1
- package/src/bash-tool.js +1 -1
- package/src/client.js +210 -22
- package/src/fastctx-bridge.js +124 -54
- package/src/index.js +46 -11
- package/src/native-executor.js +2 -1
- package/src/native-tool.js +5 -2
- package/src/tool-runner.js +245 -44
- package/src/toolchain.js +16 -10
package/src/tool-runner.js
CHANGED
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
* module is testable without winget, Windows, or a network.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
29
|
-
import { readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
28
|
+
import { spawn } from 'node:child_process'
|
|
29
|
+
import { readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
30
|
+
import { randomUUID } from 'node:crypto'
|
|
30
31
|
import { tmpdir } from 'node:os'
|
|
31
32
|
import { join } from 'node:path'
|
|
32
33
|
|
|
@@ -41,6 +42,46 @@ import {
|
|
|
41
42
|
const PROBE_TIMEOUT_MS = 60_000
|
|
42
43
|
const INSTALL_TIMEOUT_MS = 15 * 60_000
|
|
43
44
|
|
|
45
|
+
/** winget's own download-retry chatter (issue #35): when this shows up and the
|
|
46
|
+
* process is STILL running one window later, the download is wedged — DO
|
|
47
|
+
* falls back to WinINet, retries, and would otherwise burn the whole 15-minute
|
|
48
|
+
* ceiling in silence. */
|
|
49
|
+
export const NETWORK_RETRY_PATTERN = /failed to download, waiting a bit and retry|0x80072ee2|0x80072efd/i
|
|
50
|
+
const NETWORK_RETRY_WINDOW_MS = 120_000
|
|
51
|
+
/** Hard cap on the collected output of one run (mirrors the old maxBuffer). */
|
|
52
|
+
const OUTPUT_CAP_BYTES = 16 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
/** Kill a whole process tree. */
|
|
55
|
+
function killTree(child) {
|
|
56
|
+
try {
|
|
57
|
+
if (!child || child.pid === undefined) return
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
60
|
+
} else {
|
|
61
|
+
child.kill('SIGKILL')
|
|
62
|
+
}
|
|
63
|
+
} catch { /* best effort — the verdict does not depend on the kill landing */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sort a run result that ended through cancel / hard timeout / wedged
|
|
67
|
+
* download from an ordinary winget failure (issue #35).
|
|
68
|
+
* Only the run's OWN `cancelled` flag counts here: a cancel that lands AFTER
|
|
69
|
+
* winget already exited must not rewrite a finished install into a cancelled
|
|
70
|
+
* one — the loop skips the remaining entries instead. */
|
|
71
|
+
function verdictFrom(result) {
|
|
72
|
+
if (result && result.cancelled === true) return { ok: false, kind: 'cancelled', detail: 'cancelled by the user' }
|
|
73
|
+
if (result && result.timedOut === true) return { ok: false, kind: 'timeout', detail: tailLine(result.output) || 'hard timeout' }
|
|
74
|
+
if (result && result.networkRetry === true) return { ok: false, kind: 'network', detail: tailLine(result.output) || 'download retried without progress' }
|
|
75
|
+
return classifyWingetResult(result.code, result.output)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The freshest full line of a run's output, capped — the one signal a stuck
|
|
79
|
+
* download actually prints ("Failed to download, waiting a bit and retry"). */
|
|
80
|
+
function tailLine(text) {
|
|
81
|
+
const lines = String(text || '').trimEnd().split(/\r?\n/)
|
|
82
|
+
return (lines[lines.length - 1] || '').slice(0, 300)
|
|
83
|
+
}
|
|
84
|
+
|
|
44
85
|
/** Only one job at a time: winget serializes on its own state anyway, and two
|
|
45
86
|
* concurrent installs is how a half-applied catalog happens. */
|
|
46
87
|
let activeJob = null
|
|
@@ -48,27 +89,109 @@ let activeJob = null
|
|
|
48
89
|
/**
|
|
49
90
|
* The real IO. `run` never rejects: a non-zero exit is data, and a spawn
|
|
50
91
|
* failure becomes a synthetic exit code so callers have one shape to handle.
|
|
92
|
+
*
|
|
93
|
+
* v0.43.0 (issue #35): the old `execFile` buffered the child's whole output,
|
|
94
|
+
* so a wedged winget download looked like "processing" for 15 minutes with
|
|
95
|
+
* nothing to watch and no way out. `spawn` streams instead: `hooks.onOutput`
|
|
96
|
+
* receives each line as it arrives, a hard timeout, an external
|
|
97
|
+
* `cancelActive()` and the download-retry watchdog below all end the run by
|
|
98
|
+
* killing the WHOLE process tree (winget dispatches forwarders / msiexec /
|
|
99
|
+
* DeliveryOptimization children — killing only the parent leaves them), and
|
|
100
|
+
* the result carries `cancelled` / `timedOut` / `networkRetry` so callers can
|
|
101
|
+
* sort those endings from ordinary failures.
|
|
102
|
+
* @param {{ networkRetryWindowMs?: number }} [options]
|
|
51
103
|
* @returns {object}
|
|
52
104
|
*/
|
|
53
|
-
export function defaultRunnerIo() {
|
|
105
|
+
export function defaultRunnerIo(options = {}) {
|
|
106
|
+
const networkRetryWindowMs = Number(options.networkRetryWindowMs) > 0
|
|
107
|
+
? Number(options.networkRetryWindowMs)
|
|
108
|
+
: NETWORK_RETRY_WINDOW_MS
|
|
109
|
+
/** The in-flight run: `{ child, flags }`, or null between runs. */
|
|
110
|
+
let current = null
|
|
54
111
|
return {
|
|
55
|
-
run(file, args, timeoutMs) {
|
|
112
|
+
run(file, args, timeoutMs, hooks) {
|
|
113
|
+
const onOutput = hooks && typeof hooks.onOutput === 'function' ? hooks.onOutput : null
|
|
56
114
|
return new Promise((resolve) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
115
|
+
let settled = false
|
|
116
|
+
const flags = {}
|
|
117
|
+
const child = spawn(file, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
118
|
+
current = { child, flags }
|
|
119
|
+
let output = ''
|
|
120
|
+
let bytes = 0
|
|
121
|
+
const swallow = (chunk) => {
|
|
122
|
+
bytes += chunk.length
|
|
123
|
+
if (bytes <= OUTPUT_CAP_BYTES) output += chunk.toString('utf8')
|
|
124
|
+
if (onOutput !== null) {
|
|
125
|
+
// Keep only the freshest line (a 300-char tail): the card shows one
|
|
126
|
+
// grey line under the progress, not a log.
|
|
127
|
+
const line = String(chunk).split(/\r?\n/).filter(Boolean).pop()
|
|
128
|
+
if (line) onOutput(line.slice(0, 300))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
child.stdout.on('data', swallow)
|
|
132
|
+
child.stderr.on('data', swallow)
|
|
133
|
+
// The wedged-download watchdog: from the FIRST retry line, a process
|
|
134
|
+
// still alive one window later is not making progress (winget cycles
|
|
135
|
+
// DO → WinINet → "waiting a bit and retry" for minutes). One window,
|
|
136
|
+
// once — a slow but working download never sees it.
|
|
137
|
+
let retryTimer = null
|
|
138
|
+
child.stderr.on('data', (chunk) => {
|
|
139
|
+
if (settled || flags.networkRetry || flags.timedOut || flags.cancelled || retryTimer !== null) return
|
|
140
|
+
if (NETWORK_RETRY_PATTERN.test(String(chunk))) {
|
|
141
|
+
retryTimer = setTimeout(() => {
|
|
142
|
+
retryTimer = null
|
|
143
|
+
if (settled || flags.timedOut || flags.cancelled) return
|
|
144
|
+
flags.networkRetry = true
|
|
145
|
+
killTree(child)
|
|
146
|
+
}, networkRetryWindowMs)
|
|
62
147
|
}
|
|
63
|
-
|
|
148
|
+
})
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
if (settled || flags.networkRetry || flags.cancelled) return
|
|
151
|
+
flags.timedOut = true
|
|
152
|
+
killTree(child)
|
|
153
|
+
}, timeoutMs || PROBE_TIMEOUT_MS)
|
|
154
|
+
child.on('error', (error) => {
|
|
155
|
+
if (settled) return
|
|
156
|
+
settled = true
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null }
|
|
159
|
+
if (current !== null && current.child === child) current = null
|
|
160
|
+
resolve({ code: -1, output: output || String((error && error.message) || error) })
|
|
161
|
+
})
|
|
162
|
+
child.on('close', (code) => {
|
|
163
|
+
if (settled) return
|
|
164
|
+
settled = true
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null }
|
|
167
|
+
if (current !== null && current.child === child) current = null
|
|
168
|
+
resolve({
|
|
169
|
+
code: typeof code === 'number' ? code : -1,
|
|
170
|
+
output,
|
|
171
|
+
...(flags.cancelled === true ? { cancelled: true } : {}),
|
|
172
|
+
...(flags.timedOut === true ? { timedOut: true } : {}),
|
|
173
|
+
...(flags.networkRetry === true ? { networkRetry: true } : {}),
|
|
174
|
+
})
|
|
64
175
|
})
|
|
65
176
|
})
|
|
66
177
|
},
|
|
178
|
+
/** Abort the in-flight run (issue #35). A no-op between runs; the run
|
|
179
|
+
* resolves with `cancelled: true` once the tree is down. */
|
|
180
|
+
cancelActive() {
|
|
181
|
+
const entry = current
|
|
182
|
+
if (!entry || entry.child.exitCode !== null || entry.child.signalCode !== null) return false
|
|
183
|
+
entry.flags.cancelled = true
|
|
184
|
+
killTree(entry.child)
|
|
185
|
+
return true
|
|
186
|
+
},
|
|
67
187
|
pathDirs() {
|
|
68
188
|
return String(process.env.PATH || '').split(';').filter(Boolean)
|
|
69
189
|
},
|
|
70
190
|
readdir(dir) {
|
|
71
|
-
return readdirSync(dir)
|
|
191
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
192
|
+
},
|
|
193
|
+
isFile(path) {
|
|
194
|
+
try { return statSync(path).isFile() } catch { return false }
|
|
72
195
|
},
|
|
73
196
|
readFile(path) {
|
|
74
197
|
return readFileSync(path, 'utf8')
|
|
@@ -82,6 +205,27 @@ export function defaultRunnerIo() {
|
|
|
82
205
|
}
|
|
83
206
|
}
|
|
84
207
|
|
|
208
|
+
/** Export one authoritative inventory into a per-call owned path. Failure is not absence. */
|
|
209
|
+
async function readWingetInventory(io) {
|
|
210
|
+
const exportPath = io.tmpFile(`dsh-gitbash-shell-wg-export-${randomUUID()}.json`)
|
|
211
|
+
try {
|
|
212
|
+
const result = await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS)
|
|
213
|
+
if (result.code !== 0) throw new Error(`export exited with code ${result.code}`)
|
|
214
|
+
const text = io.readFile(exportPath)
|
|
215
|
+
const json = JSON.parse(text)
|
|
216
|
+
if (!json || !Array.isArray(json.Sources) || json.Sources.some(source =>
|
|
217
|
+
!source || typeof source.SourceDetails?.Name !== 'string' || !Array.isArray(source.Packages)
|
|
218
|
+
|| source.Packages.some(pkg => !pkg || typeof pkg.PackageIdentifier !== 'string'))) {
|
|
219
|
+
throw new Error('invalid export structure')
|
|
220
|
+
}
|
|
221
|
+
return parseExportJson(text, TOOLCHAIN)
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new Error(`winget inventory unavailable: ${error?.message ?? error}`)
|
|
224
|
+
} finally {
|
|
225
|
+
io.remove(exportPath)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
85
229
|
/**
|
|
86
230
|
* The whole catalog's state in four winget/PATH reads: one `--version` probe,
|
|
87
231
|
* one `export` inventory, one `--upgrade-available` list, one PATH scan.
|
|
@@ -108,16 +252,7 @@ export async function probeToolchain(io) {
|
|
|
108
252
|
}
|
|
109
253
|
}
|
|
110
254
|
|
|
111
|
-
const
|
|
112
|
-
let owned = new Map()
|
|
113
|
-
try {
|
|
114
|
-
await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS)
|
|
115
|
-
owned = parseExportJson(io.readFile(exportPath), TOOLCHAIN)
|
|
116
|
-
} catch {
|
|
117
|
-
owned = new Map()
|
|
118
|
-
} finally {
|
|
119
|
-
io.remove(exportPath)
|
|
120
|
-
}
|
|
255
|
+
const owned = await readWingetInventory(io)
|
|
121
256
|
|
|
122
257
|
let upgradable = new Set()
|
|
123
258
|
try {
|
|
@@ -188,8 +323,18 @@ export async function runToolJob(io, request, onChange) {
|
|
|
188
323
|
total: entries.length,
|
|
189
324
|
done: 0,
|
|
190
325
|
current: '',
|
|
326
|
+
// v0.43.0 (issue #35): observability + cancellation. `currentSince` times
|
|
327
|
+
// the entry in flight, `lastOutput` mirrors the freshest winget line,
|
|
328
|
+
// `cancelled` is set by cancelToolJob(), and `io` is kept so the cancel
|
|
329
|
+
// path can reach the process tree. The route's snapshot picks fields
|
|
330
|
+
// explicitly, so the io reference never reaches the wire.
|
|
331
|
+
startedAt: Date.now(),
|
|
332
|
+
currentSince: 0,
|
|
333
|
+
lastOutput: '',
|
|
334
|
+
cancelled: false,
|
|
191
335
|
finished: false,
|
|
192
336
|
results: [],
|
|
337
|
+
io,
|
|
193
338
|
}
|
|
194
339
|
activeJob = job
|
|
195
340
|
|
|
@@ -197,42 +342,73 @@ export async function runToolJob(io, request, onChange) {
|
|
|
197
342
|
// the installed package id (GNU vs MSVC ripgrep), never the preferred one.
|
|
198
343
|
let owned = new Map()
|
|
199
344
|
if (entries.length > 0) {
|
|
200
|
-
const exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json')
|
|
201
345
|
try {
|
|
202
|
-
await io
|
|
203
|
-
|
|
204
|
-
|
|
346
|
+
owned = await readWingetInventory(io)
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (job.cancelled) {
|
|
349
|
+
// The cancel landed while the inventory was being read: nothing was
|
|
350
|
+
// attempted, so nothing may be reported as a failure.
|
|
351
|
+
job.results = entries.map((tool) => ({
|
|
352
|
+
id: tool.id, cmd: tool.cmd, action: mode === 'upgrade' ? 'upgrade' : 'install',
|
|
353
|
+
wingetId: tool.ids[0], admin: tool.admin === true, ok: false, kind: 'skipped',
|
|
354
|
+
detail: 'cancelled before this entry started',
|
|
355
|
+
}))
|
|
356
|
+
job.done = entries.length
|
|
357
|
+
job.current = ''
|
|
358
|
+
job.finished = true
|
|
359
|
+
if (onChange) onChange(job)
|
|
360
|
+
return job
|
|
361
|
+
}
|
|
362
|
+
// Do not choose a preferred build or start an installer from an unknown inventory.
|
|
363
|
+
job.results = entries.map(tool => ({
|
|
364
|
+
id: tool.id, cmd: tool.cmd, action: mode === 'upgrade' ? 'upgrade' : 'install',
|
|
365
|
+
wingetId: tool.ids[0], admin: tool.admin === true, ok: false, kind: 'failed',
|
|
366
|
+
detail: `${error?.message ?? error}; no install or upgrade was attempted`,
|
|
367
|
+
}))
|
|
368
|
+
job.done = entries.length
|
|
369
|
+
job.finished = true
|
|
370
|
+
if (onChange) onChange(job)
|
|
371
|
+
return job
|
|
372
|
+
}
|
|
205
373
|
}
|
|
206
374
|
|
|
207
375
|
for (const tool of entries) {
|
|
208
|
-
job.current = tool.cmd
|
|
209
|
-
if (onChange) onChange(job)
|
|
210
376
|
const installed = owned.get(tool.id)
|
|
211
377
|
const useUpgrade = mode === 'upgrade' || (mode === 'auto' && !!installed)
|
|
378
|
+
const base = {
|
|
379
|
+
id: tool.id,
|
|
380
|
+
cmd: tool.cmd,
|
|
381
|
+
action: useUpgrade ? 'upgrade' : 'install',
|
|
382
|
+
wingetId: installed ? installed.pkgId : tool.ids[0],
|
|
383
|
+
admin: tool.admin === true,
|
|
384
|
+
}
|
|
385
|
+
// A cancel lands between entries too: the remaining ones are skipped, not
|
|
386
|
+
// failed (issue #35) — the user asked to stop, not to be told it broke.
|
|
387
|
+
if (job.cancelled) {
|
|
388
|
+
job.results.push({ ...base, ok: false, kind: 'skipped', detail: 'cancelled before this entry started' })
|
|
389
|
+
job.done += 1
|
|
390
|
+
if (onChange) onChange(job)
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
job.current = tool.cmd
|
|
394
|
+
job.currentSince = Date.now()
|
|
395
|
+
if (onChange) onChange(job)
|
|
212
396
|
const argv = useUpgrade ? upgradeArgv(tool, installed && installed.pkgId) : installArgv(tool)
|
|
397
|
+
const hooks = { onOutput: (line) => { job.lastOutput = line } }
|
|
213
398
|
let verdict
|
|
214
399
|
try {
|
|
215
|
-
const result = await io.run('winget', argv, INSTALL_TIMEOUT_MS)
|
|
216
|
-
verdict =
|
|
400
|
+
const result = await io.run('winget', argv, INSTALL_TIMEOUT_MS, hooks)
|
|
401
|
+
verdict = verdictFrom(result)
|
|
217
402
|
// An explicit "install" on something winget already owns is really an
|
|
218
403
|
// upgrade request; retry as one so the user's click does the obvious thing.
|
|
219
|
-
if (!verdict.ok && !useUpgrade && verdict.kind === 'not-found' && installed) {
|
|
220
|
-
const retry = await io.run('winget', upgradeArgv(tool, installed.pkgId), INSTALL_TIMEOUT_MS)
|
|
221
|
-
verdict =
|
|
404
|
+
if (!verdict.ok && !useUpgrade && verdict.kind === 'not-found' && installed && !job.cancelled) {
|
|
405
|
+
const retry = await io.run('winget', upgradeArgv(tool, installed.pkgId), INSTALL_TIMEOUT_MS, hooks)
|
|
406
|
+
verdict = verdictFrom(retry)
|
|
222
407
|
}
|
|
223
408
|
} catch (error) {
|
|
224
409
|
verdict = { ok: false, kind: 'failed', detail: String((error && error.message) || error) }
|
|
225
410
|
}
|
|
226
|
-
job.results.push({
|
|
227
|
-
id: tool.id,
|
|
228
|
-
cmd: tool.cmd,
|
|
229
|
-
action: useUpgrade ? 'upgrade' : 'install',
|
|
230
|
-
wingetId: installed ? installed.pkgId : tool.ids[0],
|
|
231
|
-
admin: tool.admin === true,
|
|
232
|
-
ok: verdict.ok,
|
|
233
|
-
kind: verdict.kind,
|
|
234
|
-
detail: verdict.detail,
|
|
235
|
-
})
|
|
411
|
+
job.results.push({ ...base, ok: verdict.ok, kind: verdict.kind, detail: verdict.detail })
|
|
236
412
|
job.done += 1
|
|
237
413
|
if (onChange) onChange(job)
|
|
238
414
|
}
|
|
@@ -257,9 +433,34 @@ export function currentToolJob() {
|
|
|
257
433
|
return activeJob
|
|
258
434
|
}
|
|
259
435
|
|
|
260
|
-
/**
|
|
436
|
+
/**
|
|
437
|
+
* Cancel the active job: mark it, then kill the whole winget process tree so
|
|
438
|
+
* the in-flight entry ends immediately and the loop marks the rest skipped
|
|
439
|
+
* (issue #35). A no-op on a finished or missing job, or on an id mismatch.
|
|
440
|
+
* @param {string} [id] - when given, only that job is cancelled.
|
|
441
|
+
* @returns {object} the (possibly unchanged) current job.
|
|
442
|
+
*/
|
|
443
|
+
export function cancelToolJob(id) {
|
|
444
|
+
const job = activeJob
|
|
445
|
+
if (!job || job.finished) return currentToolJob()
|
|
446
|
+
if (id && job.id !== id) return job
|
|
447
|
+
job.cancelled = true
|
|
448
|
+
try { if (job.io && typeof job.io.cancelActive === 'function') job.io.cancelActive() } catch { /* best effort */ }
|
|
449
|
+
return job
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Forget a finished job (called when the card closes the progress view).
|
|
453
|
+
* Closing it MID-RUN now cancels instead: the old clear just dropped the
|
|
454
|
+
* activeJob lock and let a second job race the still-running winget — a
|
|
455
|
+
* half-applied catalog was one click away (issue #35). */
|
|
261
456
|
export function clearToolJob(id) {
|
|
262
|
-
|
|
457
|
+
const job = activeJob
|
|
458
|
+
if (!job) return
|
|
459
|
+
if (!job.finished) {
|
|
460
|
+
cancelToolJob(id)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
if (!id || job.id === id) activeJob = null
|
|
263
464
|
}
|
|
264
465
|
|
|
265
466
|
// ── request fencing ─────────────────────────────────────────────────────────
|
package/src/toolchain.js
CHANGED
|
@@ -229,7 +229,7 @@ const EXEC_EXTS = new Set(['.exe', '.cmd', '.bat', '.com'])
|
|
|
229
229
|
* Only EXECUTABLE entries resolve (see EXEC_EXTS): a man page (`just.1`) or a
|
|
230
230
|
* document (`jq.md`) shares the stem without being the command, and issue #31
|
|
231
231
|
* measured a man page winning the first-hit race over the `just.exe` beside it.
|
|
232
|
-
* @param {{pathDirs: () => string[], readdir: (dir: string) => string
|
|
232
|
+
* @param {{pathDirs: () => string[], readdir: (dir: string) => Array, isFile?: (path: string) => boolean}} io
|
|
233
233
|
* @param {Array} tools - catalog entries to look for.
|
|
234
234
|
* @returns {Map<string, string>} cmd → the first resolving full path.
|
|
235
235
|
*/
|
|
@@ -247,17 +247,23 @@ export function scanCommands(io, tools) {
|
|
|
247
247
|
try { entries = io.readdir(dir) } catch { continue }
|
|
248
248
|
if (!Array.isArray(entries)) continue
|
|
249
249
|
for (const entry of entries) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// resolving to a command it merely shares a stem with.
|
|
254
|
-
const suffix = /(\.[^./\\]+)$/.exec(entry)
|
|
250
|
+
const name = typeof entry === 'string' ? entry : entry?.name
|
|
251
|
+
if (typeof name !== 'string') continue
|
|
252
|
+
const suffix = /(\.[^./\\]+)$/.exec(name)
|
|
255
253
|
if (suffix !== null && !EXEC_EXTS.has(suffix[1].toLowerCase())) continue
|
|
256
|
-
const base = (suffix === null ?
|
|
254
|
+
const base = (suffix === null ? name : name.slice(0, suffix.index)).toLowerCase()
|
|
257
255
|
const cmd = wanted.get(base)
|
|
258
|
-
if (cmd
|
|
259
|
-
|
|
260
|
-
|
|
256
|
+
if (cmd === undefined || found.has(cmd)) continue
|
|
257
|
+
const fullPath = dir.replace(/[\\/]+$/, '') + '\\' + name
|
|
258
|
+
try {
|
|
259
|
+
// Production IO supplies Dirents. Follow only symlink candidates to a file;
|
|
260
|
+
// directories (even make / jq.exe) and broken links never satisfy a command.
|
|
261
|
+
// String-only injected IO remains supported for legacy deterministic fixtures.
|
|
262
|
+
const file = typeof entry === 'string'
|
|
263
|
+
? (typeof io.isFile !== 'function' || io.isFile(fullPath))
|
|
264
|
+
: (entry.isFile?.() === true || (entry.isSymbolicLink?.() === true && io.isFile?.(fullPath) === true))
|
|
265
|
+
if (file) found.set(cmd, fullPath)
|
|
266
|
+
} catch { /* an unreadable or disappearing candidate is not a command */ }
|
|
261
267
|
}
|
|
262
268
|
}
|
|
263
269
|
return found
|