dsh-gitbash-shell 0.27.0 → 0.28.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/src/index.js CHANGED
@@ -245,8 +245,15 @@ export function effectivePresetIds(configured, { suppress, peerGitBash } = {}) {
245
245
  return list.filter((id) => id !== PEER_COVERED_PRESET_ID)
246
246
  }
247
247
 
248
- /** Git Bash binary default — must match src/shell.js. */
249
- const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
248
+ /**
249
+ * The ONE bash resolver (v0.28.0, issue #11): the executor (src/shell.js) and
250
+ * this translation layer read the same memoized result, so "dialect translated
251
+ * to Q:/Git but the executor still spawns C:/Program Files/Git" cannot happen.
252
+ */
253
+ import {
254
+ DEFAULT_GIT_BASH, GIT_BASH_DOWNLOAD_URL, bashResolutionReport,
255
+ effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached, settingsBashPath,
256
+ } from './bash-path.js'
250
257
 
251
258
  /** The shipped preset whose skills/ dir seeds cordis-gitbash. */
252
259
  const SKILLS_SOURCE_PRESET = 'cordis'
@@ -1448,23 +1455,12 @@ export function buildTranslateEnv(configuredBashPath) {
1448
1455
  if (h && existsSync(h)) env.home = h.replace(/\\/g, '/')
1449
1456
  } catch { /* keep undefined */ }
1450
1457
  try {
1451
- const candidates = []
1452
- if (cacheKey !== '') candidates.push(cacheKey.replace(/\\/g, '/'))
1453
- candidates.push(DEFAULT_GIT_BASH)
1454
- for (const dir of String(process.env.PATH ?? '').split(';')) {
1455
- const clean = dir && dir.trim()
1456
- if (clean) candidates.push(clean.replace(/\\/g, '/') + '/bash.exe')
1457
- }
1458
- for (const candidate of candidates) {
1459
- try {
1460
- if (!existsSync(candidate)) continue
1461
- const root = dirname(dirname(candidate))
1462
- if (existsSync(join(root, 'usr', 'bin'))) {
1463
- env.gitRoot = root.replace(/\\/g, '/')
1464
- break
1465
- }
1466
- } catch { /* try the next candidate */ }
1467
- }
1458
+ // v0.28.0 (issue #11): ONE resolver for the executor and this layer. Its
1459
+ // chain is setting > default install paths > PATH > registry PATH and it
1460
+ // refuses WSL/MSYS2/Cygwin outright; `cacheKey` is the settings bashPath,
1461
+ // i.e. the same explicit tier the executor reads.
1462
+ const resolution = resolveGitBashCached({ configured: cacheKey })
1463
+ if (resolution.ok) env.gitRoot = resolution.root.replace(/\\/g, '/')
1468
1464
  } catch { /* keep undefined */ }
1469
1465
  translateEnvCache.set(cacheKey, env)
1470
1466
  return env
@@ -2351,7 +2347,28 @@ export async function apply(ctx, config = {}) {
2351
2347
  // plugins (e.g. dsh-ptc-cordis-preset) can adopt it in the presets they
2352
2348
  // materialize: service present, 'active: true' on Windows, 'active: false'
2353
2349
  // where the bundle is installed but the platform stack stayed native.
2354
- const gitBashCapability = { active: process.platform === 'win32', bashPath: DEFAULT_GIT_BASH }
2350
+ /* The explicit tier of the resolution chain, in its documented priority:
2351
+ the `gitbash-executor` row's own config > the `gitbash-shell` row/settings
2352
+ field > empty (= run the automatic chain). Both this layer and the
2353
+ executor resolve from this same value, so an explicit answer is never
2354
+ substituted and never silently ignored.
2355
+ (issue #11: the row config is where the reporter's workaround landed.) */
2356
+ const configuredBashPath = effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), settingsBashPath(ctx))
2357
+ const bashResolution = resolveGitBashCached({ configured: configuredBashPath })
2358
+ const gitBashCapability = {
2359
+ active: process.platform === 'win32',
2360
+ // Kept for existing consumers (peer plugins read `bashPath`): the resolved
2361
+ // interpreter. When resolution failed we report the explicit value (or the
2362
+ // historical default) so the ENOENT a consumer may hit names a real path —
2363
+ // never a substitute shell.
2364
+ bashPath: bashResolution.ok ? bashResolution.path : (configuredBashPath || DEFAULT_GIT_BASH),
2365
+ // Additive (v0.28.0): the full verdict, for the guided popup and peers.
2366
+ ok: bashResolution.ok,
2367
+ source: bashResolution.source,
2368
+ configured: configuredBashPath,
2369
+ tried: bashResolution.tried,
2370
+ downloadUrl: GIT_BASH_DOWNLOAD_URL,
2371
+ }
2355
2372
  const disposeGitBash = ctx.provide('gitBash', gitBashCapability)
2356
2373
  ctx.effect(() => disposeGitBash, 'dsh-gitbash-shell: gitBash capability')
2357
2374
 
@@ -2571,7 +2588,7 @@ export async function apply(ctx, config = {}) {
2571
2588
  // untouched and the result metadata is not echoed back in MSYS form.
2572
2589
  if (dialect.posixPaths && dialectApplies(dialect, exec && exec.agent)) {
2573
2590
  echo = true
2574
- const env = dialect.virtualMounts ? buildTranslateEnv(dialect.bashPath) : null
2591
+ const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2575
2592
  echoEnv = env
2576
2593
  if (exec && exec.arguments && typeof exec.arguments === 'object') {
2577
2594
  const translated = translateDispatch(exec, dialect, env)
@@ -2626,7 +2643,7 @@ export async function apply(ctx, config = {}) {
2626
2643
  if (!dialectApplies(dialect, exec && exec.agent)) return next()
2627
2644
  if (dialect.posixPaths && dialect.errorDialect && exec && result && typeof result === 'object'
2628
2645
  && result.isError === true && Array.isArray(result.content)) {
2629
- const env = dialect.virtualMounts ? buildTranslateEnv(dialect.bashPath) : null
2646
+ const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2630
2647
  if (exec.name === 'run_code') {
2631
2648
  // paths only — the /dev/null hint is for file tools
2632
2649
  const content = rewriteErrorContent(result.content, { nulHint: false, env })
@@ -2694,12 +2711,56 @@ export async function apply(ctx, config = {}) {
2694
2711
  }
2695
2712
  }
2696
2713
 
2714
+ // ── fail-loud bash report + the client-visible status route (v0.28.0) ──
2715
+ // issue #11: the old failure mode was "every command dies with
2716
+ // `spawn C:/Program Files/Git/bin/bash.exe ENOENT` and nothing explains it".
2717
+ // Now the boot log names the value, the whole probe chain and where to fix
2718
+ // it, and the client half reads the same verdict over HTTP to raise the
2719
+ // guided popup. Nothing here substitutes another shell — by design.
2720
+ console.log(TAG + ' bash resolution: ' + bashResolutionReport(bashResolution).split('\n').join('\n' + TAG + ' '))
2721
+ if (process.platform === 'win32') {
2722
+ try {
2723
+ ctx.inject(['webServer'], (wctx) => {
2724
+ try {
2725
+ const route = '/dsh-gitbash-shell/api/status'
2726
+ const read = () => {
2727
+ const resolution = resolveGitBashCached({ configured: configuredBashPath, fresh: true })
2728
+ return {
2729
+ ok: resolution.ok,
2730
+ platform: process.platform,
2731
+ resolved: resolution.path,
2732
+ root: resolution.root,
2733
+ source: resolution.source,
2734
+ configured: resolution.configured,
2735
+ tried: resolution.tried,
2736
+ downloadUrl: GIT_BASH_DOWNLOAD_URL,
2737
+ settingsHint: 'Settings -> Plugins -> dsh-gitbash-shell -> "Git Bash path"',
2738
+ }
2739
+ }
2740
+ const handler = (req, res) => {
2741
+ const text = JSON.stringify(read())
2742
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(text) })
2743
+ res.end(text)
2744
+ }
2745
+ const dispose = wctx.webServer.register({ kind: 'prefix', path: route, handler })
2746
+ wctx.effect(() => dispose, 'dsh-gitbash-shell: bash status route')
2747
+ console.log(TAG + ' bash status route active at ' + route + ' (read by the guided popup)')
2748
+ } catch (error) {
2749
+ console.log(TAG + ' bash status route failed: ' + (error?.message ?? error))
2750
+ }
2751
+ })
2752
+ } catch (error) {
2753
+ console.log(TAG + ' bash status route wiring failed: ' + (error?.message ?? error))
2754
+ }
2755
+ }
2756
+
2697
2757
  // ── dsh-better-sidebar terminal adoption (Windows only) ────────────────
2698
2758
  // The sidebar resolves its terminal shell through the settings seam per
2699
2759
  // open; adopt it through that seam (see adoptSidebarShell for rationale).
2700
2760
  // Disable with `betterSidebarShell: false` in the plugin row config.
2701
2761
  if (config.betterSidebarShell !== false && process.platform === 'win32') {
2702
- adoptSidebarShell(ctx, gitBashCapability.bashPath, () => liveSettings.adoptSidebar())
2762
+ if (bashResolution.ok) adoptSidebarShell(ctx, bashResolution.path, () => liveSettings.adoptSidebar())
2763
+ else console.log(TAG + ' sidebar terminal adoption skipped: no Git Bash resolved (nothing is substituted)')
2703
2764
  }
2704
2765
 
2705
2766
  // ── era split: declarative registration on dsh >= 0.1.7 ──────────────────
package/src/shell.js CHANGED
@@ -38,10 +38,13 @@
38
38
  */
39
39
 
40
40
  import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
41
+
42
+ import { DEFAULT_GIT_BASH, bashResolutionReport, effectiveConfiguredBashPath, resolveGitBashCached, settingsBashPath } from './bash-path.js'
43
+
44
+ /** Re-exported for callers that historically read it here (the value now lives in bash-path.js). */
45
+ export { DEFAULT_GIT_BASH }
41
46
  import z from '@deepseek-ai/schemastery'
42
47
 
43
- /** Default Git for Windows bash (forward slashes work on Windows too). */
44
- export const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
45
48
 
46
49
  /** Log prefix, matching src/index.js. */
47
50
  const TAG = '[gitbash-shell]'
@@ -95,7 +98,12 @@ export function gitBashShellConfig(z) {
95
98
  graceMs: live(z.number().default(3000)),
96
99
  // Our own knob: the base class neither declares nor `.get()`s it, and
97
100
  // `get bashPath()` below reads it as a plain value, so it stays plain.
98
- bashPath: z.string().default(DEFAULT_GIT_BASH),
101
+ //
102
+ // EMPTY means AUTO (v0.28.0, issue #11): the shared resolver walks
103
+ // setting > default install paths > PATH > registry PATH and refuses WSL /
104
+ // MSYS2 / Cygwin. A hard-coded default here is what made a Git installed
105
+ // outside C:/Program Files look like "dsh is broken".
106
+ bashPath: z.string().default(''),
99
107
  })
100
108
  }
101
109
 
@@ -107,9 +115,49 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
107
115
  static inject = ['subprocess', 'sandbox', 'sandboxPolicy']
108
116
  static Config = Config
109
117
 
110
- /** Effective Git Bash path: composition/config value with the default fallback. */
118
+ /**
119
+ * Effective Git Bash path. The configured value wins when it is non-empty
120
+ * (the user's explicit answer is never substituted); otherwise the SHARED
121
+ * resolver decides — the very same result the path-dialect translation layer
122
+ * uses, so "dialect works, executor cannot find bash" (issue #11) is
123
+ * structurally impossible.
124
+ *
125
+ * A failed resolution returns '' — deliberately NOT a substitute shell. Every
126
+ * argv built from it then fails loudly on spawn; `apply()` has already
127
+ * printed the full report, and the client half shows the guided popup.
128
+ */
111
129
  get bashPath() {
112
- return this.config.bashPath ?? DEFAULT_GIT_BASH
130
+ return this.bashResolution().path
131
+ }
132
+
133
+ /**
134
+ * Resolve the interpreter with the SAME merged explicit tier the main plugin
135
+ * uses (executor row config > `gitbash-shell` settings row > empty = auto),
136
+ * so both halves of the plugin always agree on which bash this process runs.
137
+ * @returns {object} the memoized resolution verdict.
138
+ */
139
+ bashResolution() {
140
+ const configured = effectiveConfiguredBashPath(this.config.bashPath, settingsBashPath(this.ctx))
141
+ return resolveGitBashCached({ configured })
142
+ }
143
+
144
+ /**
145
+ * The resolved Git Bash path, or a THROW that says exactly what is wrong.
146
+ *
147
+ * The throw is the design (AGENTS.md §4h): with no Git Bash there is no
148
+ * command execution at all — we do not substitute pwsh, cmd, WSL or MSYS2,
149
+ * and we do not let a bare `spawn ENOENT` be the only thing the user sees.
150
+ * The boot log carries the same report (src/index.js) and the client half
151
+ * shows the guided popup with the probed list.
152
+ * @returns {string} the resolved interpreter path.
153
+ */
154
+ requireBashPath() {
155
+ const resolution = this.bashResolution()
156
+ if (!resolution.ok) {
157
+ throw new Error('dsh-gitbash-shell: no Git for Windows bash found, so this command cannot run. '
158
+ + bashResolutionReport(resolution).split('\n').slice(1).join(' | '))
159
+ }
160
+ return resolution.path
113
161
  }
114
162
 
115
163
  /**
@@ -129,7 +177,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
129
177
  * @returns the provider's exact argv and settlement-classification facts (a promise on dsh >= 0.1.6).
130
178
  */
131
179
  confine(command, policy, signal) {
132
- return this.ctx.sandbox.confine([this.bashPath, '-c', command], policy, signal)
180
+ return this.ctx.sandbox.confine([this.requireBashPath(), '-c', command], policy, signal)
133
181
  }
134
182
 
135
183
  /**
@@ -219,7 +267,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
219
267
  const routed = this.withParityEnv(spec)
220
268
  const route = this.gitBashRoute(routed)
221
269
  if (route === undefined) return super.execute(routed)
222
- const argv = [this.bashPath, '-c', routed.command]
270
+ const argv = [this.requireBashPath(), '-c', routed.command]
223
271
  if (route.unconfined) this.warnConfinedUnconfined(route.mode)
224
272
  const sandbox = route.unconfined
225
273
  ? { mode: route.mode, denied: false, enforcement: 'unconfined' }
@@ -304,13 +352,13 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
304
352
  const { mode } = policy
305
353
  if (mode === 'danger-full-access') {
306
354
  const { result } = GitBashSandboxExecutor.unwrapRunArgv(
307
- await this.runArgv(spec, [this.bashPath, '-c', spec.command]))
355
+ await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command]))
308
356
  return { ...result, sandbox: { mode, denied: false } }
309
357
  }
310
358
  if (process.platform === 'win32') {
311
359
  this.warnConfinedUnconfined(mode)
312
360
  const { result, spawnRequested } = GitBashSandboxExecutor.unwrapRunArgv(
313
- await this.runArgv(spec, [this.bashPath, '-c', spec.command]))
361
+ await this.runArgv(spec, [this.requireBashPath(), '-c', spec.command]))
314
362
  // No spawn means no argv ran, so there is nothing to label as
315
363
  // unconfined; the parent reports the same bare sandbox facts there.
316
364
  return { ...result, sandbox: spawnRequested ? { mode, denied: false, enforcement: 'unconfined' } : { mode, denied: false } }
@@ -326,7 +374,7 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
326
374
  const fullAccess = mode === 'danger-full-access'
327
375
  if (fullAccess || process.platform === 'win32') {
328
376
  if (!fullAccess) this.warnConfinedUnconfined(mode)
329
- const proc = this.startArgv(spec, [this.bashPath, '-c', spec.command])
377
+ const proc = this.startArgv(spec, [this.requireBashPath(), '-c', spec.command])
330
378
  proc.sandbox = fullAccess ? { mode, denied: false } : { mode, denied: false, enforcement: 'unconfined' }
331
379
  // Match the loaded base contract: the async one (dsh >= 0.1.6) is
332
380
  // awaited by its callers, the synchronous one is consumed directly.