dsh-gitbash-shell 0.29.2 → 0.30.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/src/index.js CHANGED
@@ -255,6 +255,10 @@ import {
255
255
  effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached, settingsBashPath,
256
256
  } from './bash-path.js'
257
257
  import { adoptTerminalShell, terminalAdoptReport } from './terminal-shell.js'
258
+ import {
259
+ clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
260
+ probeToolchain, readJsonBody, startToolJob,
261
+ } from './tool-runner.js'
258
262
 
259
263
  /** The shipped preset whose skills/ dir seeds cordis-gitbash. */
260
264
  const SKILLS_SOURCE_PRESET = 'cordis'
@@ -2772,6 +2776,90 @@ export async function apply(ctx, config = {}) {
2772
2776
  }
2773
2777
  }
2774
2778
 
2779
+ // ── winget toolchain route (v0.30.0) ─────────────────────────────────────
2780
+ // The settings card's "install / upgrade" buttons. Installing software is a
2781
+ // real side effect, so src/tool-runner.js fences the route: only a loopback
2782
+ // caller with a same-origin Origin/Referer gets through, and the ids in the
2783
+ // body are mapped onto the CLOSED catalog before any argv is built. The
2784
+ // request can therefore never name a package winget would install.
2785
+ //
2786
+ // GET (no query) → full status: every catalog row + the live job snapshot
2787
+ // GET ?job=1 → the job snapshot ONLY (the cheap poll while installing)
2788
+ // POST ?action=run → start a job; 409 while one is already running
2789
+ // POST ?action=clear→ forget a finished job
2790
+ if (process.platform === 'win32') {
2791
+ try {
2792
+ ctx.inject(['webServer'], (wctx) => {
2793
+ try {
2794
+ const route = '/dsh-gitbash-shell/api/tools'
2795
+ const io = defaultRunnerIo()
2796
+ const send = (res, code, payload) => {
2797
+ const text = JSON.stringify(payload)
2798
+ res.writeHead(code, {
2799
+ 'content-type': 'application/json; charset=utf-8',
2800
+ 'cache-control': 'no-store',
2801
+ 'content-length': Buffer.byteLength(text),
2802
+ })
2803
+ res.end(text)
2804
+ }
2805
+ const snapshot = () => {
2806
+ const job = currentToolJob()
2807
+ if (!job) return null
2808
+ return {
2809
+ id: job.id, action: job.action, total: job.total, done: job.done,
2810
+ current: job.current, finished: job.finished, results: job.results.slice(),
2811
+ }
2812
+ }
2813
+ const handler = async (req, res) => {
2814
+ const fence = fenceToolRequest(req)
2815
+ if (!fence.ok) {
2816
+ console.log(TAG + ' toolchain request refused (' + fence.reason + ')')
2817
+ send(res, 403, { error: 'forbidden', reason: fence.reason })
2818
+ return
2819
+ }
2820
+ let query = new URLSearchParams()
2821
+ try { query = new URL(req.url || '/', 'http://localhost').searchParams } catch { /* keep empty */ }
2822
+ const method = String(req.method || 'GET').toUpperCase()
2823
+ try {
2824
+ if (method === 'POST') {
2825
+ const action = query.get('action') || ''
2826
+ if (action === 'clear') {
2827
+ clearToolJob('')
2828
+ send(res, 200, { ok: true, job: null })
2829
+ return
2830
+ }
2831
+ if (action !== 'run') { send(res, 400, { error: 'bad-action', action }); return }
2832
+ const body = await readJsonBody(req)
2833
+ if (!body) { send(res, 400, { error: 'bad-request' }); return }
2834
+ const started = startToolJob(io, body)
2835
+ if (!started.started) {
2836
+ send(res, 409, { error: 'busy', job: snapshot() })
2837
+ return
2838
+ }
2839
+ send(res, 202, { ok: true, job: snapshot() })
2840
+ return
2841
+ }
2842
+ // The cheap poll: while a job runs the card only needs its progress,
2843
+ // not another three winget invocations.
2844
+ if (query.get('job') === '1') { send(res, 200, { job: snapshot() }); return }
2845
+ const status = await probeToolchain(io)
2846
+ send(res, 200, { ...status, job: snapshot() })
2847
+ } catch (error) {
2848
+ send(res, 500, { error: 'internal', detail: String((error && error.message) || error) })
2849
+ }
2850
+ }
2851
+ const dispose = wctx.webServer.register({ kind: 'prefix', path: route, handler })
2852
+ wctx.effect(() => dispose, 'dsh-gitbash-shell: toolchain route')
2853
+ console.log(TAG + ' winget toolchain route active at ' + route + ' (loopback + same-origin fenced)')
2854
+ } catch (error) {
2855
+ console.log(TAG + ' toolchain route failed: ' + (error?.message ?? error))
2856
+ }
2857
+ })
2858
+ } catch (error) {
2859
+ console.log(TAG + ' toolchain route wiring failed: ' + (error?.message ?? error))
2860
+ }
2861
+ }
2862
+
2775
2863
  // ── OFFICIAL sidebar terminal adoption (v0.29.0, task-25) ──────────────
2776
2864
  // User report: dsh's own "new terminal" started WSL, because
2777
2865
  // `terminal-controller` falls back to the execution environment's default
@@ -0,0 +1,337 @@
1
+ /**
2
+ * dsh-gitbash-shell — winget toolchain runner (v0.30.0).
3
+ *
4
+ * The host half behind the settings card's "install / upgrade" buttons. Three
5
+ * properties matter more than the feature itself:
6
+ *
7
+ * 1. NOTHING HERE TAKES A PACKAGE ID FROM THE REQUEST. A request names
8
+ * CATALOG ids (`toolchain.js`), which are mapped to winget ids inside this
9
+ * module; an unknown id is dropped. The closed catalog is the only thing
10
+ * that can ever reach winget, and `install`/`upgrade` additionally carry
11
+ * `--exact --source winget`.
12
+ * 2. EVERY winget CALL IS `execFile` WITH AN ARGV ARRAY. No shell string is
13
+ * ever built, so no part of a request can become a command.
14
+ * 3. THE ROUTE IS FENCED. Installing software is a real side effect, so the
15
+ * handler refuses anything that is not a loopback caller with a same-origin
16
+ * Origin/Referer — a page the user merely visited must not be able to make
17
+ * their machine install packages.
18
+ *
19
+ * `winget export` is the inventory of record: it is JSON (the localized `list`
20
+ * tables truncate wide ids — measured), it names the exact package the machine
21
+ * has (a GNU-build ripgrep is a different id from the MSVC one — measured), and
22
+ * `--include-versions` carries the installed version.
23
+ *
24
+ * Every process/filesystem fact arrives through the injected `io`, so the whole
25
+ * module is testable without winget, Windows, or a network.
26
+ */
27
+
28
+ import { execFile } from 'node:child_process'
29
+ import { readdirSync, readFileSync, rmSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join } from 'node:path'
32
+
33
+ import {
34
+ TOOLCHAIN, classifyToolState, classifyWingetResult, describeProvider,
35
+ exportArgv, installArgv, isActionable, listUpgradableArgv, parseExportJson,
36
+ parseUpgradable, scanCommands, toolById, upgradeArgv,
37
+ } from './toolchain.js'
38
+
39
+ /** Hard ceiling on one winget run. A UAC prompt waits for the user, so the
40
+ * installing entry is given more room than the read-only probes. */
41
+ const PROBE_TIMEOUT_MS = 60_000
42
+ const INSTALL_TIMEOUT_MS = 15 * 60_000
43
+
44
+ /** Only one job at a time: winget serializes on its own state anyway, and two
45
+ * concurrent installs is how a half-applied catalog happens. */
46
+ let activeJob = null
47
+
48
+ /**
49
+ * The real IO. `run` never rejects: a non-zero exit is data, and a spawn
50
+ * failure becomes a synthetic exit code so callers have one shape to handle.
51
+ * @returns {object}
52
+ */
53
+ export function defaultRunnerIo() {
54
+ return {
55
+ run(file, args, timeoutMs) {
56
+ 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 || '') })
62
+ }
63
+ resolve({ code: 0, output })
64
+ })
65
+ })
66
+ },
67
+ pathDirs() {
68
+ return String(process.env.PATH || '').split(';').filter(Boolean)
69
+ },
70
+ readdir(dir) {
71
+ return readdirSync(dir)
72
+ },
73
+ readFile(path) {
74
+ return readFileSync(path, 'utf8')
75
+ },
76
+ tmpFile(name) {
77
+ return join(tmpdir(), name)
78
+ },
79
+ remove(path) {
80
+ try { rmSync(path, { force: true }) } catch { /* best effort */ }
81
+ },
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The whole catalog's state in four winget/PATH reads: one `--version` probe,
87
+ * one `export` inventory, one `--upgrade-available` list, one PATH scan.
88
+ * @param {object} io - runner IO.
89
+ * @returns {Promise<object>} the status payload the card renders.
90
+ */
91
+ export async function probeToolchain(io) {
92
+ // winget's own probe is async here; probeWinget() in toolchain.js is the
93
+ // synchronous equivalent used by the unit tests.
94
+ const versionRun = await io.run('winget', ['--version'], PROBE_TIMEOUT_MS)
95
+ const versionMatch = String(versionRun.output || '').match(/v?(\d+\.\d+\.\d+)/)
96
+ const wingetOk = versionRun.code === 0 && !!versionMatch
97
+
98
+ let hits = new Map()
99
+ try { hits = scanCommands(io, TOOLCHAIN) } catch { hits = new Map() }
100
+
101
+ if (!wingetOk) {
102
+ // Without winget nothing can be installed or upgraded; still report what is
103
+ // on the machine so the card explains itself instead of showing an error.
104
+ return {
105
+ winget: { ok: false, version: '' },
106
+ tools: TOOLCHAIN.map((tool) => rowFor(tool, { hits, owned: undefined, upgradable: false, wingetOk: false })),
107
+ sourceNote: '',
108
+ }
109
+ }
110
+
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
+ }
121
+
122
+ let upgradable = new Set()
123
+ try {
124
+ const listed = await io.run('winget', listUpgradableArgv(), PROBE_TIMEOUT_MS)
125
+ upgradable = parseUpgradable(listed.output, TOOLCHAIN)
126
+ } catch {
127
+ upgradable = new Set()
128
+ }
129
+
130
+ const tools = TOOLCHAIN.map((tool) => rowFor(tool, { hits, owned: owned.get(tool.id), upgradable: upgradable.has(tool.id), wingetOk: true }))
131
+ return {
132
+ winget: { ok: true, version: versionMatch[1] },
133
+ tools,
134
+ sourceNote: 'winget',
135
+ }
136
+ }
137
+
138
+ /** One card row: the catalog facts plus what this machine actually has. */
139
+ function rowFor(tool, facts) {
140
+ const path = facts.hits.get(tool.cmd) || ''
141
+ const owned = facts.owned
142
+ const state = facts.wingetOk
143
+ ? classifyToolState({ hasCommand: path !== '', wingetOwned: !!owned, upgradable: !!facts.upgradable })
144
+ : (path !== '' ? 'external' : 'missing')
145
+ return {
146
+ id: tool.id,
147
+ cmd: tool.cmd,
148
+ group: tool.group,
149
+ admin: tool.admin === true,
150
+ wingetId: owned ? owned.pkgId : tool.ids[0],
151
+ publisher: tool.publisher || '',
152
+ source: tool.source || '',
153
+ note: tool.note || '',
154
+ state,
155
+ actionable: isActionable(state),
156
+ // Present only when the command resolves; for a winget-owned package whose
157
+ // MSI did not add its directory to PATH this stays empty and the row is
158
+ // still `current`, which is the honest description.
159
+ commandPath: path,
160
+ provider: owned ? 'winget' : (path ? describeProvider(path) : ''),
161
+ version: owned && owned.version ? owned.version : '',
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Install or upgrade the requested catalog entries, one at a time, recording
167
+ * progress in the module-level job so the card can poll it. Returns when the
168
+ * whole batch is done; callers that do not want to wait use `startToolJob`.
169
+ * @param {object} io - runner IO.
170
+ * @param {{ action: string, ids: string[] }} request
171
+ * @param {(job: object) => void} [onChange] - called after each entry.
172
+ * @returns {Promise<object>} the finished job.
173
+ */
174
+ export async function runToolJob(io, request, onChange) {
175
+ // 'auto' (what the card sends) lets the inventory decide per entry: a package
176
+ // winget already owns is UPGRADED, anything else is INSTALLED. An explicit
177
+ // 'install'/'upgrade' still wins when a caller states one.
178
+ const requested = request && typeof request.action === 'string' ? request.action : 'auto'
179
+ const mode = requested === 'install' || requested === 'upgrade' ? requested : 'auto'
180
+ const wanted = Array.isArray(request && request.ids) ? request.ids : []
181
+ // Map request ids onto the CLOSED catalog; unknown ids are dropped, never
182
+ // forwarded. Duplicates collapse so a request cannot install twice.
183
+ const entries = [...new Set(wanted)].map((id) => toolById(id)).filter(Boolean)
184
+
185
+ const job = {
186
+ id: String(Date.now()) + '-' + Math.random().toString(36).slice(2, 8),
187
+ action: mode,
188
+ total: entries.length,
189
+ done: 0,
190
+ current: '',
191
+ finished: false,
192
+ results: [],
193
+ }
194
+ activeJob = job
195
+
196
+ // Resolve what winget actually owns BEFORE upgrading: an upgrade must target
197
+ // the installed package id (GNU vs MSVC ripgrep), never the preferred one.
198
+ let owned = new Map()
199
+ if (entries.length > 0) {
200
+ const exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json')
201
+ 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) }
205
+ }
206
+
207
+ for (const tool of entries) {
208
+ job.current = tool.cmd
209
+ if (onChange) onChange(job)
210
+ const installed = owned.get(tool.id)
211
+ const useUpgrade = mode === 'upgrade' || (mode === 'auto' && !!installed)
212
+ const argv = useUpgrade ? upgradeArgv(tool, installed && installed.pkgId) : installArgv(tool)
213
+ let verdict
214
+ try {
215
+ const result = await io.run('winget', argv, INSTALL_TIMEOUT_MS)
216
+ verdict = classifyWingetResult(result.code, result.output)
217
+ // An explicit "install" on something winget already owns is really an
218
+ // 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)
222
+ }
223
+ } catch (error) {
224
+ verdict = { ok: false, kind: 'failed', detail: String((error && error.message) || error) }
225
+ }
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
+ })
236
+ job.done += 1
237
+ if (onChange) onChange(job)
238
+ }
239
+
240
+ job.current = ''
241
+ job.finished = true
242
+ if (onChange) onChange(job)
243
+ return job
244
+ }
245
+
246
+ /** Start a job in the background and hand back its id immediately. */
247
+ export function startToolJob(io, request) {
248
+ if (activeJob && !activeJob.finished) return { started: false, job: activeJob }
249
+ // Each entry records its own verdict; this catch only stops a failure inside
250
+ // the runner from becoming an unhandled rejection.
251
+ const promise = runToolJob(io, request).catch(() => activeJob)
252
+ return { started: true, promise }
253
+ }
254
+
255
+ /** The job the card should be showing, or null. */
256
+ export function currentToolJob() {
257
+ return activeJob
258
+ }
259
+
260
+ /** Forget a finished job (called when the card closes the progress view). */
261
+ export function clearToolJob(id) {
262
+ if (activeJob && (!id || activeJob.id === id)) activeJob = null
263
+ }
264
+
265
+ // ── request fencing ─────────────────────────────────────────────────────────
266
+
267
+ /** Loopback in every spelling a Node socket reports on Windows. */
268
+ export function isLoopbackAddress(address) {
269
+ const value = String(address || '')
270
+ return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1' || value.startsWith('127.')
271
+ }
272
+
273
+ /**
274
+ * Whether an Origin/Referer value belongs to the same host the request arrived
275
+ * on. A browser always sends one of them on a cross-site request, so a mismatch
276
+ * is exactly the CSRF case this fence exists for.
277
+ * @param {string} value - the Origin or Referer header.
278
+ * @param {string} host - the request's Host header.
279
+ * @returns {boolean}
280
+ */
281
+ export function isSameOriginValue(value, host) {
282
+ if (typeof value !== 'string' || value === '') return false
283
+ let origin
284
+ try { origin = new URL(value).host } catch { return false }
285
+ const expected = String(host || '').toLowerCase()
286
+ if (expected === '') return false
287
+ return origin.toLowerCase() === expected
288
+ }
289
+
290
+ /**
291
+ * The fence for the tools route. Installing is a side effect, so:
292
+ * - the peer must be loopback (a LAN or tunnel client never gets to install);
293
+ * - a mutating request must carry an Origin or Referer that matches Host.
294
+ * A same-origin GET from the settings page passes; a form/fetch posted by any
295
+ * other page fails on the origin check.
296
+ * @param {{ method?: string, headers?: object, socket?: object }} req
297
+ * @returns {{ ok: boolean, reason: string }}
298
+ */
299
+ export function fenceToolRequest(req) {
300
+ const socket = req && req.socket ? req.socket : {}
301
+ if (!isLoopbackAddress(socket.remoteAddress)) return { ok: false, reason: 'not-loopback' }
302
+ const method = String((req && req.method) || 'GET').toUpperCase()
303
+ if (method === 'GET' || method === 'HEAD') return { ok: true, reason: '' }
304
+ const headers = (req && req.headers) || {}
305
+ const host = headers.host || ''
306
+ const origin = headers.origin || headers.referer || ''
307
+ if (origin === '') return { ok: false, reason: 'no-origin' }
308
+ if (!isSameOriginValue(origin, host)) return { ok: false, reason: 'cross-origin' }
309
+ return { ok: true, reason: '' }
310
+ }
311
+
312
+ /**
313
+ * Read a JSON request body with a hard size cap. Returns null on anything that
314
+ * is not a small JSON object, so the caller answers 400 instead of guessing.
315
+ * @param {object} req - the request stream.
316
+ * @param {number} [limit] - byte ceiling.
317
+ * @returns {Promise<object|null>}
318
+ */
319
+ export function readJsonBody(req, limit = 64 * 1024) {
320
+ return new Promise((resolve) => {
321
+ let size = 0
322
+ const chunks = []
323
+ const fail = () => { resolve(null); try { req.destroy() } catch { /* ignore */ } }
324
+ req.on('data', (chunk) => {
325
+ size += chunk.length
326
+ if (size > limit) return fail()
327
+ chunks.push(chunk)
328
+ })
329
+ req.on('error', () => resolve(null))
330
+ req.on('end', () => {
331
+ try {
332
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
333
+ resolve(parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null)
334
+ } catch { resolve(null) }
335
+ })
336
+ })
337
+ }