dsh-gitbash-shell 0.4.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/LICENSE +21 -0
- package/README.md +133 -0
- package/README_EN.md +58 -0
- package/assets/code-gitbash/agent.cordis.yml +264 -0
- package/assets/code-gitbash/preset.yml +3 -0
- package/assets/cordis-gitbash/agent.cordis.yml +264 -0
- package/assets/cordis-gitbash/preset.yml +3 -0
- package/assets/minimal-gitbash/agent.cordis.yml +89 -0
- package/assets/minimal-gitbash/preset.yml +3 -0
- package/assets/standard-gitbash/agent.cordis.yml +253 -0
- package/assets/standard-gitbash/preset.yml +3 -0
- package/cordis.patch.yml +39 -0
- package/dsh.plugin.json +13 -0
- package/package.json +54 -0
- package/src/index.js +367 -0
- package/src/shell.js +96 -0
package/dsh.plugin.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "dsh-external/dsh-gitbash-shell",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"main": "./src/index.js",
|
|
5
|
+
"description": "Windows 全模式 Git Bash:用 Git for Windows bash 替换 PowerShell 执行器,并物化 standard/minimal/code/cordis 的 Git Bash 变体 preset。",
|
|
6
|
+
"engines": {
|
|
7
|
+
"dsh": ">=0.0.1"
|
|
8
|
+
},
|
|
9
|
+
"contributes": {
|
|
10
|
+
"tools": [],
|
|
11
|
+
"skills": []
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-gitbash-shell",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "DSH plugin: run every agent shell command through Git for Windows bash on Windows instead of PowerShell. Replaces the pwsh executor with a Git Bash ctx.shell provider and materializes Git Bash variants of the standard/minimal/code/cordis agent presets into your user preset root at startup.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/KannaKuron/dsh-gitbash-shell.git"
|
|
9
|
+
},
|
|
10
|
+
"main": "src/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./shell": "./src/shell.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"dsh": {
|
|
19
|
+
"bundle": {
|
|
20
|
+
"patch": "./cordis.patch.yml"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"assets",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"dsh.plugin.json",
|
|
28
|
+
"README.md",
|
|
29
|
+
"README_EN.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test tests/smoke.mjs"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"dsh",
|
|
37
|
+
"deepseek-harness",
|
|
38
|
+
"git-bash",
|
|
39
|
+
"windows",
|
|
40
|
+
"shell",
|
|
41
|
+
"dsh-plugin"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/KannaKuron/dsh-gitbash-shell",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/KannaKuron/dsh-gitbash-shell/issues"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbash-shell — host half (preset materialization).
|
|
3
|
+
*
|
|
4
|
+
* Installed as a profile bundle, this plugin swaps Windows' PowerShell shell
|
|
5
|
+
* for Git for Windows bash (the executor lives in ./shell.js). On startup it
|
|
6
|
+
* also materializes four Git Bash agent presets into the FIRST user-trust
|
|
7
|
+
* preset root, mirroring how dsh-ptc-cordis-preset ships its 'ptc-cordis'
|
|
8
|
+
* preset:
|
|
9
|
+
*
|
|
10
|
+
* standard-gitbash — 标准模式 · Git Bash (tool-bash on, tool-pwsh off)
|
|
11
|
+
* minimal-gitbash — 极简模式 · Git Bash (persistent Git Bash terminal)
|
|
12
|
+
* code-gitbash — PTC 模式 · Git Bash (Code Mode presentation)
|
|
13
|
+
* cordis-gitbash — 创造模式 · Git Bash (self-modifying toolset; skills
|
|
14
|
+
* copied from the INSTALLED shipped 'cordis' preset so
|
|
15
|
+
* the guidance tracks the deployment)
|
|
16
|
+
*
|
|
17
|
+
* Each preset directory carries a .plugin-managed.json marker recording the
|
|
18
|
+
* hash of every file this plugin wrote. Ownership rules (per preset):
|
|
19
|
+
* - absent → materialize;
|
|
20
|
+
* - foreign → written by someone else: never touch;
|
|
21
|
+
* - user-modified → ours but edited: never touch again (delete the
|
|
22
|
+
* directory to re-materialize);
|
|
23
|
+
* - unmodified → refreshed only when the plugin version changed (or,
|
|
24
|
+
* for cordis-gitbash, when the live skills source
|
|
25
|
+
* drifted); otherwise idle.
|
|
26
|
+
*
|
|
27
|
+
* Uninstall hygiene mirrors dsh-ptc-cordis-preset: on disposal, a package
|
|
28
|
+
* directory that still exists means reload/update/restart — keep the
|
|
29
|
+
* presets; a vanished package.json means uninstall — remove each preset the
|
|
30
|
+
* user never modified.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createHash } from 'node:crypto'
|
|
34
|
+
import {
|
|
35
|
+
cpSync,
|
|
36
|
+
existsSync,
|
|
37
|
+
mkdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
readdirSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from 'node:fs'
|
|
43
|
+
import { dirname, join } from 'node:path'
|
|
44
|
+
import { fileURLToPath } from 'node:url'
|
|
45
|
+
|
|
46
|
+
/** Plugin identity for cordis.yml rows. */
|
|
47
|
+
export const name = 'dsh-gitbash-shell'
|
|
48
|
+
|
|
49
|
+
/** The preset roster is a hard dependency: without it there is nothing to do. */
|
|
50
|
+
export const inject = ['agentPresets']
|
|
51
|
+
|
|
52
|
+
const TAG = '[gitbash-shell]'
|
|
53
|
+
const MANAGED_BY = 'dsh-gitbash-shell'
|
|
54
|
+
const MARKER_FILE = '.plugin-managed.json'
|
|
55
|
+
|
|
56
|
+
/** Default preset ids this plugin materializes, in roster order (configurable). */
|
|
57
|
+
export const PRESET_IDS = ['standard-gitbash', 'minimal-gitbash', 'code-gitbash', 'cordis-gitbash']
|
|
58
|
+
|
|
59
|
+
/** Git Bash binary default — must match src/shell.js. */
|
|
60
|
+
const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
|
|
61
|
+
|
|
62
|
+
/** The shipped preset whose skills/ dir seeds cordis-gitbash. */
|
|
63
|
+
const SKILLS_SOURCE_PRESET = 'cordis'
|
|
64
|
+
|
|
65
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
66
|
+
const pkgDir = join(here, '..')
|
|
67
|
+
|
|
68
|
+
// ── tree hashing ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** Every file under `root`, as sorted relative POSIX-style paths. */
|
|
71
|
+
function walkFiles(root, rel = '') {
|
|
72
|
+
const out = []
|
|
73
|
+
let entries
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(join(root, rel), { withFileTypes: true })
|
|
76
|
+
} catch {
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
const r = rel ? `${rel}/${e.name}` : e.name
|
|
81
|
+
if (e.isDirectory()) out.push(...walkFiles(root, r))
|
|
82
|
+
else if (e.isFile()) out.push(r)
|
|
83
|
+
}
|
|
84
|
+
return out.sort()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Map of relative path → sha256 for every file under `root`. */
|
|
88
|
+
function hashTree(root) {
|
|
89
|
+
const files = {}
|
|
90
|
+
for (const rel of walkFiles(root)) {
|
|
91
|
+
if (rel === MARKER_FILE) continue
|
|
92
|
+
files[rel] = createHash('sha256').update(readFileSync(join(root, rel))).digest('hex')
|
|
93
|
+
}
|
|
94
|
+
return files
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The parsed marker, or null when the tree is not ours. */
|
|
98
|
+
function readMarker(target) {
|
|
99
|
+
try {
|
|
100
|
+
const m = JSON.parse(readFileSync(join(target, MARKER_FILE), 'utf8'))
|
|
101
|
+
if (m && m.managedBy === MANAGED_BY && m.files && typeof m.files === 'object') return m
|
|
102
|
+
} catch {
|
|
103
|
+
/* absent or unreadable → not ours */
|
|
104
|
+
}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Classify a preset directory: absent | foreign | user-modified | unmodified. */
|
|
109
|
+
function classify(target) {
|
|
110
|
+
if (!existsSync(target)) return 'absent'
|
|
111
|
+
const marker = readMarker(target)
|
|
112
|
+
if (!marker) return 'foreign'
|
|
113
|
+
const current = hashTree(target)
|
|
114
|
+
const recorded = marker.files
|
|
115
|
+
const keys = Object.keys(recorded)
|
|
116
|
+
if (keys.length !== Object.keys(current).length) return 'user-modified'
|
|
117
|
+
for (const k of keys) if (current[k] !== recorded[k]) return 'user-modified'
|
|
118
|
+
return 'unmodified'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 'skills/<rel>' → sha256 map of a skills source tree, or null when absent. */
|
|
122
|
+
function skillsHashes(source) {
|
|
123
|
+
if (!source || !existsSync(source)) return null
|
|
124
|
+
const out = {}
|
|
125
|
+
for (const rel of walkFiles(source)) {
|
|
126
|
+
out[`skills/${rel}`] = createHash('sha256').update(readFileSync(join(source, rel))).digest('hex')
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Startup decision for an existing unmodified tree: 'refresh' or 'idle'. */
|
|
132
|
+
function syncDecision({ state, marker, version, sourceHashes }) {
|
|
133
|
+
if (state !== 'unmodified' || !marker) return 'refresh'
|
|
134
|
+
if (marker.version !== version) return 'refresh'
|
|
135
|
+
const recorded = {}
|
|
136
|
+
for (const k of Object.keys(marker.files)) if (k.startsWith('skills/')) recorded[k] = marker.files[k]
|
|
137
|
+
if (sourceHashes === null) return Object.keys(recorded).length === 0 ? 'idle' : 'refresh'
|
|
138
|
+
const live = Object.keys(sourceHashes)
|
|
139
|
+
const seen = Object.keys(recorded)
|
|
140
|
+
if (live.length !== seen.length) return 'refresh'
|
|
141
|
+
for (const k of live) if (recorded[k] !== sourceHashes[k]) return 'refresh'
|
|
142
|
+
return 'idle'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── materialization ─────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/** Write one preset directory from scratch. Returns 'ok' or 'no-skills-source'. */
|
|
148
|
+
function materialize({ target, presetId, skillsSource, version }) {
|
|
149
|
+
rmSync(target, { recursive: true, force: true })
|
|
150
|
+
mkdirSync(target, { recursive: true })
|
|
151
|
+
|
|
152
|
+
writeFileSync(join(target, 'agent.cordis.yml'), readFileSync(join(pkgDir, 'assets', presetId, 'agent.cordis.yml')))
|
|
153
|
+
writeFileSync(join(target, 'preset.yml'), readFileSync(join(pkgDir, 'assets', presetId, 'preset.yml')))
|
|
154
|
+
|
|
155
|
+
let skills = 'none'
|
|
156
|
+
if (presetId === 'cordis-gitbash') {
|
|
157
|
+
if (skillsSource && existsSync(skillsSource)) {
|
|
158
|
+
cpSync(skillsSource, join(target, 'skills'), { recursive: true, force: true, dereference: true })
|
|
159
|
+
skills = 'copied'
|
|
160
|
+
} else {
|
|
161
|
+
mkdirSync(join(target, 'skills'), { recursive: true })
|
|
162
|
+
skills = 'missing-source'
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const marker = { managedBy: MANAGED_BY, version, presetId, files: hashTree(target) }
|
|
167
|
+
writeFileSync(join(target, MARKER_FILE), JSON.stringify(marker, null, 2) + '\n')
|
|
168
|
+
return skills
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Disposal-time cleanup for one preset. */
|
|
172
|
+
function cleanupOnDispose({ target, packageJsonExists }) {
|
|
173
|
+
if (packageJsonExists) return 'kept-package-intact'
|
|
174
|
+
const state = classify(target)
|
|
175
|
+
if (state === 'unmodified') {
|
|
176
|
+
rmSync(target, { recursive: true, force: true })
|
|
177
|
+
return 'removed'
|
|
178
|
+
}
|
|
179
|
+
return `kept-${state}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── inspect-registry compatibility shim ─────────────────────────────────────
|
|
183
|
+
//
|
|
184
|
+
// The host-plane runner's inspect registry is a process-global singleton and
|
|
185
|
+
// its `register` THROWS on a duplicate provider id. `tool-cordis` registers
|
|
186
|
+
// the same provider ids from every preset that mounts it, so a process
|
|
187
|
+
// hosting both the built-in Creation mode and any cordis-derived preset
|
|
188
|
+
// (cordis-gitbash here, ptc-cordis from dsh-ptc-cordis-preset) fails the
|
|
189
|
+
// SECOND mount. Two registrants of the SAME package produce identical
|
|
190
|
+
// manifests, so replacing the stored entry instead of throwing is a no-op for
|
|
191
|
+
// consumers, and the identity-guarded disposer keeps teardown consistent.
|
|
192
|
+
// Installed once at boot by this always-mounted host row, so every later
|
|
193
|
+
// cordis toolset mount coexists.
|
|
194
|
+
|
|
195
|
+
const SHIM_FLAG = '__gitbashShellRegisterShim'
|
|
196
|
+
|
|
197
|
+
/** Wrap one inspect registry's `register` to tolerate duplicate registrations. */
|
|
198
|
+
export function installRegisterShim(reg) {
|
|
199
|
+
const restore = () => {}
|
|
200
|
+
if (!reg || typeof reg.register !== 'function' || !(reg.providers instanceof Map)) {
|
|
201
|
+
return { installed: false, restore }
|
|
202
|
+
}
|
|
203
|
+
if (reg.register[SHIM_FLAG] === true) return { installed: false, restore }
|
|
204
|
+
const original = reg.register
|
|
205
|
+
try {
|
|
206
|
+
const wrapped = function register(registration) {
|
|
207
|
+
try {
|
|
208
|
+
return original.call(this, registration)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const message = error && error.message ? error.message : String(error)
|
|
211
|
+
if (!message.includes('is already registered')) throw error
|
|
212
|
+
const manifest = registration && registration.manifest
|
|
213
|
+
if (!manifest || typeof manifest.id !== 'string') throw error
|
|
214
|
+
const stored = { ...registration, manifest }
|
|
215
|
+
this.providers.set(manifest.id, stored)
|
|
216
|
+
const self = this
|
|
217
|
+
return () => {
|
|
218
|
+
if (self.providers.get(manifest.id) === stored) self.providers.delete(manifest.id)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
try { Object.defineProperty(wrapped, SHIM_FLAG, { value: true }) } catch { /* cosmetic */ }
|
|
223
|
+
reg.register = wrapped
|
|
224
|
+
return {
|
|
225
|
+
installed: true,
|
|
226
|
+
restore: () => {
|
|
227
|
+
try { if (reg.register === wrapped) reg.register = original } catch { /* never block */ }
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
return { installed: false, restore }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── plugin ──────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
/** First user-trust root: the roster's authoring target. */
|
|
238
|
+
function firstUserRoot(roots) {
|
|
239
|
+
for (const r of roots) if (r && r.trust === 'user' && typeof r.path === 'string') return r
|
|
240
|
+
return undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Directory of skills inside the installed shipped `cordis` preset, if any. */
|
|
244
|
+
async function findSkillsSource(agentPresets) {
|
|
245
|
+
try {
|
|
246
|
+
const list = await agentPresets.list()
|
|
247
|
+
const cordis = Array.isArray(list) ? list.find((p) => p && p.id === SKILLS_SOURCE_PRESET && typeof p.path === 'string') : undefined
|
|
248
|
+
if (!cordis) return undefined
|
|
249
|
+
return join(dirname(cordis.path), 'skills')
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Purge preset directories this plugin materialized in earlier versions but
|
|
257
|
+
* that are no longer in the configured materialization set. Only an
|
|
258
|
+
* UNMODIFIED tree is removed; a user-edited one stays (and is left alone).
|
|
259
|
+
* @param userRoot - the user-trust preset root path.
|
|
260
|
+
* @param keep - preset ids to keep.
|
|
261
|
+
*/
|
|
262
|
+
function purgeOrphans(userRoot, keep) {
|
|
263
|
+
try {
|
|
264
|
+
for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
|
|
265
|
+
if (!entry.isDirectory()) continue
|
|
266
|
+
const id = entry.name
|
|
267
|
+
if (keep.includes(id)) continue
|
|
268
|
+
const target = join(userRoot, id)
|
|
269
|
+
const marker = readMarker(target)
|
|
270
|
+
if (!marker || marker.managedBy !== MANAGED_BY) continue
|
|
271
|
+
if (classify(target) === 'unmodified') {
|
|
272
|
+
rmSync(target, { recursive: true, force: true })
|
|
273
|
+
console.log(`${TAG} removed orphan preset '${id}' (no longer materialized)`)
|
|
274
|
+
} else {
|
|
275
|
+
console.log(`${TAG} orphan preset '${id}' was modified after materialization — leaving it alone`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.log(`${TAG} orphan cleanup skipped: ${error?.message ?? error}`)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function apply(ctx, config = {}) {
|
|
284
|
+
const presetIds = Array.isArray(config.presets) && config.presets.length > 0 ? config.presets : PRESET_IDS
|
|
285
|
+
|
|
286
|
+
// The shim rides along every mount of this plugin and degrades silently if
|
|
287
|
+
// the upstream shape differs from what we verified.
|
|
288
|
+
let shim = { installed: false, restore: () => {} }
|
|
289
|
+
try {
|
|
290
|
+
shim = installRegisterShim(ctx.get('cordisInspect'))
|
|
291
|
+
} catch {
|
|
292
|
+
/* never block startup */
|
|
293
|
+
}
|
|
294
|
+
if (shim.installed) {
|
|
295
|
+
ctx.effect(() => () => shim.restore(), 'dsh-gitbash-shell: inspect-registry shim')
|
|
296
|
+
console.log(`${TAG} inspect-registry compatibility shim active (multiple cordis-mode sessions supported)`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── cooperation capability ────────────────────────────────────────────────
|
|
300
|
+
// Publish whether the Git Bash shell stack is active on this host so peer
|
|
301
|
+
// plugins (e.g. dsh-ptc-cordis-preset) can adopt it in the presets they
|
|
302
|
+
// materialize: service present, 'active: true' on Windows, 'active: false'
|
|
303
|
+
// where the bundle is installed but the platform stack stayed native.
|
|
304
|
+
const gitBashCapability = { active: process.platform === 'win32', bashPath: DEFAULT_GIT_BASH }
|
|
305
|
+
const disposeGitBash = ctx.provide('gitBash', gitBashCapability)
|
|
306
|
+
ctx.effect(() => disposeGitBash, 'dsh-gitbash-shell: gitBash capability')
|
|
307
|
+
|
|
308
|
+
const roots = ctx.agentPresets?.roots ?? []
|
|
309
|
+
const userRoot = firstUserRoot(roots)
|
|
310
|
+
if (!userRoot) {
|
|
311
|
+
console.log(`${TAG} no user-trust preset root configured — nothing to materialize`)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let version = '0.0.0'
|
|
316
|
+
try {
|
|
317
|
+
version = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')).version ?? version
|
|
318
|
+
} catch {
|
|
319
|
+
/* fall back to the placeholder */
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const skillsSource = await findSkillsSource(ctx.agentPresets)
|
|
323
|
+
const userRootPath = userRoot.path
|
|
324
|
+
purgeOrphans(userRootPath, presetIds)
|
|
325
|
+
|
|
326
|
+
for (const presetId of presetIds) {
|
|
327
|
+
const target = join(userRootPath, presetId)
|
|
328
|
+
const state = classify(target)
|
|
329
|
+
if (state === 'foreign') {
|
|
330
|
+
console.log(`${TAG} a preset not written by this plugin already exists at ${target} — leaving it alone`)
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
if (state === 'user-modified') {
|
|
334
|
+
console.log(`${TAG} preset at ${target} was modified after materialization — keeping the user's version (delete the directory to re-materialize)`)
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Reversible side effect, registered BEFORE the idle check so a quiet
|
|
339
|
+
// startup keeps uninstall hygiene.
|
|
340
|
+
ctx.effect(() => () => {
|
|
341
|
+
try {
|
|
342
|
+
const result = cleanupOnDispose({ target, packageJsonExists: existsSync(join(pkgDir, 'package.json')) })
|
|
343
|
+
if (result === 'removed') console.log(`${TAG} package uninstalled — removed the unmodified '${presetId}' preset`)
|
|
344
|
+
else if (result !== 'kept-package-intact' && result !== 'kept-absent') console.log(`${TAG} preset ${result} on disposal — kept`)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.log(`${TAG} cleanup skipped: ${error?.message ?? error}`)
|
|
347
|
+
}
|
|
348
|
+
}, `dsh-gitbash-shell: preset materialization (${presetId})`)
|
|
349
|
+
|
|
350
|
+
const sourceHashes = presetId === 'cordis-gitbash' ? skillsHashes(skillsSource) : null
|
|
351
|
+
const marker = readMarker(target)
|
|
352
|
+
if (state === 'unmodified' && syncDecision({ state, marker, version, sourceHashes }) === 'idle') {
|
|
353
|
+
ctx.logger?.('gitbash-shell')?.debug?.( `preset '${presetId}' up to date (v${version}) — idle`)
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const skills = materialize({ target, presetId, skillsSource, version })
|
|
358
|
+
const verb = state === 'absent' ? 'materialized' : 'refreshed'
|
|
359
|
+
console.log(
|
|
360
|
+
`${TAG} ${verb} preset '${presetId}' into ${userRootPath} (v${version})` +
|
|
361
|
+
(skills === 'copied' ? " (skills copied from the installed 'cordis' preset)" : '')
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Test surface: pure helpers, no Cordis context required.
|
|
367
|
+
export const _internal = { PRESET_IDS, MARKER_FILE, classify, materialize, cleanupOnDispose, firstUserRoot, hashTree, skillsHashes, syncDecision, installRegisterShim }
|
package/src/shell.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbash-shell/shell — the Git Bash ctx.shell executor.
|
|
3
|
+
*
|
|
4
|
+
* The shipped Windows host composes `dsh-pwsh-sandbox` as ctx.shell and the
|
|
5
|
+
* platform gates (`!!js process.platform === 'win32'`) disable the bash
|
|
6
|
+
* stack on Windows, because a bare `bash` on Windows resolves to the WSL
|
|
7
|
+
* shim in System32 — or to nothing at all when Git's bin directory is not on
|
|
8
|
+
* PATH. This plugin restores the bash stack on Windows while pointing it at
|
|
9
|
+
* Git for Windows' real bash.exe:
|
|
10
|
+
*
|
|
11
|
+
* - extends @deepseek-ai/dsh-bash-sandbox so every confined-mode
|
|
12
|
+
* (read-only / workspace-write) call keeps the exact sandbox policy,
|
|
13
|
+
* denial, and runner-failure semantics of the shipped stack;
|
|
14
|
+
* - overrides `confine` so the inner argv is [git-bash, -c, command];
|
|
15
|
+
* - overrides run/start ONLY for danger-full-access (the parent's
|
|
16
|
+
* full-access branch calls LocalBashExecutor.run, which hardcodes the
|
|
17
|
+
* bare `bash` name), routing through runArgv/startArgv with the same
|
|
18
|
+
* argv as the confined branch.
|
|
19
|
+
*
|
|
20
|
+
* Environment inheritance: bash.exe is spawned as a direct child of the host
|
|
21
|
+
* process (never through the git-bash.exe login launcher), so it inherits the
|
|
22
|
+
* full system environment plus the DSH_* snapshot exactly like the pwsh
|
|
23
|
+
* executor did.
|
|
24
|
+
*
|
|
25
|
+
* Loaded as a class plugin: Cordis constructs it and the ShellExecutor base
|
|
26
|
+
* registers `ctx.shell` (one implementation per context).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
|
30
|
+
import z from '@deepseek-ai/schemastery'
|
|
31
|
+
|
|
32
|
+
/** Default Git for Windows bash (forward slashes work on Windows too). */
|
|
33
|
+
export const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
|
|
34
|
+
|
|
35
|
+
/** Resolved configuration: the local executor's knobs, plus the Git Bash path. */
|
|
36
|
+
export const Config = z.object({
|
|
37
|
+
cwd: z.string(),
|
|
38
|
+
timeoutMs: z.number().default(120000),
|
|
39
|
+
maxTimeoutMs: z.number().default(600000),
|
|
40
|
+
maxOutputBytes: z.number().default(64000),
|
|
41
|
+
maxSpillBytes: z.number().default(64 * 1024 * 1024),
|
|
42
|
+
graceMs: z.number().default(3000),
|
|
43
|
+
bashPath: z.string().default(DEFAULT_GIT_BASH),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/** Git Bash executor — mirrors the shipped bash/pwsh sandbox executors. */
|
|
47
|
+
export class GitBashSandboxExecutor extends SandboxBashExecutor {
|
|
48
|
+
static inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
|
49
|
+
static Config = Config
|
|
50
|
+
|
|
51
|
+
/** Effective Git Bash path: composition/config value with the default fallback. */
|
|
52
|
+
get bashPath() {
|
|
53
|
+
return this.config.bashPath ?? DEFAULT_GIT_BASH
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Wrap one shell command via the ctx.sandbox provider, substituting Git Bash
|
|
58
|
+
* for the shipped bare `bash` argv.
|
|
59
|
+
* @param command - shell source for the confined inner `bash -c`.
|
|
60
|
+
* @param policy - resolved confined execution policy.
|
|
61
|
+
* @returns the provider's exact argv and settlement-classification facts.
|
|
62
|
+
*/
|
|
63
|
+
confine(command, policy) {
|
|
64
|
+
return this.ctx.sandbox.confine([this.bashPath, '-c', command], policy)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Full-access path with Git Bash argv: the parent's full-access branch
|
|
69
|
+
* delegates to LocalBashExecutor.run, which hardcodes the bare `bash`
|
|
70
|
+
* name, so override that branch here and keep everything else inherited.
|
|
71
|
+
*/
|
|
72
|
+
async run(spec) {
|
|
73
|
+
const policy = spec.sandboxPolicy
|
|
74
|
+
if (policy === undefined) return super.run(spec)
|
|
75
|
+
const { mode } = policy
|
|
76
|
+
if (mode === 'danger-full-access') {
|
|
77
|
+
const result = await this.runArgv(spec, [this.bashPath, '-c', spec.command])
|
|
78
|
+
return { ...result, sandbox: { mode, denied: false } }
|
|
79
|
+
}
|
|
80
|
+
return super.run(spec)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
start(spec) {
|
|
84
|
+
const policy = spec.sandboxPolicy
|
|
85
|
+
if (policy === undefined) return super.start(spec)
|
|
86
|
+
const { mode } = policy
|
|
87
|
+
if (mode === 'danger-full-access') {
|
|
88
|
+
const proc = this.startArgv(spec, [this.bashPath, '-c', spec.command])
|
|
89
|
+
proc.sandbox = { mode, denied: false }
|
|
90
|
+
return proc
|
|
91
|
+
}
|
|
92
|
+
return super.start(spec)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default GitBashSandboxExecutor
|