dsh-gitbash-shell 0.40.4 → 0.41.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 +19 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +6 -6
- package/src/index.js +67 -0
- package/src/inline-tools.js +80 -4
- package/src/terminal-bundle.js +346 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
> 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
|
|
4
4
|
> 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
|
|
5
5
|
|
|
6
|
+
## v0.41.0 — 2026-10-19
|
|
7
|
+
|
|
8
|
+
### feat:官方「持久终端」实验包 Git Bash 化自动接管;dsh-ops 并存时剔除其 shell 工具;terminal_* 方言说明
|
|
9
|
+
|
|
10
|
+
- **持久终端自动接管(v0.41.0 主特性)**:官方实验包 `@deepseek-ai/dsh-experimental-terminal-bundle` 在 win32 上默认 `optional-terminal-bash` 禁 / `optional-terminal-pwsh` 启——开启「持久终端」后终端是 PowerShell,与本插件的 bash-first 语义相反。v0.41 起检测到 bundle 挂载后,由插件维护 profile patch(bundle 组子行按 id 覆盖,官方 `applyEntryPatches` 通道):`optional-terminal-bash` → `disabled: false` + `shellPath` 指 Git Bash;`optional-terminal-pwsh` → `disabled: true`(仅当该行不存在时写入,已存在则不动并提示并存)。用户显式配置(bash 行 `disabled: true` 或 `shellPath` 指向别处)一律尊重,不覆盖。
|
|
11
|
+
- **为什么直写 patch 文件**:官方 config-editor 两个硬限制(实测 packages/boot/config-editor)——`entries()` 只寻址 include 挂载的顶层行(bundle 组内子行不可见);`edit()` 只写 config 键,写不了行级 `disabled`。bundle 子行覆盖只能落在 profile patch 文件上。安全链:`parseDocument` 保注释保 `!!js` → 变更后 re-parse 自检 → 原子写(`@deepseek-ai/dsh-atomic-write` 优先,降级 tmp+rename,mode 0600)→ 回读验证 → 失败回滚原字节。守卫测试随之演进:index.js 豁免「no fs path to the profile patch」整版禁令(bundle 接管是有意的直写,安全特征有断言),**侧栏终端接管仍走 configEditor 不变**。
|
|
12
|
+
- **terminal_* 方言说明**:bundle 接管生效后(`terminalDialect.gitBash`),工具路由说明进入持久终端通道——`terminal_send`/`terminal_signal` 的命令按 Git Bash/MSYS 方言书写,与单次 bash 工具同一套提示。
|
|
13
|
+
- **dsh-ops 并存时的 shell 工具剔除(用户决策定案)**:安装了本插件 = bash 用本插件方案;`ops_bash` / `ops_run` 在本插件 bash 工具在场的 agent 上默认 deny(`tools.restrict` + `tools/pre-execute` 双层),不做设置开关;ops 的 file / 后台 / PTC 增强不受影响。
|
|
14
|
+
- 验证:`npm test` 263 项全绿(新增 tests/terminal-bundle.mjs 26 项:plan 矩阵 12 + writer 12 + report + detect);守卫测试演进 2 处(compatibility assemble 段参数面补 `terminalDialect`;smoke 侧栏守卫按上述范围收窄)。
|
|
15
|
+
|
|
16
|
+
## v0.40.5 — 2026-10-18
|
|
17
|
+
|
|
18
|
+
### 工具行字号跟随宿主字体设置;半像素字号归整(fix)
|
|
19
|
+
|
|
20
|
+
- **内容面接官方 delta**:工具行可见面接 `--dsh-content-font-delta-secondary`(官方派生变量 = 宿主「设置 → 字体大小」正文值 − 1px 再钳制,非独立设置项)——`.gb-toolRow`(12px 基准)、`.gb-toolState` / `.gb-toolSrc`(11px 基准)、`.gb-toolResult` / `.gb-toolFail`(基准 11.5→12px)。用户把正文字号调大后,工具行、状态徽标、来源路径、结果与失败说明同步放大,不再钉死。
|
|
21
|
+
- **半像素归整官方档位**:官方字号体系没有 x.5 值——`.gb-sectionTitle` 12.5→12px(设置卡 chrome 标题,保持固定不接 delta,与官方设置卡一致);`.gb-toolResult` / `.gb-toolFail` 11.5→12px 基准(默认观感 +0.5px,换来 delta 跟随)。
|
|
22
|
+
- 调查结论(四插件联合排查,2026-10-18):官方 ui-deliverables 的 IDE 类面板本身全部固定字号、零 delta,设置卡 chrome 固定亦合规;本插件只有上述工具行属于「内容随设置缩放」范畴,其余小字(偏移、徽标)为有意设计不动。
|
|
23
|
+
- 验证:`npm test` 236 项全绿(1 skip 为既有基线);CSS_TEXT 内 delta 引用 5 处、半像素残留 0;真机双端验证待用户复验(改后默认 14px 设置下视觉基本无变化,调大后生效)。
|
|
24
|
+
|
|
6
25
|
## v0.40.4 — 2026-10-17
|
|
7
26
|
|
|
8
27
|
### 终端工具扫描只认可执行条目:手册页/文档/同名目录不再冒充命令(fix)
|
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-external/dsh-gitbash-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "Windows 全模式 Git Bash:自带 bash 工具(私有 Git Bash 执行器实例,官方 PowerShell 执行器原样保留),官方四个模式经行内工具补位直接获得 Git Bash(v0.35.0 起;v0.39.0 起不再替换宿主执行器),四个 *·Git Bash 变体 preset 可开关;官方 pwsh 工具默认隐藏、开关可见(真 PowerShell);附带 gb_* FastCtx 文件工具,检测到 dsh-ops 自动让位。联动:dsh-ptc-cordis-preset(PTC 创造模式带 Git Bash 行且可去重)+ dsh-better-sidebar(终端标签自动切 Git Bash)。",
|
|
6
6
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gitbash-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Windows 上给 agent 一个真正的 Git Bash 命令工具:私有 Git Bash 执行器实例(官方 PowerShell 执行器原样保留),统一 MSYS 盘根路径方言,官方 pwsh 工具默认隐藏可开关,物化四个内置预设的 Git Bash 变体,侧栏终端接管可开关。· Give agents a real Git Bash tool on Windows: a private Git Bash executor instance (the official PowerShell executor stays untouched), a unified MSYS drive-root path dialect, an opt-in official pwsh tool, and four Git Bash preset variants.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/client.js
CHANGED
|
@@ -1861,7 +1861,7 @@ window.__ModuleLoader__.load({
|
|
|
1861
1861
|
".gb-modalTitle{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary)}",
|
|
1862
1862
|
".gb-modalList{margin:0;padding-left:18px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary);word-break:break-all}",
|
|
1863
1863
|
".gb-input{flex:1;min-width:0;padding:6px 8px;font-size:12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary)}",
|
|
1864
|
-
".gb-sectionTitle{font-size:
|
|
1864
|
+
".gb-sectionTitle{font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-secondary)}",
|
|
1865
1865
|
".gb-input{flex:1;min-width:0;appearance:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:6px 10px;outline:none}",
|
|
1866
1866
|
".gb-input:focus{border-color:var(--dsw-alias-brand-primary)}",
|
|
1867
1867
|
".gb-saveBtn{appearance:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;padding:6px 12px;cursor:pointer;transition:border-color .16s,color .16s}",
|
|
@@ -1871,15 +1871,15 @@ window.__ModuleLoader__.load({
|
|
|
1871
1871
|
".gb-stack{display:flex;flex-direction:column;gap:8px}",
|
|
1872
1872
|
/* terminal toolchain (v0.30.0) */
|
|
1873
1873
|
".gb-tools{display:flex;flex-direction:column;max-height:260px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 8px;background:var(--dsw-alias-bg-layer-1,transparent)}",
|
|
1874
|
-
".gb-toolRow{display:flex;align-items:center;gap:8px;font-size:
|
|
1874
|
+
".gb-toolRow{display:flex;align-items:center;gap:8px;font-size:calc(12px + var(--dsh-content-font-delta-secondary, 0px));line-height:1.95;color:var(--dsw-alias-label-secondary);cursor:pointer}",
|
|
1875
1875
|
".gb-toolRow input{flex:none;margin:0}",
|
|
1876
1876
|
".gb-toolCmd{flex:none;min-width:78px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--dsw-alias-label-primary)}",
|
|
1877
|
-
".gb-toolState{flex:none;font-size:
|
|
1877
|
+
".gb-toolState{flex:none;font-size:calc(11px + var(--dsh-content-font-delta-secondary, 0px));padding:0 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);white-space:nowrap}",
|
|
1878
1878
|
".gb-st-missing{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}",
|
|
1879
1879
|
".gb-st-managed{border-color:var(--dsw-alias-state-warning-primary,#d29922);color:var(--dsw-alias-state-warning-primary,#d29922)}",
|
|
1880
1880
|
".gb-st-current{border-color:var(--dsw-alias-state-success-primary,#3fb950);color:var(--dsw-alias-state-success-primary,#3fb950)}",
|
|
1881
|
-
".gb-toolSrc{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:
|
|
1882
|
-
".gb-toolResult{display:flex;gap:6px;font-size:
|
|
1881
|
+
".gb-toolSrc{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:calc(11px + var(--dsh-content-font-delta-secondary, 0px));color:var(--dsw-alias-label-tertiary)}",
|
|
1882
|
+
".gb-toolResult{display:flex;gap:6px;font-size:calc(12px + var(--dsh-content-font-delta-secondary, 0px));line-height:1.6;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}",
|
|
1883
1883
|
".gb-ok{flex:none;color:var(--dsw-alias-state-success-primary,#3fb950)}",
|
|
1884
1884
|
".gb-bad{flex:none;color:var(--dsw-alias-status-danger,#e5484d)}",
|
|
1885
1885
|
".gb-segBtn:disabled{opacity:.45;cursor:default}",
|
|
@@ -1895,7 +1895,7 @@ window.__ModuleLoader__.load({
|
|
|
1895
1895
|
".gb-toolBarFill{display:block;height:100%;width:38%;border-radius:2px;background:var(--dsw-alias-brand-primary);animation:gb-slide 1.15s ease-in-out infinite}",
|
|
1896
1896
|
".gb-jobBar{height:6px;border-radius:3px;background:var(--dsw-alias-border-l2);overflow:hidden}",
|
|
1897
1897
|
".gb-jobBarFill{display:block;height:100%;border-radius:3px;background:var(--dsw-alias-brand-primary);transition:width .3s ease}",
|
|
1898
|
-
".gb-toolFail{margin:0 0 4px 26px;font-size:
|
|
1898
|
+
".gb-toolFail{margin:0 0 4px 26px;font-size:calc(12px + var(--dsh-content-font-delta-secondary, 0px));line-height:1.5;color:var(--dsw-alias-status-danger,#e5484d);overflow-wrap:anywhere}",
|
|
1899
1899
|
"@keyframes gb-spin{to{transform:rotate(360deg)}}",
|
|
1900
1900
|
"@keyframes gb-slide{0%{margin-left:-38%}100%{margin-left:100%}}",
|
|
1901
1901
|
].join("\n");
|
package/src/index.js
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
mkdtempSync,
|
|
50
50
|
readFileSync,
|
|
51
51
|
readdirSync,
|
|
52
|
+
renameSync,
|
|
52
53
|
rmSync,
|
|
53
54
|
writeFileSync,
|
|
54
55
|
} from 'node:fs'
|
|
@@ -59,6 +60,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
59
60
|
import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
|
|
60
61
|
import { loadSchemastery } from './schemastery.js'
|
|
61
62
|
import { applyToolRouting, mountInlineTools } from './inline-tools.js'
|
|
63
|
+
import { adoptTerminalBundlePatch, detectTerminalBundle, terminalBundleReport, terminalDialect } from './terminal-bundle.js'
|
|
64
|
+
import { resolveHostModule } from './bash-tool.js'
|
|
62
65
|
import { mountFastctx } from './fastctx-bridge.js'
|
|
63
66
|
|
|
64
67
|
/**
|
|
@@ -2250,6 +2253,7 @@ export async function apply(ctx, config = {}) {
|
|
|
2250
2253
|
applyToolRouting(assembly.tools, {
|
|
2251
2254
|
pwshVisible: liveSettings.pwshTool() === true,
|
|
2252
2255
|
msys: applies && dialect.posixPaths === true,
|
|
2256
|
+
terminalGitBash: terminalDialect.gitBash === true,
|
|
2253
2257
|
})
|
|
2254
2258
|
}
|
|
2255
2259
|
if (applies && dialect.posixPaths === true && assembly && typeof assembly === 'object') {
|
|
@@ -2726,6 +2730,69 @@ export async function apply(ctx, config = {}) {
|
|
|
2726
2730
|
}
|
|
2727
2731
|
}
|
|
2728
2732
|
|
|
2733
|
+
// ── OFFICIAL persistent-terminal bundle adoption (v0.41.0) ─────────────
|
|
2734
|
+
// The experimental terminal bundle mounts its shell backends behind a
|
|
2735
|
+
// bundle patch that on Windows enables the pwsh backend and disables the
|
|
2736
|
+
// bash one — exactly backwards for a Git Bash house. The fix is a
|
|
2737
|
+
// PROFILE-PATCH row edit (the host patch algorithm addresses group
|
|
2738
|
+
// children by id, and bundle patches are read before the profile patch
|
|
2739
|
+
// file) because configEditor can neither see bundle children nor write a
|
|
2740
|
+
// row-level `disabled`. See src/terminal-bundle.js for the full
|
|
2741
|
+
// rationale and safety rails. The terminal_* dialect sentence rides the
|
|
2742
|
+
// existing routing block once terminalDialect.gitBash turns true.
|
|
2743
|
+
if (process.platform === 'win32') {
|
|
2744
|
+
try {
|
|
2745
|
+
ctx.inject(['profileContext'], (patchCtx) => {
|
|
2746
|
+
const run = async () => {
|
|
2747
|
+
const patchPath = patchCtx.get('profileContext')?.patchPath
|
|
2748
|
+
if (typeof patchPath !== 'string' || patchPath.trim() === '') {
|
|
2749
|
+
console.log(`${TAG} persistent terminal: no profile patch path — nothing to adopt`)
|
|
2750
|
+
return
|
|
2751
|
+
}
|
|
2752
|
+
let mounted = false
|
|
2753
|
+
try {
|
|
2754
|
+
mounted = await detectTerminalBundle(ctx)
|
|
2755
|
+
} catch (error) {
|
|
2756
|
+
console.log(`${TAG} persistent terminal: bundle detection failed: ${error?.message ?? error}`)
|
|
2757
|
+
return
|
|
2758
|
+
}
|
|
2759
|
+
const resolution = readBashResolution()
|
|
2760
|
+
const result = await adoptTerminalBundlePatch(
|
|
2761
|
+
{
|
|
2762
|
+
readFile: (file) => readFileSync(file, 'utf8'),
|
|
2763
|
+
writeFileAtomic: async (file, data) => {
|
|
2764
|
+
// The host's atomic write when resolvable; a same-directory
|
|
2765
|
+
// tmp+rename otherwise — never a torn patch file.
|
|
2766
|
+
try {
|
|
2767
|
+
const atomic = await resolveHostModule(ctx, '@deepseek-ai/dsh-atomic-write')
|
|
2768
|
+
if (typeof atomic?.writeFileAtomic === 'function') return atomic.writeFileAtomic(file, data, { mode: 0o600 })
|
|
2769
|
+
} catch { /* fallback below */ }
|
|
2770
|
+
const tmp = `${file}.gitbash-${Date.now()}.tmp`
|
|
2771
|
+
writeFileSync(tmp, data, { mode: 0o600 })
|
|
2772
|
+
renameSync(tmp, file)
|
|
2773
|
+
},
|
|
2774
|
+
yaml: () => resolveHostModule(ctx, 'yaml'),
|
|
2775
|
+
},
|
|
2776
|
+
{
|
|
2777
|
+
platform: process.platform,
|
|
2778
|
+
enabled: liveSettings.autoTerminalShell(),
|
|
2779
|
+
mounted,
|
|
2780
|
+
resolved: resolution.ok ? resolution.path : '',
|
|
2781
|
+
patchPath,
|
|
2782
|
+
},
|
|
2783
|
+
)
|
|
2784
|
+
if (result.status === 'adopted' || result.status === 'updated' || result.status === 'unchanged') {
|
|
2785
|
+
terminalDialect.gitBash = true
|
|
2786
|
+
}
|
|
2787
|
+
console.log(`${TAG} ${terminalBundleReport(result)}`)
|
|
2788
|
+
}
|
|
2789
|
+
run().catch((error) => console.log(`${TAG} persistent terminal adoption failed: ${error?.message ?? error}`))
|
|
2790
|
+
})
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
console.log(`${TAG} persistent terminal adoption wiring failed: ${error?.message ?? error}`)
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2729
2796
|
// ── dsh-better-sidebar terminal adoption (Windows only) ────────────────
|
|
2730
2797
|
// The sidebar resolves its terminal shell through the settings seam per
|
|
2731
2798
|
// open; adopt it through that seam (see adoptSidebarShell for rationale).
|
package/src/inline-tools.js
CHANGED
|
@@ -91,6 +91,27 @@ export function inlineNeeds(names, { pwshVisible } = {}) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The dsh-ops shell pair (v0.41.0, owner decision): while this plugin's own
|
|
96
|
+
* bash owns a session, those two names are DENIED for the agent. The list is
|
|
97
|
+
* dsh-ops's own published surface - lib/policy.js builds every public name as
|
|
98
|
+
* TOOL_PREFIX + raw with TOOL_PREFIX = "ops_", and the shell-class raw names
|
|
99
|
+
* are "run" (one-shot shell) and "bash" (in-process shell). Their file/search
|
|
100
|
+
* (ops_inspect_local_file, ops_grep, ops_glob, ops_replace), background-job
|
|
101
|
+
* (ops_run_background, ops_job_*) and batch surfaces stay untouched - the
|
|
102
|
+
* owner's call: installing dsh-ops buys its enhancements, not a second bash.
|
|
103
|
+
*/
|
|
104
|
+
export const OPS_SHELL_TOOLS = Object.freeze(['ops_bash', 'ops_run'])
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The deny reason for an ops shell call on an agent our bash owns (route B
|
|
108
|
+
* wording): the tool exists, the host just routes shells elsewhere - the
|
|
109
|
+
* sentence names the owner and keeps the agent inside the offered face.
|
|
110
|
+
*/
|
|
111
|
+
export function deniedOpsShellReason() {
|
|
112
|
+
return 'the dsh-ops shell tools are denied on this host: dsh-gitbash-shell owns the shell (its own Git Bash bash tool). Use the bash tool for commands; the other ops_* tools (files, search, background jobs) are unaffected.'
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
/**
|
|
95
116
|
* The routing addendum appended to the `bash` tool's description through the
|
|
96
117
|
* system-prompt assemble waterfall. TWO variants (v0.37.0, user decision):
|
|
@@ -124,10 +145,16 @@ const ROUTING_SAFETY = 'Choose the execution route before running. A policy/acce
|
|
|
124
145
|
const PTC_ROUTING = 'Inside run_code, call only bindings actually offered by the current SDK with await tools.<name>(...). Select by their descriptions: shell execution for shell syntax/pipelines; an offered typed native-executable binding for exact Windows argv or explicitly known per-stream encodings; an offered complete-path conversion binding before passing an MSYS value to Node fs/path. These are different operations, not substitutes for permissions. Never pass routes, search text or source code to a path converter. Program strings do not perform shell expansion. If a needed binding is absent, report the missing capability; do not invent its name. ' + ROUTING_SAFETY
|
|
125
146
|
|
|
126
147
|
/** Model guidance names only tools in this assembly, not a switch's intent. */
|
|
127
|
-
export function toolRoutingHintFor(tools, { pwshVisible = false } = {}) {
|
|
148
|
+
export function toolRoutingHintFor(tools, { pwshVisible = false, terminalGitBash = false } = {}) {
|
|
128
149
|
const names = new Set((Array.isArray(tools) ? tools : []).map(tool => tool?.name))
|
|
129
150
|
if (!names.has('bash')) return ''
|
|
130
151
|
const lines = [pwshVisible && names.has('pwsh') ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH]
|
|
152
|
+
// v0.41.0: the persistent-terminal dialect fact. Said ONLY when the host
|
|
153
|
+
// actually carries the terminal tools AND our terminal-bundle adoption has
|
|
154
|
+
// settled the rows to Git Bash (the caller passes the settled flag) - a
|
|
155
|
+
// bundle left at its official defaults runs pwsh there, and a sentence
|
|
156
|
+
// must never describe a shell the button will not start.
|
|
157
|
+
if (terminalGitBash === true && names.has('terminal_open')) lines.push('The persistent terminal tools (terminal_open/read/send/...) run Git Bash (MSYS) on this host: the same drive-root paths (/c/...) and MINGW64 semantics as this bash tool; they are NOT PowerShell.')
|
|
131
158
|
if (names.has('gitbash_native')) lines.push('Use gitbash_native before execution when a Windows .exe needs exact literal argv such as /all or /c without MSYS conversion, or independently declared stdout/stderr encodings. Supply an absolute executable, mark each argument literal or path, and select only a known encoding (raw for unknown/mixed bytes). It does not implement shell pipelines, expansion or .cmd/.bat wrappers; only resolved danger-full-access can execute it.')
|
|
132
159
|
if (names.has('gitbash_to_native_path')) lines.push('Use gitbash_to_native_path only for a complete caller-declared MSYS filesystem path that a native Node fs/path API needs, especially a runtime-built value. Read native_path from the result. Never convert routes, search strings or shell/program source. Already-native or cwd-relative Node paths need no conversion; ordinary tool path fields already use their own translation. Conversion grants no access.')
|
|
133
160
|
lines.push(ROUTING_SAFETY)
|
|
@@ -135,7 +162,7 @@ export function toolRoutingHintFor(tools, { pwshVisible = false } = {}) {
|
|
|
135
162
|
}
|
|
136
163
|
|
|
137
164
|
/** Add a replaceable, idempotent block; a reused assembly follows visibility. */
|
|
138
|
-
export function applyToolRouting(tools, { pwshVisible = false, msys = false } = {}) {
|
|
165
|
+
export function applyToolRouting(tools, { pwshVisible = false, msys = false, terminalGitBash = false } = {}) {
|
|
139
166
|
if (!Array.isArray(tools)) return
|
|
140
167
|
for (const tool of tools) {
|
|
141
168
|
if (!tool || typeof tool.description !== 'string' || !['bash', 'run_code'].includes(tool.name)) continue
|
|
@@ -144,7 +171,7 @@ export function applyToolRouting(tools, { pwshVisible = false, msys = false } =
|
|
|
144
171
|
const end = start < 0 ? -1 : description.indexOf(ROUTING_END, start)
|
|
145
172
|
if (end >= 0) description = description.slice(0, start) + description.slice(end + ROUTING_END.length)
|
|
146
173
|
const hint = tool.name === 'bash'
|
|
147
|
-
? toolRoutingHintFor(tools, { pwshVisible }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '')
|
|
174
|
+
? toolRoutingHintFor(tools, { pwshVisible, terminalGitBash }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '')
|
|
148
175
|
: PTC_ROUTING
|
|
149
176
|
tool.description = description + ROUTING_START + hint + ROUTING_END
|
|
150
177
|
}
|
|
@@ -234,7 +261,7 @@ export function mountInlineTools(ctx, deps) {
|
|
|
234
261
|
const controller = new AbortController()
|
|
235
262
|
let closed = false
|
|
236
263
|
const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted
|
|
237
|
-
const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() })
|
|
264
|
+
const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, denyOpsShell: undefined, pending: undefined, controller: new AbortController() })
|
|
238
265
|
const experimentalEnabled = () => deps.experimentalEnabled?.() !== false
|
|
239
266
|
// Loader imports cannot be cancelled, but no continuation may outlive its
|
|
240
267
|
// owner. Join our cancelled continuations without waiting on a stuck loader.
|
|
@@ -384,6 +411,26 @@ export function mountInlineTools(ctx, deps) {
|
|
|
384
411
|
log(`${extra.name} activation failed; bash remains available: ${error?.message ?? error}`)
|
|
385
412
|
}
|
|
386
413
|
}
|
|
414
|
+
// v0.41.0 (owner decision): while OUR bash owns the session, the ops
|
|
415
|
+
// shell pair is denied by name - installing dsh-ops buys its file,
|
|
416
|
+
// search and background enhancements, not a second bash. Through the
|
|
417
|
+
// AGENT's scoped context, the exact shape the pwsh hide uses (issue
|
|
418
|
+
// #18: the root context throws "requires a scoped context"). Only the
|
|
419
|
+
// names actually in the face are listed, so a future dsh-ops rename
|
|
420
|
+
// degrades to "no denial", never to a wrong deny list. On failure the
|
|
421
|
+
// ops tools stay - our bash remains the routing answer either way.
|
|
422
|
+
const opsShell = faceNames(agent).filter((name) => OPS_SHELL_TOOLS.includes(name))
|
|
423
|
+
if (opsShell.length > 0) {
|
|
424
|
+
try {
|
|
425
|
+
const deny = agent.ctx?.tools?.restrict?.({ deny: [...opsShell] })
|
|
426
|
+
if (typeof deny === 'function') {
|
|
427
|
+
state.denyOpsShell = deny
|
|
428
|
+
log('ops shell tools denied for this agent (' + opsShell.join(', ') + '): this host\'s bash is ours; their file/search/background tools stay')
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
log('ops shell denial failed (their shell tools stay available): ' + (error?.message ?? error))
|
|
432
|
+
}
|
|
433
|
+
}
|
|
387
434
|
retained = true
|
|
388
435
|
log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
|
|
389
436
|
} catch (error) {
|
|
@@ -414,6 +461,7 @@ export function mountInlineTools(ctx, deps) {
|
|
|
414
461
|
const previous = tracked.get(agent)
|
|
415
462
|
if (previous?.timer) { clearTimeout(previous.timer); previous.timer = undefined }
|
|
416
463
|
if (previous?.denyPwsh) { previous.denyPwsh(); previous.denyPwsh = undefined }
|
|
464
|
+
if (previous?.denyOpsShell) { previous.denyOpsShell(); previous.denyOpsShell = undefined }
|
|
417
465
|
const names = faceNames(agent)
|
|
418
466
|
// The composed face may not be wired yet at created-time. Retry bounded:
|
|
419
467
|
// a face with NEITHER tool carries no verdict. Five tries at 250ms, then
|
|
@@ -457,6 +505,26 @@ export function mountInlineTools(ctx, deps) {
|
|
|
457
505
|
if (needs.needsBash && !state.scope) {
|
|
458
506
|
startBash(agent, state)
|
|
459
507
|
}
|
|
508
|
+
// v0.41.0: keep the ops shell denial attached while our bash owns the
|
|
509
|
+
// agent. The mask was undone above for observation; an agent that is
|
|
510
|
+
// already owned (state.scope, including one still mounting - provideBash
|
|
511
|
+
// claims the scope before its asynchronous stages) gets the denial
|
|
512
|
+
// re-attached here, so a reconcile fired by a switch flip or a re-show
|
|
513
|
+
// never leaves the pair visible.
|
|
514
|
+
if (state.scope && !state.denyOpsShell) {
|
|
515
|
+
const opsShell = names.filter((name) => OPS_SHELL_TOOLS.includes(name))
|
|
516
|
+
if (opsShell.length > 0) {
|
|
517
|
+
try {
|
|
518
|
+
const deny = agent.ctx?.tools?.restrict?.({ deny: [...opsShell] })
|
|
519
|
+
if (typeof deny === 'function') {
|
|
520
|
+
state.denyOpsShell = deny
|
|
521
|
+
log('ops shell tools denied for this agent (' + opsShell.join(', ') + ')')
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
log('ops shell denial failed (their shell tools stay available): ' + (error?.message ?? error))
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
460
528
|
}
|
|
461
529
|
|
|
462
530
|
const teardown = async (agent) => {
|
|
@@ -466,6 +534,7 @@ export function mountInlineTools(ctx, deps) {
|
|
|
466
534
|
state.controller.abort(new Error('inline bash agent disposed'))
|
|
467
535
|
if (state.timer) clearTimeout(state.timer)
|
|
468
536
|
if (state.denyPwsh) { state.denyPwsh(); state.denyPwsh = undefined }
|
|
537
|
+
if (state.denyOpsShell) { state.denyOpsShell(); state.denyOpsShell = undefined }
|
|
469
538
|
await Promise.all([state.scope?.dispose(), state.pending])
|
|
470
539
|
}
|
|
471
540
|
|
|
@@ -510,6 +579,13 @@ export function mountInlineTools(ctx, deps) {
|
|
|
510
579
|
if (exec?.name === 'pwsh' && !opsSeen && deps.pwshEnabled?.() !== true) {
|
|
511
580
|
return { kind: 'deny', reason: hiddenPwshDenyReason() }
|
|
512
581
|
}
|
|
582
|
+
// v0.41.0, second independent layer for the ops shell pair: while our
|
|
583
|
+
// bash owns this agent, a call that slips past the visibility
|
|
584
|
+
// restriction is denied by name - same reasoning as the pwsh guard
|
|
585
|
+
// above.
|
|
586
|
+
if (OPS_SHELL_TOOLS.includes(exec?.name) && tracked.get(exec?.agent)?.scope) {
|
|
587
|
+
return { kind: 'deny', reason: deniedOpsShellReason() }
|
|
588
|
+
}
|
|
513
589
|
} catch { /* never block the waterfall */ }
|
|
514
590
|
return next()
|
|
515
591
|
})
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbash-shell — OFFICIAL persistent-terminal bundle adoption (v0.41.0).
|
|
3
|
+
*
|
|
4
|
+
* The experimental terminal bundle (@deepseek-ai/dsh-experimental-terminal-bundle)
|
|
5
|
+
* ships its shell backends behind a bundle patch whose own win32 gating reads:
|
|
6
|
+
* the bash backend row is DISABLED on win32 and the pwsh backend row is enabled
|
|
7
|
+
* — its README states this verbatim ("POSIX 使用 Bash,Windows 使用 PowerShell").
|
|
8
|
+
* On a Git Bash house that is exactly backwards: the official terminal_* tools
|
|
9
|
+
* would open PowerShell while every other shell surface on this host runs Git
|
|
10
|
+
* Bash (session tool, sidebar terminal, PTC translation).
|
|
11
|
+
*
|
|
12
|
+
* Fixing it needs a PROFILE-PATCH edit, not the settings service:
|
|
13
|
+
* - the bundle rows are group CHILDREN, and the host patch algorithm addresses
|
|
14
|
+
* children by id (buildMap recurses into group config arrays), so a profile
|
|
15
|
+
* patch row `- id: optional-terminal-bash {disabled: false, config:{...}}`
|
|
16
|
+
* overrides the bundle row (bundle patches are read BEFORE the profile patch
|
|
17
|
+
* file — packages/boot/app-boot/src/profile-context.ts:57);
|
|
18
|
+
* - configEditor CANNOT do this (measured, packages/boot/config-editor):
|
|
19
|
+
* entries() only sees include-mounted TOP-LEVEL rows, and edit() only
|
|
20
|
+
* writes the config key, never a row-level `disabled`.
|
|
21
|
+
*
|
|
22
|
+
* So this module edits the profile patch file directly, with the same care the
|
|
23
|
+
* official editor takes: `yaml`'s parseDocument (comments and !!js expressions
|
|
24
|
+
* survive untouched), targeted setIn on the two rows, a re-parse self-check,
|
|
25
|
+
* an atomic write, a verify-by-reread and a rollback to the previous bytes on
|
|
26
|
+
* any mismatch. HARD RULES (inherited from terminal-shell.js): never overwrite
|
|
27
|
+
* an explicit user choice, only ever write a VERIFIED Git Bash path, and
|
|
28
|
+
* re-read after writing.
|
|
29
|
+
*
|
|
30
|
+
* The bundle not being mounted is the normal state for most hosts: nothing is
|
|
31
|
+
* written then, and the session-tool inline patch keeps owning the shell story.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-gitbash-shell/terminal-bundle
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { normalizeBashPath } from './bash-path.js'
|
|
37
|
+
|
|
38
|
+
/** Bundle row that mounts the terminal_* tool suite — its presence (enabled) IS the bundle. */
|
|
39
|
+
export const TERMINAL_BUNDLE_TOOL_ROW = 'optional-tool-terminal'
|
|
40
|
+
/** Bundle row for the persistent bash backend (disabled on win32 by the bundle itself). */
|
|
41
|
+
export const TERMINAL_BUNDLE_BASH_ROW = 'optional-terminal-bash'
|
|
42
|
+
/** Bundle row for the persistent pwsh backend (enabled on win32 by the bundle itself). */
|
|
43
|
+
export const TERMINAL_BUNDLE_PWSH_ROW = 'optional-terminal-pwsh'
|
|
44
|
+
|
|
45
|
+
/** Live dialect fact for the prompt-assembly waterfall (read by index.js).
|
|
46
|
+
* True only when the persistent terminal provably runs this host's Git Bash. */
|
|
47
|
+
export const terminalDialect = { gitBash: false }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Is the official terminal bundle mounted with its tool row active? The tool
|
|
51
|
+
* row's presence is what turns terminal_open on — the shell backends without
|
|
52
|
+
* it are inert. Reads through the loader service the same way the ops
|
|
53
|
+
* presence probe does (an async fiber, so this awaits).
|
|
54
|
+
* @param {object} ctx - host context carrying `inject`.
|
|
55
|
+
* @returns {Promise<boolean>}
|
|
56
|
+
*/
|
|
57
|
+
export async function detectTerminalBundle(ctx) {
|
|
58
|
+
let present = false
|
|
59
|
+
await ctx.inject(['loader'], (loaderCtx) => {
|
|
60
|
+
const entries = loaderCtx?.get?.('loader')?.entries?.()
|
|
61
|
+
if (typeof entries?.[Symbol.iterator] !== 'function') return
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry?.options?.id !== TERMINAL_BUNDLE_TOOL_ROW) continue
|
|
64
|
+
if (entry.options.disabled === true) continue
|
|
65
|
+
present = true
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
return present
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the plugin config entry for one patch row (the fields this module owns).
|
|
74
|
+
* Takes the RAW parsed patch entry (`config` wrapper included) and returns the
|
|
75
|
+
* FLATTENED facts; undefined when the input is not a row-shaped object.
|
|
76
|
+
* @param {unknown} row - a parsed patch entry.
|
|
77
|
+
* @returns {{disabled?: boolean, shellPath?: string}|undefined}
|
|
78
|
+
*/
|
|
79
|
+
export function readPatchRow(row) {
|
|
80
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return undefined
|
|
81
|
+
const out = {}
|
|
82
|
+
if (row.disabled === true) out.disabled = true
|
|
83
|
+
else if (row.disabled === false) out.disabled = false
|
|
84
|
+
const config = row.config
|
|
85
|
+
if (config && typeof config === 'object' && !Array.isArray(config)) {
|
|
86
|
+
if (typeof config.shellPath === 'string') out.shellPath = config.shellPath
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pure decision: should (and how exactly) the profile patch adopt the bundle's
|
|
93
|
+
* shell rows for this host? Mirrors terminal-shell's planTerminalAdopt shape —
|
|
94
|
+
* one action + one human reason, every state enumerable and testable.
|
|
95
|
+
* @param {{platform?: string, enabled?: boolean, mounted?: boolean, resolved?: string, rows?: {bash?: {disabled?: boolean, shellPath?: string}, pwsh?: {disabled?: boolean}}}} input - rows are the FLATTENED row facts (readPatchRow/currentBundleRows output).
|
|
96
|
+
* @returns {{action: string, reason: string, bash?: {disabled: boolean, shellPath?: string}, pwsh?: {disabled: boolean}}}
|
|
97
|
+
*/
|
|
98
|
+
export function planTerminalBundleAdopt(input) {
|
|
99
|
+
const { platform, enabled, mounted, resolved, rows } = input ?? {}
|
|
100
|
+
if (platform !== 'win32') {
|
|
101
|
+
return { action: 'skip-platform', reason: 'the persistent terminal bundle keeps its own POSIX bash backend; nothing to adopt off-Windows' }
|
|
102
|
+
}
|
|
103
|
+
if (mounted !== true) {
|
|
104
|
+
return { action: 'bundle-absent', reason: 'the persistent terminal bundle is not mounted (or its tool row is disabled) — nothing to adopt' }
|
|
105
|
+
}
|
|
106
|
+
if (enabled !== true) {
|
|
107
|
+
return { action: 'skip-disabled', reason: 'autoTerminalShell is off — the bundle keeps its official shell backends' }
|
|
108
|
+
}
|
|
109
|
+
if (typeof resolved !== 'string' || resolved.trim() === '') {
|
|
110
|
+
return { action: 'skip-unresolved', reason: 'no verified Git Bash executable on this host — the bundle keeps its official backends' }
|
|
111
|
+
}
|
|
112
|
+
const shellPath = resolved.trim()
|
|
113
|
+
// `rows` carries the FLATTENED facts (readPatchRow/currentBundleRows output):
|
|
114
|
+
// { disabled?, shellPath? } — not the raw patch row with its config wrapper.
|
|
115
|
+
const bash = rows?.bash
|
|
116
|
+
const pwsh = rows?.pwsh
|
|
117
|
+
if (bash === undefined) {
|
|
118
|
+
// No user/bundle override row yet: write ours, and disable the pwsh backend
|
|
119
|
+
// ONLY when no row of its own exists (a user-written row is never touched).
|
|
120
|
+
const pwshWrite = pwsh === undefined ? { disabled: true } : undefined
|
|
121
|
+
return {
|
|
122
|
+
action: 'adopt',
|
|
123
|
+
reason: pwshWrite
|
|
124
|
+
? 'writing the persistent-terminal overrides: bash backend on the verified Git Bash, pwsh backend off'
|
|
125
|
+
: 'writing the persistent-terminal bash-backend override on the verified Git Bash; a pwsh-backend row already exists and is left alone',
|
|
126
|
+
bash: { disabled: false, shellPath },
|
|
127
|
+
...(pwshWrite ? { pwsh: pwshWrite } : {}),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (bash.disabled === true) {
|
|
131
|
+
return { action: 'kept-user-choice', reason: 'the profile patch explicitly disables the persistent bash backend — not touching an explicit user choice' }
|
|
132
|
+
}
|
|
133
|
+
const configured = typeof bash.shellPath === 'string' ? bash.shellPath.trim() : ''
|
|
134
|
+
if (configured === '') {
|
|
135
|
+
// Row exists, backend on, but shellPath left at the bundle's POSIX default
|
|
136
|
+
// (/bin/bash) — that cannot work on Windows. Completing it is not a user
|
|
137
|
+
// override: the field is EMPTY.
|
|
138
|
+
return {
|
|
139
|
+
action: 'update-shellpath',
|
|
140
|
+
reason: 'the bash backend row is enabled but shellPath is unset (the bundle default /bin/bash cannot work on Windows); completing it with the verified Git Bash',
|
|
141
|
+
bash: { disabled: false, shellPath },
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (normalizeBashPath(configured) !== normalizeBashPath(shellPath)) {
|
|
145
|
+
return { action: 'kept-user-choice', reason: `the persistent bash backend already points at "${configured}" — not overwriting an explicit user choice` }
|
|
146
|
+
}
|
|
147
|
+
if (pwsh !== undefined && pwsh.disabled !== true) {
|
|
148
|
+
return { action: 'unchanged', reason: 'the persistent terminal already runs the verified Git Bash; the pwsh backend row is also enabled (a user choice we do not touch)' }
|
|
149
|
+
}
|
|
150
|
+
return { action: 'unchanged', reason: 'the persistent terminal already runs the verified Git Bash (bash row on it, pwsh backend off)' }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extract the current override rows for our two ids from a parsed patch document.
|
|
155
|
+
* @param {object} yaml - the eemeli yaml module (isMap/getIn surface).
|
|
156
|
+
* @param {object} document - a yaml parseDocument result.
|
|
157
|
+
* @returns {{bash?: object, pwsh?: object}}
|
|
158
|
+
*/
|
|
159
|
+
export function currentBundleRows(yaml, document) {
|
|
160
|
+
const out = {}
|
|
161
|
+
const items = document?.contents?.items
|
|
162
|
+
if (!Array.isArray(items)) return out
|
|
163
|
+
const read = (id) => {
|
|
164
|
+
const index = findRow(yaml, document, id)
|
|
165
|
+
if (index < 0) return undefined
|
|
166
|
+
const item = items[index]
|
|
167
|
+
const row = {}
|
|
168
|
+
const disabled = item.getIn?.(['disabled'])
|
|
169
|
+
if (disabled === true || disabled === false) row.disabled = disabled
|
|
170
|
+
const shellPath = item.getIn?.(['config', 'shellPath'])
|
|
171
|
+
if (typeof shellPath === 'string') row.shellPath = shellPath
|
|
172
|
+
return row
|
|
173
|
+
}
|
|
174
|
+
const bash = read(TERMINAL_BUNDLE_BASH_ROW)
|
|
175
|
+
if (bash !== undefined) out.bash = bash
|
|
176
|
+
const pwsh = read(TERMINAL_BUNDLE_PWSH_ROW)
|
|
177
|
+
if (pwsh !== undefined) out.pwsh = pwsh
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Last matching row index for an id (later rows win, mirroring the host patch
|
|
183
|
+
* algorithm); skips insert rows, same guards as config-editor's lookup.
|
|
184
|
+
* @returns {number} index or -1.
|
|
185
|
+
*/
|
|
186
|
+
export function findRow(yaml, document, id) {
|
|
187
|
+
const items = document?.contents?.items
|
|
188
|
+
if (!Array.isArray(items)) return -1
|
|
189
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
190
|
+
const item = items[i]
|
|
191
|
+
if (!yaml.isMap?.(item)) continue
|
|
192
|
+
if (item.getIn?.(['insert']) !== undefined) continue
|
|
193
|
+
if (item.getIn?.(['id']) !== id) continue
|
|
194
|
+
return i
|
|
195
|
+
}
|
|
196
|
+
return -1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Rewrite the profile patch file so the official persistent terminal runs the
|
|
201
|
+
* verified Git Bash. Reads, plans, writes ONLY on adopt/update-shellpath,
|
|
202
|
+
* re-parses its own output as a self-check, writes atomically, verifies by
|
|
203
|
+
* re-reading and rolls back to the previous bytes on any mismatch.
|
|
204
|
+
* @param {{readFile: Function, writeFileAtomic: Function, yaml: Function}} io - injected IO (fs + yaml loader).
|
|
205
|
+
* @param {{platform?: string, enabled?: boolean, mounted?: boolean, resolved?: string, patchPath: string}} input
|
|
206
|
+
* @returns {Promise<{action: string, status: string, reason?: string, shellPath?: string, pwshRowWritten?: boolean}>}
|
|
207
|
+
*/
|
|
208
|
+
export async function adoptTerminalBundlePatch(io, input) {
|
|
209
|
+
const done = (action, status, reason, extra = {}) => ({ action, status, reason, ...extra })
|
|
210
|
+
const { patchPath } = input
|
|
211
|
+
let yamlModule
|
|
212
|
+
try {
|
|
213
|
+
yamlModule = await io.yaml()
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return done('skip-modules', 'skip-modules', undefined, { detail: `the yaml document module is unavailable (${error?.message ?? error})` })
|
|
216
|
+
}
|
|
217
|
+
if (typeof yamlModule?.parseDocument !== 'function' || typeof yamlModule?.isSeq !== 'function' || typeof yamlModule?.isMap !== 'function') {
|
|
218
|
+
return done('skip-modules', 'skip-modules', undefined, { detail: 'the yaml document module lacks parseDocument/isSeq/isMap' })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let before
|
|
222
|
+
try {
|
|
223
|
+
before = await io.readFile(patchPath)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error?.code !== 'ENOENT') return done('skip-read', 'skip-read', undefined, { detail: `the profile patch could not be read (${error?.message ?? error})` })
|
|
226
|
+
before = '[]\n'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const parse = (text) => yamlModule.parseDocument(text, {
|
|
230
|
+
customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value) => value }],
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const document = parse(before)
|
|
234
|
+
if (Array.isArray(document?.errors) && document.errors.length > 0) {
|
|
235
|
+
return done('skip-parse', 'skip-parse', undefined, { detail: 'the profile patch has YAML errors; refusing to edit a broken document' })
|
|
236
|
+
}
|
|
237
|
+
if (!yamlModule.isSeq(document?.contents)) {
|
|
238
|
+
return done('skip-shape', 'skip-shape', undefined, { detail: 'the profile patch is not a YAML sequence; refusing to edit an unknown shape' })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The plan runs AFTER the current rows are known: it decides between
|
|
242
|
+
// writing and standing down based on what the profile patch already says.
|
|
243
|
+
const rows = currentBundleRows(yamlModule, document)
|
|
244
|
+
const plan = planTerminalBundleAdopt({ ...input, rows })
|
|
245
|
+
if (plan.action !== 'adopt' && plan.action !== 'update-shellpath') {
|
|
246
|
+
return done(plan.action, plan.action, plan.reason)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Explicit QUOTE_DOUBLE scalars: the patch stays human-readable and the
|
|
250
|
+
// path keeps its quotes (the plain style a bare createNode produces is
|
|
251
|
+
// valid but nothing like what a hand-written row looks like).
|
|
252
|
+
const quotedScalar = (value) => {
|
|
253
|
+
const node = new yamlModule.Scalar(value)
|
|
254
|
+
node.type = 'QUOTE_DOUBLE'
|
|
255
|
+
return node
|
|
256
|
+
}
|
|
257
|
+
const newMap = (...pairs) => {
|
|
258
|
+
const map = document.createNode({})
|
|
259
|
+
for (const [key, value] of pairs) map.set(key, value)
|
|
260
|
+
return map
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// An empty patch file parses as a FLOW sequence ([]); once rows are added
|
|
264
|
+
// it must print in block style or every new row comes out one-line flow.
|
|
265
|
+
document.contents.flow = false
|
|
266
|
+
|
|
267
|
+
// A brand-new row is added by parsing a handwritten-looking FRAGMENT and
|
|
268
|
+
// pushing its node: what lands on disk then reads exactly like the row a
|
|
269
|
+
// careful human would write (block style, quoted path), instead of the
|
|
270
|
+
// flow-style shape a bare createNode serializes to.
|
|
271
|
+
const rowFromFragment = (text) => {
|
|
272
|
+
const fragment = parse(text)
|
|
273
|
+
if (Array.isArray(fragment?.errors) && fragment.errors.length > 0) return undefined
|
|
274
|
+
return fragment?.contents?.items?.[0]
|
|
275
|
+
}
|
|
276
|
+
const bashIndex = findRow(yamlModule, document, TERMINAL_BUNDLE_BASH_ROW)
|
|
277
|
+
const bashNode = plan.bash
|
|
278
|
+
if (bashIndex >= 0) {
|
|
279
|
+
document.setIn([bashIndex, 'disabled'], bashNode.disabled)
|
|
280
|
+
document.setIn([bashIndex, 'config'], newMap(['shellPath', quotedScalar(bashNode.shellPath)]))
|
|
281
|
+
} else {
|
|
282
|
+
const row = rowFromFragment(`- id: ${TERMINAL_BUNDLE_BASH_ROW}\n disabled: ${bashNode.disabled}\n config:\n shellPath: ${JSON.stringify(bashNode.shellPath)}\n`)
|
|
283
|
+
if (row === undefined) {
|
|
284
|
+
return done(plan.action, 'write-failed', plan.reason, { detail: 'the new bash row fragment did not parse; nothing was written' })
|
|
285
|
+
}
|
|
286
|
+
document.add(row)
|
|
287
|
+
}
|
|
288
|
+
let pwshRowWritten = false
|
|
289
|
+
if (plan.pwsh && findRow(yamlModule, document, TERMINAL_BUNDLE_PWSH_ROW) < 0) {
|
|
290
|
+
const row = rowFromFragment(`- id: ${TERMINAL_BUNDLE_PWSH_ROW}\n disabled: ${plan.pwsh.disabled}\n`)
|
|
291
|
+
if (row === undefined) {
|
|
292
|
+
return done(plan.action, 'write-failed', plan.reason, { detail: 'the new pwsh row fragment did not parse; nothing was written' })
|
|
293
|
+
}
|
|
294
|
+
document.add(row)
|
|
295
|
+
pwshRowWritten = true
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Self-check before touching disk: the serialized document must re-parse clean.
|
|
299
|
+
const serialized = String(document)
|
|
300
|
+
const check = parse(serialized)
|
|
301
|
+
if (Array.isArray(check?.errors) && check.errors.length > 0) {
|
|
302
|
+
return done(plan.action, 'write-failed', plan.reason, { detail: 'the edited patch did not re-parse cleanly; nothing was written' })
|
|
303
|
+
}
|
|
304
|
+
if (findRow(yamlModule, check, TERMINAL_BUNDLE_BASH_ROW) < 0) {
|
|
305
|
+
return done(plan.action, 'write-failed', plan.reason, { detail: 'the edited patch lost the bash row during serialization; nothing was written' })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await io.writeFileAtomic(patchPath, serialized)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return done(plan.action, 'write-failed', plan.reason, { detail: `the atomic write failed (${error?.message ?? error}); the previous patch bytes stand` })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Verify by re-reading what actually landed on disk.
|
|
315
|
+
try {
|
|
316
|
+
const reread = parse(await io.readFile(patchPath))
|
|
317
|
+
const rows = currentBundleRows(yamlModule, reread)
|
|
318
|
+
const bash = rows.bash
|
|
319
|
+
const pathOk = bash !== undefined
|
|
320
|
+
&& bash.disabled === false
|
|
321
|
+
&& typeof bash.shellPath === 'string'
|
|
322
|
+
&& normalizeBashPath(bash.shellPath) === normalizeBashPath(bashNode.shellPath)
|
|
323
|
+
const pwshOk = !pwshRowWritten || readPatchRow(rows.pwsh)?.disabled === true
|
|
324
|
+
if (!pathOk || !pwshOk) {
|
|
325
|
+
await io.writeFileAtomic(patchPath, before)
|
|
326
|
+
return done(plan.action, 'write-failed', plan.reason, { detail: 'the written patch did not read back as intended; rolled back to the previous bytes' })
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
try { await io.writeFileAtomic(patchPath, before) } catch { /* report the primary failure */ }
|
|
330
|
+
return done(plan.action, 'write-failed', plan.reason, { detail: `the verification read failed (${error?.message ?? error}); rolled back to the previous bytes` })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return done(plan.action, plan.action === 'adopt' ? 'adopted' : 'updated', plan.reason, { shellPath: bashNode.shellPath, pwshRowWritten })
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* One line per outcome, terminal-shell style.
|
|
338
|
+
* @param {{action: string, status: string, reason?: string, detail?: string, shellPath?: string}} result
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
export function terminalBundleReport(result) {
|
|
342
|
+
if (!result || typeof result !== 'object') return 'persistent terminal adoption returned nothing'
|
|
343
|
+
if (typeof result.detail === 'string' && result.detail !== '') return `persistent terminal: ${result.status} — ${result.detail}`
|
|
344
|
+
if (typeof result.reason === 'string' && result.reason !== '') return `persistent terminal: ${result.reason}`
|
|
345
|
+
return `persistent terminal: ${result.status}`
|
|
346
|
+
}
|