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.
@@ -25,8 +25,9 @@
25
25
  * module is testable without winget, Windows, or a network.
26
26
  */
27
27
 
28
- import { execFile } from 'node:child_process'
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
- execFile(file, args, { windowsHide: true, timeout: timeoutMs || PROBE_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
58
- const output = String(stdout || '') + String(stderr || '')
59
- if (error) {
60
- const code = typeof error.code === 'number' ? error.code : -1
61
- return resolve({ code, output: output || String(error.message || '') })
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
- resolve({ code: 0, output })
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 exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json')
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.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS)
203
- owned = parseExportJson(io.readFile(exportPath), TOOLCHAIN)
204
- } catch { owned = new Map() } finally { io.remove(exportPath) }
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 = classifyWingetResult(result.code, result.output)
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 = classifyWingetResult(retry.code, retry.output)
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
- /** Forget a finished job (called when the card closes the progress view). */
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
- if (activeJob && (!id || activeJob.id === id)) activeJob = null
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[]}} io
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
- if (typeof entry !== 'string') continue
251
- // The LAST extension decides, exactly as before — but it now has to be
252
- // an executable one, so a man page/doc/directory is skipped instead of
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 ? entry : entry.slice(0, suffix.index)).toLowerCase()
254
+ const base = (suffix === null ? name : name.slice(0, suffix.index)).toLowerCase()
257
255
  const cmd = wanted.get(base)
258
- if (cmd !== undefined && !found.has(cmd)) {
259
- found.set(cmd, dir.replace(/[\\/]+$/, '') + '\\' + entry)
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