dsh-git-bash 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## 0.1.1 (2026-09-13)
6
+
7
+ - Documentation only: the English and Chinese READMEs now link to each other
8
+ from their first line, so the language switch is discoverable.
9
+ - Added this changelog (the package had none).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VviLliAm-qwq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # dsh-git-bash
2
+
3
+ **English** · [中文](README.zh.md)
4
+
5
+ Make a **Git for Windows** installation resolvable as `bash` inside the dsh
6
+ host process, so the official bash shell stack can run on Windows.
7
+
8
+ ## The problem it solves
9
+
10
+ dsh ships two shell stacks and gates them by platform: on Windows
11
+ `@deepseek-ai/dsh-base` mounts `pwsh-sandbox` + `tool-pwsh` and disables
12
+ `bash-sandbox` + `tool-bash`. The bash stack is not disabled for lack of
13
+ support — it is disabled because it spawns the **bare name** `bash`:
14
+
15
+ - the executor runs `["bash", "-c", command]`, and
16
+ - the sandbox layer builds its own `["bash", "-c", command]` for the confined
17
+ runner.
18
+
19
+ Neither place accepts a configurable executable path. A stock Windows PATH does
20
+ not resolve `bash`: Git for Windows puts `cmd\git.exe` on PATH, but not
21
+ `bin\bash.exe`. So the only seam that reaches both spawn sites is the process
22
+ PATH — which is what this plugin edits.
23
+
24
+ ## What it does
25
+
26
+ On `win32` only, during `apply()`:
27
+
28
+ 1. detects a Git installation (`%ProgramFiles%\Git\bin`,
29
+ `%ProgramFiles(x86)%\Git\bin`, `%LOCALAPPDATA%\Programs\Git\bin`, then
30
+ `C:\Program Files\Git\bin`) and checks that it really holds `bash.exe`;
31
+ 2. prepends that directory to `process.env.PATH` — but only when `bash` is not
32
+ resolvable already, and never reordering an existing entry;
33
+ 3. records what it decided in `~/.dsh-tui/dsh-git-bash.log` (bounded: past
34
+ 128 KiB the older half is dropped, and `node --test` runs never write it);
35
+ 4. restores the previous PATH on unload — but only while the value is still its
36
+ own, so a PATH someone else edited afterwards is left alone.
37
+
38
+ On other platforms it logs `skipped: not win32` and does nothing.
39
+
40
+ It never throws: a missing installation, an unreadable log or a hostile config
41
+ leaves the host exactly as it was.
42
+
43
+ ## Enabling the bash stack: the bundle patch does both halves
44
+
45
+ A plugin cannot register a tool; it can only make the environment the official
46
+ tools need. So this package's own bundle patch does both halves, and installing
47
+ the package IS the installation:
48
+
49
+ ```yaml
50
+ - insert:
51
+ - id: dsh-git-bash
52
+ name: 'dsh-git-bash'
53
+ - id: bash-sandbox # enabled (config restated: a patch replaces it whole)
54
+ disabled: false
55
+ - id: pwsh-sandbox # disabled
56
+ disabled: true
57
+ - id: tool-bash # enabled -> the `bash` tool appears
58
+ disabled: false
59
+ - id: tool-pwsh # disabled
60
+ disabled: true
61
+ ```
62
+
63
+ Both stacks provide `ctx.shell`, so exactly one may mount: this is a swap, not
64
+ an addition. `pwsh` remains reachable from bash (`powershell -Command '…'`) if
65
+ you need it.
66
+
67
+ **Reverting is removing the bundle** (`dsh plugin --profile <p> remove
68
+ dsh-git-bash`): every override above disappears with it and the platform gating
69
+ in `@deepseek-ai/dsh-base` applies again.
70
+
71
+ > **Do not put this swap in a LIVE profile's own `cordis.patch.yml` instead.**
72
+ > That file is watched by `patchReload: live`, so editing it re-configures
73
+ > running sessions immediately — before this plugin's row is loaded, which
74
+ > leaves them with a shell stack that cannot spawn `bash`. A bundle patch is
75
+ > read at boot, which is the order this needs.
76
+
77
+ ## Install
78
+
79
+ ```sh
80
+ dsh plugin --profile dsh-tui add file:/absolute/path/to/dsh-git-bash
81
+ ```
82
+
83
+ Then restart the TUI (`/restart`) and confirm the composition before trusting
84
+ it:
85
+
86
+ ```sh
87
+ dsh --profile dsh-tui --dump-config # composes the tree without starting the UI
88
+ ```
89
+
90
+ ## Configuration
91
+
92
+ | Key | Type | Default | Meaning |
93
+ | --- | --- | --- | --- |
94
+ | `gitBinDir` | string | `""` | Directory holding `bash.exe`. Empty = auto-detect. When set, it is the only candidate — a typo is reported instead of being papered over. |
95
+
96
+ Set it on the plugin's row when detection cannot find your installation:
97
+
98
+ ```yaml
99
+ - id: dsh-git-bash
100
+ config:
101
+ gitBinDir: 'D:\PortableGit\bin'
102
+ ```
103
+
104
+ ## Known limitations
105
+
106
+ - **This enables a platform-gated stack.** dsh disables the bash rows on Windows
107
+ by design; this plugin removes the reason it cannot work, but that composition
108
+ is not one upstream tests. Keep the `.bak` of your `cordis.patch.yml`: rolling
109
+ back is deleting the four override lines above.
110
+ - **Sandbox modes are the untested part.** With `danger-full-access` the
111
+ executor skips the confinement wrapper entirely, which is what has been
112
+ exercised here. Under `workspace-write` the sandbox layer wraps the command in
113
+ the Windows ACL restricted-token runner — a path built for POSIX. Treat
114
+ confined modes as unverified.
115
+ - A plugin cannot add tools; it can only make the environment the official tools
116
+ need. If the bash rows are not enabled, this plugin changes nothing visible.
117
+ - The PATH edit is process-wide inside the host process, which is exactly the
118
+ point: every consumer resolving `bash` (the agent's own commands included)
119
+ benefits.
120
+
121
+ ## Development
122
+
123
+ ```sh
124
+ pnpm install
125
+ pnpm test # node:test unit tests (pure discovery/PATH rules)
126
+ pnpm check:encoding # no UTF-8 BOM / damaged sequences / tab indentation
127
+ pnpm verify # both, in order
128
+ ```
129
+
130
+ The tests are pure and injectable: `resolveBinDir` takes its environment and its
131
+ existence probe as arguments, so the discovery rules are pinned without touching
132
+ the machine. The win32 cases exercise the real installation when one is present
133
+ and skip otherwise.
134
+
135
+ ## Diagnostics
136
+
137
+ `~/.dsh-tui/dsh-git-bash.log` holds one line per decision: the detected
138
+ directory, `already resolvable`, `PATH left unchanged`, the unload restore (or
139
+ `PATH left alone: it changed after this plugin`). Every message is also sent to
140
+ the host logger.
141
+
142
+ ## Publishing
143
+
144
+ - **Repository**: <https://github.com/VviLliAm-qwq/dsh-git-bash> (public)
145
+
146
+ ## License
147
+
148
+ MIT.
package/README.zh.md ADDED
@@ -0,0 +1,107 @@
1
+ # dsh-git-bash
2
+
3
+ **中文** · [English](README.md)
4
+
5
+ 让 **Git for Windows** 的 `bash` 在 dsh 宿主进程里能被解析到,从而让官方 bash shell 工具栈在 Windows 上跑起来。
6
+
7
+ ## 它解决什么问题
8
+
9
+ dsh 带两套 shell 栈,并按平台互相门控:Windows 下 `@deepseek-ai/dsh-base` 挂 `pwsh-sandbox` + `tool-pwsh`,同时把 `bash-sandbox` + `tool-bash` 关掉。关掉 bash 栈**不是因为不支持**,而是因为它 spawn 的是**裸名字 `bash`**:
10
+
11
+ - 执行器跑的是 `["bash", "-c", command]`;
12
+ - 沙箱层为受限运行器**自己又拼了一份** `["bash", "-c", command]`。
13
+
14
+ 这两处都不接受"可配置的可执行文件路径"。而 Windows 默认 PATH 里没有 `bash`:Git for Windows 只把 `cmd\git.exe` 放进 PATH,不含 `bin\bash.exe`。所以能同时覆盖这两个 spawn 点的唯一接缝就是**进程 PATH**——本插件改的就是它。
15
+
16
+ ## 它做什么
17
+
18
+ 只在 `win32` 上,在 `apply()` 期间:
19
+
20
+ 1. 探测 Git 安装位置(`%ProgramFiles%\Git\bin`、`%ProgramFiles(x86)%\Git\bin`、`%LOCALAPPDATA%\Programs\Git\bin`,最后 `C:\Program Files\Git\bin`),并确认里面**真的有** `bash.exe`;
21
+ 2. 把该目录**前置**进 `process.env.PATH`——仅在 `bash` 尚不可解析时动手,且从不重排已有条目;
22
+ 3. 把决定写进 `~/.dsh-tui/dsh-git-bash.log`(有上限:超过 128 KiB 丢旧的一半;`node --test` 下不写文件);
23
+ 4. 卸载时还原原 PATH——但只在**该值仍是自己设的那个**时才还原,别人之后改过的 PATH 一律不动。
24
+
25
+ 其他平台只记一行 `skipped: not win32`,什么都不做。
26
+
27
+ 它永不抛异常:没装 Git、日志写不了、配置是垃圾——宿主都保持原样。
28
+
29
+ ## 启用 bash 栈:插件自带的 bundle patch 两件一起做
30
+
31
+ 插件本身不能注册工具,只能把官方工具需要的环境准备好。所以**本包自己的 bundle patch 把两件事一起做了**——装上这个包就是全部安装步骤:
32
+
33
+ ```yaml
34
+ - insert:
35
+ - id: dsh-git-bash
36
+ name: 'dsh-git-bash'
37
+ - id: bash-sandbox # 开(config 原样回填:patch 会整体替换它)
38
+ disabled: false
39
+ - id: pwsh-sandbox # 关
40
+ disabled: true
41
+ - id: tool-bash # 开 → 出现 `bash` 工具
42
+ disabled: false
43
+ - id: tool-pwsh # 关
44
+ disabled: true
45
+ ```
46
+
47
+ 两套栈都提供 `ctx.shell`,所以只能挂一个:这是**换栈**不是叠加。需要 PowerShell 时仍可从 bash 里调(`powershell -Command '…'`)。
48
+
49
+ **回滚 = 卸载这个包**(`dsh plugin --profile <p> remove dsh-git-bash`):上面所有覆盖随包一起消失,`@deepseek-ai/dsh-base` 的平台门控重新生效。
50
+
51
+ > **不要把这段换栈放进"活着的" profile 自己的 `cordis.patch.yml`。** 那个文件被 `patchReload: live` 监视,一改就**立刻重配运行中的会话**——而那时本插件的行还没加载,于是那些会话的 shell 栈连 `bash` 都 spawn 不到。bundle patch 是启动时读取的,顺序才是对的。
52
+
53
+ ## 安装
54
+
55
+ ```sh
56
+ dsh plugin --profile dsh-tui add file:/到本仓库的绝对路径/dsh-git-bash
57
+ ```
58
+
59
+ 之后在 TUI 内 `/restart`,并在信任它之前先验证组合:
60
+
61
+ ```sh
62
+ dsh --profile dsh-tui --dump-config # 只组合插件树,不启动界面
63
+ ```
64
+
65
+ ## 配置
66
+
67
+ | 键 | 类型 | 默认 | 含义 |
68
+ | --- | --- | --- | --- |
69
+ | `gitBinDir` | string | `""` | 存放 `bash.exe` 的目录。留空 = 自动探测。一旦设置它就是**唯一**候选——写错会如实报告,而不是被"兜住"。 |
70
+
71
+ 探测不到时可以在这行上指定:
72
+
73
+ ```yaml
74
+ - id: dsh-git-bash
75
+ config:
76
+ gitBinDir: 'D:\PortableGit\bin'
77
+ ```
78
+
79
+ ## 已知限制
80
+
81
+ - **这等于启用了官方按平台关掉的栈。** dsh 有意在 Windows 上禁用 bash 行;本插件去掉了它跑不起来的原因,但这条组合并非上游测试覆盖的路径。请保留 `cordis.patch.yml` 的 `.bak`:回滚就是删掉上面那四行覆盖。
82
+ - **沙箱模式是未验证的部分。** `danger-full-access` 下执行器完全跳过受限包装,这是实测过的路径;`workspace-write` 下沙箱层会把命令交给 Windows ACL 受限令牌运行器——那条路是为 POSIX 写的。请把受限模式当作未经验证。
83
+ - 插件不能新增工具,只能把官方工具需要的环境准备好。若没有启用 bash 行,本插件不会带来任何可见变化。
84
+ - 它改的是宿主进程内**进程级**的 PATH,这正是目的:所有解析 `bash` 的消费者(包括代理自己的命令)都受益。
85
+
86
+ ## 开发与验证
87
+
88
+ ```sh
89
+ pnpm install
90
+ pnpm test # node:test 单测(纯探测 / PATH 规则)
91
+ pnpm check:encoding # 无 UTF-8 BOM / 编码损坏 / tab 缩进
92
+ pnpm verify # 以上两项依次执行
93
+ ```
94
+
95
+ 测试全是纯函数且可注入:`resolveBinDir` 的环境与"存在性探针"都从参数传入,所以探测规则能在不碰本机的前提下钉死。win32 的用例在有真实安装时跑真机、没有则跳过。
96
+
97
+ ## 诊断
98
+
99
+ `~/.dsh-tui/dsh-git-bash.log` 每个决定一行:探测到的目录、`already resolvable`、`PATH left unchanged`、卸载时的还原(或 `PATH left alone: it changed after this plugin`)。每条消息同时送宿主 logger。
100
+
101
+ ## 发布
102
+
103
+ - **仓库**:<https://github.com/VviLliAm-qwq/dsh-git-bash>(公开)
104
+
105
+ ## 许可
106
+
107
+ MIT。
@@ -0,0 +1,47 @@
1
+ # Your patch layer for this dsh profile, applied after every bundle layer:
2
+ # a top-level YAML array of loader patch entries (id-targeted config
3
+ # overrides, disables, and insert lists; `!!js` expressions allowed).
4
+ #
5
+ # dsh-git-bash does two things, and the second one is why this patch is not
6
+ # just an insert:
7
+ #
8
+ # 1. inserts the dsh-git-bash row, which prepends a Git for Windows `bin`
9
+ # directory to the host process PATH so the bare name `bash` resolves;
10
+ # 2. swaps the two platform-gated shell stacks. `@deepseek-ai/dsh-base`
11
+ # disables the bash rows on win32 (`disabled: !!js process.platform ===
12
+ # 'win32'`) because their executor and sandbox layer both spawn `bash`.
13
+ # Both stacks provide `ctx.shell`, so exactly one may mount: this is a
14
+ # swap, not an addition. PowerShell stays reachable from bash
15
+ # (`powershell -Command '…'`).
16
+ #
17
+ # Revert by removing the bundle (`dsh plugin --profile <p> remove dsh-git-bash`):
18
+ # every override below disappears with it. `bash-sandbox`'s config is restated
19
+ # because a patch replaces the row's whole config object.
20
+ #
21
+ # The row is inserted last, after the stack rows it enables. That order is
22
+ # harmless: nothing spawns `bash` while the tree loads, and this plugin has
23
+ # already fixed PATH by the time any tool call runs. Keep a LIVE profile's own
24
+ # `cordis.patch.yml` out of this swap — that file is watched by
25
+ # `patchReload: live`, so editing it swaps the stack of running sessions
26
+ # instantly, before this row is loaded.
27
+ - insert:
28
+ - id: dsh-git-bash
29
+ name: 'dsh-git-bash'
30
+
31
+ - id: bash-sandbox
32
+ name: '@deepseek-ai/dsh-bash-sandbox'
33
+ disabled: false
34
+ config:
35
+ timeoutMs: 60000
36
+
37
+ - id: pwsh-sandbox
38
+ name: '@deepseek-ai/dsh-pwsh-sandbox'
39
+ disabled: true
40
+
41
+ - id: tool-bash
42
+ name: '@deepseek-ai/dsh-tool-bash'
43
+ disabled: false
44
+
45
+ - id: tool-pwsh
46
+ name: '@deepseek-ai/dsh-tool-pwsh'
47
+ disabled: true
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Pure helpers for making a Git for Windows installation resolvable as `bash`.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * The official bash shell stack (`@deepseek-ai/dsh-bash-sandbox` +
7
+ * `@deepseek-ai/dsh-tool-bash`) spawns the *bare name* `bash` in two places:
8
+ * the local executor's `run()`/`start()` argv (`["bash", "-c", command]`) and,
9
+ * crucially, the sandbox layer's `confine()`, which builds its own
10
+ * `["bash", "-c", command]` for the confined runner. Neither goes through a
11
+ * configurable executable path, so the only place a Windows installation can
12
+ * be injected is the process PATH that resolves that name.
13
+ *
14
+ * Everything here is pure and injectable: no `process.env` reads, no
15
+ * filesystem calls, so the discovery rules are unit-testable at fixed inputs.
16
+ *
17
+ * @module dsh-git-bash/git-path
18
+ */
19
+
20
+ import { statSync } from 'node:fs'
21
+ import { delimiter as pathDelimiter, join } from 'node:path'
22
+
23
+ /** Executable the shell stack ultimately spawns. */
24
+ export const BASH_EXECUTABLE = 'bash.exe'
25
+
26
+ /**
27
+ * Installation roots probed when the config names none, most common first.
28
+ *
29
+ * `ProgramFiles(x86)` is read through a lookup function because its name is not
30
+ * a valid identifier for dot access on some engines, and an unset variable must
31
+ * simply drop the candidate rather than produce a path relative to the cwd.
32
+ *
33
+ * @param env - environment to read.
34
+ * @returns Candidate `bin` directories, de-duplicated, in probe order.
35
+ */
36
+ export function candidateBinDirs(env = {}) {
37
+ const read = name => {
38
+ const value = env[name]
39
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
40
+ }
41
+ const roots = [
42
+ read('ProgramFiles'),
43
+ read('ProgramW6432'),
44
+ read('ProgramFiles(x86)'),
45
+ read('LOCALAPPDATA') === undefined ? undefined : join(read('LOCALAPPDATA'), 'Programs'),
46
+ // The default installer target, kept last so an env-driven hit wins.
47
+ 'C:\\Program Files',
48
+ ]
49
+ const out = []
50
+ for (const root of roots) {
51
+ if (root === undefined) continue
52
+ const candidate = join(root, 'Git', 'bin')
53
+ if (!out.some(existing => existing.toLowerCase() === candidate.toLowerCase())) out.push(candidate)
54
+ }
55
+ return out
56
+ }
57
+
58
+ /** Whether `directory` holds the executable as a regular file. */
59
+ function defaultHasBash(directory) {
60
+ try {
61
+ return statSync(join(directory, BASH_EXECUTABLE)).isFile()
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The directory that makes `bash` resolvable, or `undefined`.
69
+ *
70
+ * @param options.configured - explicit `gitBinDir` from the plugin config; when
71
+ * set it is the ONLY candidate (an explicit answer must not be second-guessed).
72
+ * @param options.env - environment used to build the candidate list.
73
+ * @param options.hasBash - injectable existence probe (defaults to `fs`).
74
+ * @returns The first candidate containing the executable, else `undefined`.
75
+ */
76
+ export function resolveBinDir(options = {}) {
77
+ const hasBash = options.hasBash ?? defaultHasBash
78
+ const configured = options.configured
79
+ if (typeof configured === 'string' && configured.trim() !== '') {
80
+ const directory = configured.trim()
81
+ return hasBash(directory) ? directory : undefined
82
+ }
83
+ for (const candidate of candidateBinDirs(options.env)) {
84
+ if (hasBash(candidate)) return candidate
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ /**
90
+ * Whether one PATH segment list already contains `directory`.
91
+ *
92
+ * Windows path comparison is case-insensitive; trailing separators and empty
93
+ * segments (`;;`) are ignored so a hand-edited PATH cannot defeat the check.
94
+ */
95
+ export function pathContains(pathValue, directory, options = {}) {
96
+ const separator = options.delimiter ?? pathDelimiter
97
+ const fold = options.caseInsensitive ?? process.platform === 'win32'
98
+ const normalize = value => {
99
+ const trimmed = value.trim().replace(/[\\/]+$/, '')
100
+ return fold ? trimmed.toLowerCase() : trimmed
101
+ }
102
+ const wanted = normalize(directory)
103
+ if (wanted === '') return true
104
+ return String(pathValue ?? '')
105
+ .split(separator)
106
+ .some(segment => normalize(segment) === wanted)
107
+ }
108
+
109
+ /**
110
+ * Prepend `directory` to a PATH value.
111
+ *
112
+ * Returns the input unchanged when the directory is already present anywhere in
113
+ * the list: re-ordering a PATH the user curated is a side effect this plugin
114
+ * has no business taking, and an unchanged value also lets the caller detect
115
+ * "nothing to do" without a second probe.
116
+ *
117
+ * @param pathValue - current PATH (may be empty or undefined).
118
+ * @param directory - directory to prepend.
119
+ * @param options - `delimiter` and `caseInsensitive` overrides.
120
+ * @returns The new PATH value, or the original when nothing had to change.
121
+ */
122
+ export function prependPath(pathValue, directory, options = {}) {
123
+ const separator = options.delimiter ?? pathDelimiter
124
+ const current = typeof pathValue === 'string' ? pathValue : ''
125
+ if (directory === undefined || directory === null || String(directory).trim() === '') return current
126
+ if (pathContains(current, directory, options)) return current
127
+ const head = String(directory).replace(/[\\/]+$/, '')
128
+ return current === '' ? head : `${head}${separator}${current}`
129
+ }
package/lib/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * dsh-git-bash — Cordis entry.
3
+ *
4
+ * This module re-exports exactly the three symbols a Cordis plugin entry is
5
+ * read for (`name`, `Config`, `apply`) and nothing else: an entry module
6
+ * carrying extra symbols changes how the loader wraps the activation, which is
7
+ * the failure mode that costs an afternoon elsewhere in this ecosystem
8
+ * (see `docs/DSH-PLUGIN-SOP.md` §2.1). Implementation lives in `./plugin.js`.
9
+ *
10
+ * @module dsh-git-bash
11
+ */
12
+
13
+ export { Config, apply, name } from './plugin.js'
package/lib/plugin.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * dsh-git-bash — make a Git for Windows installation resolvable as `bash`.
3
+ *
4
+ * WHY A PLUGIN AND NOT A CONFIG FLAG
5
+ *
6
+ * The official bash shell stack is platform-gated off on Windows
7
+ * (`@deepseek-ai/dsh-base` mounts `bash-sandbox` and `tool-bash` with
8
+ * `disabled: !!js process.platform === 'win32'`), because it spawns the bare
9
+ * name `bash` — which a stock Windows PATH does not resolve, since Git for
10
+ * Windows only puts `cmd\git.exe` on PATH, not `bin\bash.exe`.
11
+ *
12
+ * Neither the executor's `run()`/`start()` argv nor the sandbox layer's
13
+ * `confine()` accepts an executable path, so the one seam that reaches both is
14
+ * the process PATH. This plugin prepends the detected `Git\bin` directory to
15
+ * `process.env.PATH` of the host process, which is what the child-process
16
+ * launcher resolves `bash` against. Enabling the bash rows in the profile is
17
+ * then a separate, explicit composition decision (see README).
18
+ *
19
+ * The plugin is deliberately defensive: it never throws, never fails the host
20
+ * startup, logs what it decided, and restores the PATH it changed on unload
21
+ * only while it still owns that change.
22
+ *
23
+ * @module dsh-git-bash
24
+ */
25
+
26
+ import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { delimiter, join } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import z from '@deepseek-ai/schemastery'
31
+ import { BASH_EXECUTABLE, prependPath, resolveBinDir } from './git-path.js'
32
+
33
+ export const name = 'dsh-git-bash'
34
+
35
+ /** Every key has a default: a missing composition entry changes nothing. */
36
+ export const Config = z.object({
37
+ /**
38
+ * Directory holding `bash.exe`. Empty means "detect it" — an explicit value
39
+ * is used as given (and reported when it holds no executable), so a machine
40
+ * with a non-standard install is a one-line config away.
41
+ */
42
+ gitBinDir: z.string().default(''),
43
+ })
44
+
45
+ /** Boot defaults mirroring the schema. */
46
+ const DEFAULTS = Object.freeze({ gitBinDir: '' })
47
+
48
+ const DIAG_LOG = join(homedir(), '.dsh-tui', 'dsh-git-bash.log')
49
+ /** Above this size the log is trimmed to its newest half. */
50
+ const MAX_LOG_BYTES = 128 * 1024
51
+ /** Inside `node --test` nothing may touch a user's log file. */
52
+ const FILE_LOG_ENABLED = typeof process.env?.NODE_TEST_CONTEXT !== 'string'
53
+
54
+ /** Coerce an untrusted config object into the known key with a valid type. */
55
+ export function sanitizeConfig(config) {
56
+ const out = { ...DEFAULTS }
57
+ if (config !== null && typeof config === 'object' && typeof config.gitBinDir === 'string') {
58
+ out.gitBinDir = config.gitBinDir
59
+ }
60
+ return out
61
+ }
62
+
63
+ /** Append one bounded diagnostic line. */
64
+ function appendLogLine(path, line) {
65
+ try {
66
+ if (statSync(path).size > MAX_LOG_BYTES) {
67
+ writeFileSync(path, readFileSync(path, 'utf8').slice(-Math.floor(MAX_LOG_BYTES / 2)))
68
+ }
69
+ } catch {
70
+ // Missing or unreadable file: the append below recreates it.
71
+ }
72
+ appendFileSync(path, line)
73
+ }
74
+
75
+ /** Quiet logger: the host logger always, plus the plugin's own bounded file. */
76
+ function createLogger(ctx) {
77
+ const write = (level, message) => {
78
+ try {
79
+ ctx.logger?.[level]?.(`dsh-git-bash: ${message}`)
80
+ } catch {
81
+ // Observability only; never let logging break the plugin.
82
+ }
83
+ if (!FILE_LOG_ENABLED) return
84
+ try {
85
+ appendLogLine(DIAG_LOG, `${new Date().toISOString()} ${level} ${message}\n`)
86
+ } catch {
87
+ // An unwritable log path is not worth surfacing.
88
+ }
89
+ }
90
+ return {
91
+ info: message => write('info', message),
92
+ warn: message => write('warn', message),
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Wire the plugin.
98
+ *
99
+ * @param ctx - Cordis context of this activation.
100
+ * @param config - composition-entry config; `gitBinDir` defaults to "".
101
+ */
102
+ export function apply(ctx, config) {
103
+ const log = createLogger(ctx)
104
+ try {
105
+ log.info(`apply started pid=${process.pid} node=${process.version} file=${fileURLToPath(import.meta.url)}`)
106
+ } catch {
107
+ // Logging must never be the reason a plugin fails to load.
108
+ }
109
+
110
+ try {
111
+ const resolved = sanitizeConfig(config)
112
+ if (process.platform !== 'win32') {
113
+ // POSIX installs resolve `bash` from the system PATH; prepending a
114
+ // Windows-style Git directory there would be wrong.
115
+ log.info('skipped: not win32')
116
+ return
117
+ }
118
+
119
+ const directory = resolveBinDir({ configured: resolved.gitBinDir, env: process.env })
120
+ if (directory === undefined) {
121
+ log.warn(
122
+ resolved.gitBinDir === ''
123
+ ? 'no Git for Windows installation found; PATH left unchanged (set gitBinDir to point at one)'
124
+ : `gitBinDir holds no ${BASH_EXECUTABLE}: ${resolved.gitBinDir}; PATH left unchanged`,
125
+ )
126
+ return
127
+ }
128
+
129
+ const before = typeof process.env.PATH === 'string' ? process.env.PATH : ''
130
+ const after = prependPath(before, directory, { delimiter })
131
+ if (after === before) {
132
+ log.info(`already resolvable, PATH left unchanged: ${directory}`)
133
+ return
134
+ }
135
+
136
+ process.env.PATH = after
137
+ log.info(`PATH prepended with ${directory}`)
138
+
139
+ // Restore only while the value is still ours: another plugin (or the user)
140
+ // may have edited PATH afterwards, and their change must win.
141
+ ctx.effect(function* pathEffect() {
142
+ yield () => {
143
+ try {
144
+ if (process.env.PATH === after) {
145
+ process.env.PATH = before
146
+ log.info('PATH restored')
147
+ } else {
148
+ log.info('PATH left alone: it changed after this plugin')
149
+ }
150
+ } catch {
151
+ // Best-effort teardown.
152
+ }
153
+ }
154
+ }, 'dsh-git-bash PATH')
155
+ } catch (error) {
156
+ log.warn(`apply failed: ${error instanceof Error ? error.message : String(error)}`)
157
+ }
158
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "dsh-git-bash",
3
+ "version": "0.1.1",
4
+ "description": "Make a Git for Windows installation resolvable as `bash` inside the dsh host process, so the official bash shell stack (dsh-bash-sandbox + dsh-tool-bash) can run on Windows",
5
+ "packageManager": "pnpm@11.22.0",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/VviLliAm-qwq/dsh-git-bash.git"
10
+ },
11
+ "type": "module",
12
+ "main": "lib/index.js",
13
+ "exports": {
14
+ ".": "./lib/index.js",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "README.zh.md",
22
+ "CHANGELOG.md",
23
+ "LICENSE"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ }
29
+ },
30
+ "engines": {
31
+ "node": "^22.19 || >=24"
32
+ },
33
+ "scripts": {
34
+ "check:encoding": "node scripts/check-encoding.mjs",
35
+ "test": "node --test",
36
+ "verify": "npm run check:encoding && npm run test"
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/schemastery": "^3.18.1"
40
+ },
41
+ "devDependencies": {
42
+ "@deepseek-ai/schemastery": "^3.18.1"
43
+ }
44
+ }