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 +9 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/README.zh.md +107 -0
- package/cordis.patch.yml +47 -0
- package/lib/git-path.js +129 -0
- package/lib/index.js +13 -0
- package/lib/plugin.js +158 -0
- package/package.json +44 -0
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。
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
package/lib/git-path.js
ADDED
|
@@ -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
|
+
}
|