dsh-gitbash-shell 0.27.0 → 0.29.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,16 @@ 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'
257
+ import { adoptTerminalShell, terminalAdoptReport } from './terminal-shell.js'
250
258
 
251
259
  /** The shipped preset whose skills/ dir seeds cordis-gitbash. */
252
260
  const SKILLS_SOURCE_PRESET = 'cordis'
@@ -880,6 +888,16 @@ export const Config = Schema === null ? undefined : Schema.object({
880
888
  gitAutocrlf: live(Schema.boolean().default(true)),
881
889
  bashPath: live(Schema.string().default('')),
882
890
  adoptSidebar: live(Schema.boolean().default(true)),
891
+ /**
892
+ * Whether the OFFICIAL sidebar terminal ("new terminal") is switched to the
893
+ * resolved Git Bash (v0.29.0, task-25). Default ON — the user asked for
894
+ * exactly this ("自动检测 … 帮忙补上一个 gitbash 可以用"). On Windows without
895
+ * this, dsh resolves the terminal shell from the environment, which on a
896
+ * machine whose PATH `bash` is the WSL launcher means the new terminal starts
897
+ * Ubuntu, not Git Bash. Only ever writes when the terminal has NO shell of
898
+ * its own: an explicit choice is never overwritten.
899
+ */
900
+ autoTerminalShell: live(Schema.boolean().default(true)),
883
901
  /**
884
902
  * Dedupe switch against dsh-ptc-cordis-preset (their issue #7, v0.25.0):
885
903
  * ON while that plugin is installed AND materializing its `PTC 创造模式`
@@ -927,12 +945,15 @@ function makeLiveReader(ctx, config) {
927
945
  subagentDialect: valueOf(config.subagentDialect) !== false,
928
946
  bashPath: typeof bash === 'string' ? bash : '',
929
947
  adoptSidebar: valueOf(config.adoptSidebar) !== false,
948
+ // Default ON (v0.29.0): the official terminal is ours unless opted out.
949
+ autoTerminalShell: valueOf(config.autoTerminalShell) !== false,
930
950
  }
931
951
  }
932
952
  return {
933
953
  dialect,
934
954
  posix: () => dialect().posixPaths === true,
935
955
  adoptSidebar: () => dialect().adoptSidebar !== false,
956
+ autoTerminalShell: () => dialect().autoTerminalShell !== false,
936
957
  // Default OFF on both eras: only an explicit true asks for deduplication.
937
958
  suppressPeerCordis: () => (legacy || !hasConfig ? readSuppressPeerCordis(ctx) : valueOf(config.suppressPeerCordis) === true),
938
959
  }
@@ -999,7 +1020,7 @@ export function readPosixPaths(ctxLike) {
999
1020
  * first. All default ON (schema defaults); never throws.
1000
1021
  */
1001
1022
  export function readDialectSettings(ctxLike) {
1002
- const fallback = { posixPaths: false, virtualMounts: false, globSplit: false, errorDialect: false, codePaths: false, gitAutocrlf: false, bashPath: '', subagentDialect: true }
1023
+ const fallback = { posixPaths: false, virtualMounts: false, globSplit: false, errorDialect: false, codePaths: false, gitAutocrlf: false, bashPath: '', subagentDialect: true, autoTerminalShell: true }
1003
1024
  try {
1004
1025
  const settings = ctxLike && typeof ctxLike.get === 'function' ? ctxLike.get('settings') : undefined
1005
1026
  const value = settings && typeof settings.get === 'function' ? settings.get(SETTINGS_NAMESPACE) : undefined
@@ -1012,6 +1033,8 @@ export function readDialectSettings(ctxLike) {
1012
1033
  codePaths: v.codePaths === true,
1013
1034
  // An old host without the key keeps the default (delegated agents covered).
1014
1035
  subagentDialect: v.subagentDialect !== false,
1036
+ // Default ON: a host without the key still gets the terminal adoption.
1037
+ autoTerminalShell: v.autoTerminalShell !== false,
1015
1038
  gitAutocrlf: v.gitAutocrlf === true,
1016
1039
  bashPath: typeof v.bashPath === 'string' ? v.bashPath : '',
1017
1040
  }
@@ -1448,23 +1471,12 @@ export function buildTranslateEnv(configuredBashPath) {
1448
1471
  if (h && existsSync(h)) env.home = h.replace(/\\/g, '/')
1449
1472
  } catch { /* keep undefined */ }
1450
1473
  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
- }
1474
+ // v0.28.0 (issue #11): ONE resolver for the executor and this layer. Its
1475
+ // chain is setting > default install paths > PATH > registry PATH and it
1476
+ // refuses WSL/MSYS2/Cygwin outright; `cacheKey` is the settings bashPath,
1477
+ // i.e. the same explicit tier the executor reads.
1478
+ const resolution = resolveGitBashCached({ configured: cacheKey })
1479
+ if (resolution.ok) env.gitRoot = resolution.root.replace(/\\/g, '/')
1468
1480
  } catch { /* keep undefined */ }
1469
1481
  translateEnvCache.set(cacheKey, env)
1470
1482
  return env
@@ -2351,7 +2363,28 @@ export async function apply(ctx, config = {}) {
2351
2363
  // plugins (e.g. dsh-ptc-cordis-preset) can adopt it in the presets they
2352
2364
  // materialize: service present, 'active: true' on Windows, 'active: false'
2353
2365
  // where the bundle is installed but the platform stack stayed native.
2354
- const gitBashCapability = { active: process.platform === 'win32', bashPath: DEFAULT_GIT_BASH }
2366
+ /* The explicit tier of the resolution chain, in its documented priority:
2367
+ the `gitbash-executor` row's own config > the `gitbash-shell` row/settings
2368
+ field > empty (= run the automatic chain). Both this layer and the
2369
+ executor resolve from this same value, so an explicit answer is never
2370
+ substituted and never silently ignored.
2371
+ (issue #11: the row config is where the reporter's workaround landed.) */
2372
+ const configuredBashPath = effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), settingsBashPath(ctx))
2373
+ const bashResolution = resolveGitBashCached({ configured: configuredBashPath })
2374
+ const gitBashCapability = {
2375
+ active: process.platform === 'win32',
2376
+ // Kept for existing consumers (peer plugins read `bashPath`): the resolved
2377
+ // interpreter. When resolution failed we report the explicit value (or the
2378
+ // historical default) so the ENOENT a consumer may hit names a real path —
2379
+ // never a substitute shell.
2380
+ bashPath: bashResolution.ok ? bashResolution.path : (configuredBashPath || DEFAULT_GIT_BASH),
2381
+ // Additive (v0.28.0): the full verdict, for the guided popup and peers.
2382
+ ok: bashResolution.ok,
2383
+ source: bashResolution.source,
2384
+ configured: configuredBashPath,
2385
+ tried: bashResolution.tried,
2386
+ downloadUrl: GIT_BASH_DOWNLOAD_URL,
2387
+ }
2355
2388
  const disposeGitBash = ctx.provide('gitBash', gitBashCapability)
2356
2389
  ctx.effect(() => disposeGitBash, 'dsh-gitbash-shell: gitBash capability')
2357
2390
 
@@ -2398,6 +2431,8 @@ export async function apply(ctx, config = {}) {
2398
2431
  gitAutocrlf: Schema.boolean().default(true),
2399
2432
  bashPath: Schema.string().default(''),
2400
2433
  adoptSidebar: Schema.boolean().default(true),
2434
+ // v0.29.0: official sidebar terminal adoption (default ON).
2435
+ autoTerminalShell: Schema.boolean().default(true),
2401
2436
  // v0.25.0: same switch as the row Config carries on dsh >= 0.1.7
2402
2437
  // (the OLD era has no cross-namespace edits, so this namespace is
2403
2438
  // the only side that can change it here).
@@ -2571,7 +2606,7 @@ export async function apply(ctx, config = {}) {
2571
2606
  // untouched and the result metadata is not echoed back in MSYS form.
2572
2607
  if (dialect.posixPaths && dialectApplies(dialect, exec && exec.agent)) {
2573
2608
  echo = true
2574
- const env = dialect.virtualMounts ? buildTranslateEnv(dialect.bashPath) : null
2609
+ const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2575
2610
  echoEnv = env
2576
2611
  if (exec && exec.arguments && typeof exec.arguments === 'object') {
2577
2612
  const translated = translateDispatch(exec, dialect, env)
@@ -2626,7 +2661,7 @@ export async function apply(ctx, config = {}) {
2626
2661
  if (!dialectApplies(dialect, exec && exec.agent)) return next()
2627
2662
  if (dialect.posixPaths && dialect.errorDialect && exec && result && typeof result === 'object'
2628
2663
  && result.isError === true && Array.isArray(result.content)) {
2629
- const env = dialect.virtualMounts ? buildTranslateEnv(dialect.bashPath) : null
2664
+ const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2630
2665
  if (exec.name === 'run_code') {
2631
2666
  // paths only — the /dev/null hint is for file tools
2632
2667
  const content = rewriteErrorContent(result.content, { nulHint: false, env })
@@ -2694,12 +2729,99 @@ export async function apply(ctx, config = {}) {
2694
2729
  }
2695
2730
  }
2696
2731
 
2732
+ // ── fail-loud bash report + the client-visible status route (v0.28.0) ──
2733
+ // issue #11: the old failure mode was "every command dies with
2734
+ // `spawn C:/Program Files/Git/bin/bash.exe ENOENT` and nothing explains it".
2735
+ // Now the boot log names the value, the whole probe chain and where to fix
2736
+ // it, and the client half reads the same verdict over HTTP to raise the
2737
+ // guided popup. Nothing here substitutes another shell — by design.
2738
+ console.log(TAG + ' bash resolution: ' + bashResolutionReport(bashResolution).split('\n').join('\n' + TAG + ' '))
2739
+ if (process.platform === 'win32') {
2740
+ try {
2741
+ ctx.inject(['webServer'], (wctx) => {
2742
+ try {
2743
+ const route = '/dsh-gitbash-shell/api/status'
2744
+ const read = () => {
2745
+ const resolution = resolveGitBashCached({ configured: configuredBashPath, fresh: true })
2746
+ return {
2747
+ ok: resolution.ok,
2748
+ platform: process.platform,
2749
+ resolved: resolution.path,
2750
+ root: resolution.root,
2751
+ source: resolution.source,
2752
+ configured: resolution.configured,
2753
+ tried: resolution.tried,
2754
+ downloadUrl: GIT_BASH_DOWNLOAD_URL,
2755
+ settingsHint: 'Settings -> Plugins -> dsh-gitbash-shell -> "Git Bash path"',
2756
+ }
2757
+ }
2758
+ const handler = (req, res) => {
2759
+ const text = JSON.stringify(read())
2760
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(text) })
2761
+ res.end(text)
2762
+ }
2763
+ const dispose = wctx.webServer.register({ kind: 'prefix', path: route, handler })
2764
+ wctx.effect(() => dispose, 'dsh-gitbash-shell: bash status route')
2765
+ console.log(TAG + ' bash status route active at ' + route + ' (read by the guided popup)')
2766
+ } catch (error) {
2767
+ console.log(TAG + ' bash status route failed: ' + (error?.message ?? error))
2768
+ }
2769
+ })
2770
+ } catch (error) {
2771
+ console.log(TAG + ' bash status route wiring failed: ' + (error?.message ?? error))
2772
+ }
2773
+ }
2774
+
2775
+ // ── OFFICIAL sidebar terminal adoption (v0.29.0, task-25) ──────────────
2776
+ // User report: dsh's own "new terminal" started WSL, because
2777
+ // `terminal-controller` falls back to the execution environment's default
2778
+ // shell and on that machine PATH's `bash` is the System32 WSL launcher.
2779
+ // The settings service CANNOT be used here (measured: `shell` is not a
2780
+ // volatile field, and there is no `terminal` configurable entry), so this
2781
+ // goes through `configEditor.edit()` — the API the official settings UI
2782
+ // persists through — and it only ever writes when the terminal has no shell
2783
+ // of its own. See src/terminal-shell.js for the full rationale.
2784
+ if (process.platform === 'win32') {
2785
+ try {
2786
+ ctx.inject(['configEditor'], (editorCtx) => {
2787
+ // The terminal row may activate after this plugin; retry a few times
2788
+ // (bounded) instead of silently giving up, and never spam the log.
2789
+ let attempts = 0
2790
+ const run = async () => {
2791
+ attempts += 1
2792
+ let result
2793
+ try {
2794
+ result = await adoptTerminalShell(editorCtx.configEditor, {
2795
+ platform: process.platform,
2796
+ enabled: liveSettings.autoTerminalShell(),
2797
+ resolved: bashResolution.ok ? bashResolution.path : '',
2798
+ })
2799
+ } catch (error) {
2800
+ // adoptTerminalShell reports expected conditions itself; reaching
2801
+ // here means something unexpected — say so instead of swallowing it.
2802
+ console.log(`${TAG} official sidebar terminal adoption crashed: ${error?.message ?? error}`)
2803
+ return
2804
+ }
2805
+ if (result.status === 'skip-row-absent' && attempts < 6) {
2806
+ setTimeout(() => { run().catch((error) => console.log(`${TAG} official sidebar terminal adoption retry failed: ${error?.message ?? error}`)) }, 3000)
2807
+ return
2808
+ }
2809
+ console.log(`${TAG} ${terminalAdoptReport(result)}`)
2810
+ }
2811
+ run().catch((error) => console.log(`${TAG} official sidebar terminal adoption failed: ${error?.message ?? error}`))
2812
+ })
2813
+ } catch (error) {
2814
+ console.log(`${TAG} official sidebar terminal adoption wiring failed: ${error?.message ?? error}`)
2815
+ }
2816
+ }
2817
+
2697
2818
  // ── dsh-better-sidebar terminal adoption (Windows only) ────────────────
2698
2819
  // The sidebar resolves its terminal shell through the settings seam per
2699
2820
  // open; adopt it through that seam (see adoptSidebarShell for rationale).
2700
2821
  // Disable with `betterSidebarShell: false` in the plugin row config.
2701
2822
  if (config.betterSidebarShell !== false && process.platform === 'win32') {
2702
- adoptSidebarShell(ctx, gitBashCapability.bashPath, () => liveSettings.adoptSidebar())
2823
+ if (bashResolution.ok) adoptSidebarShell(ctx, bashResolution.path, () => liveSettings.adoptSidebar())
2824
+ else console.log(TAG + ' sidebar terminal adoption skipped: no Git Bash resolved (nothing is substituted)')
2703
2825
  }
2704
2826
 
2705
2827
  // ── 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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * dsh-gitbash-shell — OFFICIAL sidebar terminal adoption (v0.29.0, task-25).
3
+ *
4
+ * THE PROBLEM (user report, Windows 11): dsh's own "new terminal" starts WSL,
5
+ * not Git Bash. `terminal-controller` resolves its shell from
6
+ * `subprocess.terminalEnvironment().defaultShell` when no `shell` is configured,
7
+ * and on that machine the PATH hit for `bash` is
8
+ * `C:\WINDOWS\system32\bash.EXE` — the Microsoft WSL launcher. The agent tools
9
+ * are already ours (`pwsh-sandbox` out, `gitbash-executor` in), but the sidebar
10
+ * terminal is a DIFFERENT row with a different shell resolution, so it kept
11
+ * starting WSL.
12
+ *
13
+ * WHY THIS MODULE TALKS TO `configEditor` AND NOT TO `settings`
14
+ * (measured on dsh 0.1.7-rc.2, probe readout in the task notes):
15
+ * - `settings.update('terminal', …)` is impossible twice over:
16
+ * `SettingsService.write()` requires every written path to be VOLATILE
17
+ * (`volatileForm(schema)` → `undefined` ⇒ "Plugin entry … has no volatile
18
+ * fields"), and `terminal-controller`'s Config declares `shell` as an
19
+ * ordinary field. The probe also showed no `terminal` entry at all
20
+ * ("No configurable plugin entry \"terminal\"").
21
+ * - `configEditor.edit(entry, change)` is the API the official settings UI
22
+ * itself persists through, documented as "ordinary fields keep normal
23
+ * lifecycle rules". The probe wrote `shell` and the RUNNING
24
+ * `ctx.terminalController.config.shell` carried it on the next read — no
25
+ * restart. The write lands in the profile patch document through the
26
+ * official YAML editor (comment-preserving, id-merged, file-locked); we
27
+ * never hand-edit that file ourselves.
28
+ *
29
+ * HARD RULES (same discipline as §4h):
30
+ * 1. NEVER overwrite an explicit choice. If the terminal already points
31
+ * somewhere else (another bash, a WSL profile, pwsh), we log and stop —
32
+ * the user's decision outranks our automation.
33
+ * 2. NEVER write a shell we have not verified. `resolveShell` rejects an
34
+ * unresolvable path with `SubprocessExecutableNotFoundError` and has NO
35
+ * fallback, so a bad write would break the new-terminal button outright.
36
+ * The path written here is always the one `src/bash-path.js` verified
37
+ * (exists + Git-for-Windows layout + `.windows.` fingerprint + `uname -s`
38
+ * = MINGW*_NT-*).
39
+ * 3. NEVER claim success without reading the value back. `edit()` resolving
40
+ * is not proof the row carries our shell, so `adoptTerminalShell` re-reads
41
+ * `configuration()` and reports `write-failed` when the value is not there
42
+ * (§4h: no silent false success).
43
+ */
44
+
45
+ import { normalizeBashPath } from './bash-path.js'
46
+
47
+ /** The Loader row id of the official terminal controller. */
48
+ export const TERMINAL_ROW_ID = 'terminal-controller'
49
+
50
+ /** Profile name written for Git Bash — matches the official `profile()` output. */
51
+ export const TERMINAL_SHELL_NAME = 'bash'
52
+
53
+ /**
54
+ * Arguments written for Git Bash. `['-i']` is not our invention: the official
55
+ * `profile()` in `packages/api/terminal-controller/src/shells.ts` derives
56
+ * exactly `['-i']` for any shell that is not cmd/pwsh/powershell, and `-i`
57
+ * makes bash read its rc files in the interactive terminal.
58
+ */
59
+ export const TERMINAL_SHELL_ARGS = ['-i']
60
+
61
+ /**
62
+ * The shell profile we write into `terminal-controller`'s config.
63
+ * @param path - verified Git Bash path.
64
+ * @returns the `{ path, name, args }` profile.
65
+ */
66
+ export function terminalShellProfile(path) {
67
+ return { path, name: TERMINAL_SHELL_NAME, args: [...TERMINAL_SHELL_ARGS] }
68
+ }
69
+
70
+ /**
71
+ * The effective explicit shell of the terminal row: the user patch's override
72
+ * wins over the composed bundle default (which is usually absent). Treats a
73
+ * malformed shape as "not set" so a broken hand edit cannot make us believe
74
+ * there is a user choice to respect.
75
+ * @param row - one `configEditor.configuration()` entry, or undefined.
76
+ * @returns the current shell profile, or undefined when unset.
77
+ */
78
+ export function currentTerminalShell(row) {
79
+ if (row === null || row === undefined) return undefined
80
+ const value = row.override !== null && row.override !== undefined && 'shell' in row.override
81
+ ? row.override.shell
82
+ : (row.inherited === null || row.inherited === undefined ? undefined : row.inherited.shell)
83
+ if (value === null || typeof value !== 'object') return undefined
84
+ const path = value.path
85
+ if (typeof path !== 'string' || path.trim() === '') return undefined
86
+ return {
87
+ path,
88
+ name: typeof value.name === 'string' ? value.name : '',
89
+ args: Array.isArray(value.args) ? value.args : [],
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Decide what to do about the terminal shell. Pure: every fact arrives as an
95
+ * argument, which is why the whole matrix (empty / same / other / disabled /
96
+ * unresolved / non-Windows) is unit-testable anywhere.
97
+ * @param {object} input - decision input.
98
+ * @param {string} input.platform - `process.platform` of the host.
99
+ * @param {boolean} input.enabled - the `autoTerminalShell` switch.
100
+ * @param {string} input.resolved - the verified Git Bash path ('' when unresolved).
101
+ * @param {object} [input.current] - current terminal shell profile, if any.
102
+ * @returns {{action: string, shell?: object, current?: object, reason: string}} the plan.
103
+ */
104
+ export function planTerminalAdopt({ platform, enabled, resolved, current }) {
105
+ if (platform !== 'win32') return { action: 'skip-platform', reason: 'not a Windows host; the official terminal keeps its own shell' }
106
+ if (enabled !== true) return { action: 'skip-disabled', reason: 'the autoTerminalShell switch is off' }
107
+ if (typeof resolved !== 'string' || resolved.trim() === '') {
108
+ return { action: 'skip-unresolved', reason: 'no verified Git Bash to offer (see the bash resolution report)' }
109
+ }
110
+ if (current === undefined || current === null) {
111
+ return { action: 'adopt', shell: terminalShellProfile(resolved.trim()), reason: 'the terminal has no shell configured; offering the verified Git Bash' }
112
+ }
113
+ if (normalizeBashPath(current.path) === normalizeBashPath(resolved)) {
114
+ return { action: 'unchanged', current, reason: 'the terminal already uses this Git Bash' }
115
+ }
116
+ return {
117
+ action: 'kept-user-choice',
118
+ current,
119
+ reason: 'the terminal shell points at "' + current.path + '" — an explicit choice is never overwritten; clear that field (Settings → the terminal row) to let Git Bash take over',
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Apply the plan through the official config editor, then READ THE VALUE BACK.
125
+ * @param {object} editor - `ctx.configEditor` (or a stand-in in tests).
126
+ * @param {object} options - adoption options.
127
+ * @param {string} options.platform - host platform.
128
+ * @param {boolean} options.enabled - `autoTerminalShell` switch.
129
+ * @param {string} options.resolved - verified Git Bash path ('' when unresolved).
130
+ * @returns {Promise<{status: string, action?: string, path?: string, current?: object, detail?: string}>}
131
+ * the outcome; `status` is one of the plan actions plus `skip-row-absent`
132
+ * and `write-failed`. The caller logs it — this function never lies about
133
+ * success and never throws for an expected condition.
134
+ */
135
+ export async function adoptTerminalShell(editor, { platform, enabled, resolved }) {
136
+ if (editor === null || editor === undefined || typeof editor.configuration !== 'function') {
137
+ return { status: 'skip-row-absent', detail: 'the config editor is unavailable, so the terminal row cannot be configured' }
138
+ }
139
+ const rows = editor.configuration()
140
+ const row = Array.isArray(rows) ? rows.find((candidate) => candidate.entry?.options?.id === TERMINAL_ROW_ID) : undefined
141
+ if (row === undefined) {
142
+ return { status: 'skip-row-absent', detail: 'the "' + TERMINAL_ROW_ID + '" row is not mounted in this profile' }
143
+ }
144
+ const current = currentTerminalShell(row)
145
+ const plan = planTerminalAdopt({ platform, enabled, resolved, current })
146
+ if (plan.action !== 'adopt') {
147
+ return { status: plan.action, current, detail: plan.reason }
148
+ }
149
+ if (typeof editor.edit !== 'function') {
150
+ return { status: 'write-failed', current, detail: 'the config editor exposes no edit()' }
151
+ }
152
+ try {
153
+ await editor.edit(row.entry, (raw) => ({ ...raw, shell: plan.shell }))
154
+ } catch (error) {
155
+ return { status: 'write-failed', current, detail: 'the official config editor refused the write: ' + (error && error.message ? error.message : String(error)) }
156
+ }
157
+ // Read back: `edit()` resolving is NOT proof the row now carries our shell.
158
+ let after
159
+ try {
160
+ const rowsAfter = editor.configuration()
161
+ after = Array.isArray(rowsAfter) ? rowsAfter.find((candidate) => candidate.entry?.options?.id === TERMINAL_ROW_ID) : undefined
162
+ } catch (error) {
163
+ return { status: 'write-failed', current, detail: 'the write succeeded but reading it back failed: ' + (error && error.message ? error.message : String(error)) }
164
+ }
165
+ const written = currentTerminalShell(after)
166
+ if (written === undefined || normalizeBashPath(written.path) !== normalizeBashPath(plan.shell.path)) {
167
+ return {
168
+ status: 'write-failed',
169
+ current,
170
+ detail: 'the write did not stick: the terminal row reads ' + (written === undefined ? '(no shell)' : '"' + written.path + '"'),
171
+ }
172
+ }
173
+ return { status: 'adopted', path: plan.shell.path, current }
174
+ }
175
+
176
+ /**
177
+ * The one-line log for an adoption outcome (the caller prints it). Kept here so
178
+ * the wording stays identical between the boot path and the tests.
179
+ * @param {object} result - an {@link adoptTerminalShell} result.
180
+ * @returns {string} the log line, without the plugin tag.
181
+ */
182
+ export function terminalAdoptReport(result) {
183
+ switch (result.status) {
184
+ case 'adopted':
185
+ return 'official sidebar terminal switched to Git Bash: ' + result.path + ' (name bash, args -i) — new terminals use it immediately, already-open ones keep their shell'
186
+ case 'unchanged':
187
+ return 'official sidebar terminal already uses this Git Bash: ' + (result.current ? result.current.path : '') + ' (nothing written)'
188
+ case 'kept-user-choice':
189
+ return 'official sidebar terminal NOT adopted: ' + result.detail
190
+ case 'write-failed':
191
+ return 'official sidebar terminal adoption FAILED — ' + result.detail
192
+ + ' | next steps: (1) the new-terminal shell is UNCHANGED, so dsh keeps its own resolution;'
193
+ + ' (2) set the terminal by hand: add `shell: { path: <git-bash>, name: bash, args: [\'-i\'] }` to the'
194
+ + ' `terminal-controller` row in the profile patch and restart dsh;'
195
+ + ' (3) or turn this plugin\'s "autoTerminalShell" switch off to stop the attempts;'
196
+ + ' (4) the full cause is in the lines above.'
197
+ case 'skip-disabled':
198
+ return 'official sidebar terminal adoption is off (autoTerminalShell = false) — the new-terminal shell stays whatever dsh resolves'
199
+ case 'skip-unresolved':
200
+ return 'official sidebar terminal adoption skipped: no verified Git Bash (nothing is substituted)'
201
+ case 'skip-platform':
202
+ return 'official sidebar terminal adoption applies to Windows only'
203
+ default:
204
+ return 'official sidebar terminal adoption skipped: ' + (result.detail ?? result.status)
205
+ }
206
+ }