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/CHANGELOG.md +91 -0
- package/README.md +46 -0
- package/cordis.patch.yml +5 -1
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/bash-path.js +371 -0
- package/src/client.js +405 -7
- package/src/index.js +146 -24
- package/src/shell.js +58 -10
- package/src/terminal-shell.js +206 -0
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
|
-
/**
|
|
249
|
-
|
|
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
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|