@hwfee/dsh-git-bash 0.0.0-stage → 0.1.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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The mode bridge: the page → host half of the `默认 shell` setting.
3
+ *
4
+ * WHY A ROUTE AND NOT AN EVENT. The settings row lives in the browser process;
5
+ * `run_shell` lives in the host process. `ctx.emit('git-bash/mode', …)` reaches
6
+ * browser listeners only, so the host kept its boot default (`auto`) FOREVER —
7
+ * choosing "Windows PowerShell" repainted the row and changed nothing the model
8
+ * ran. That was invisible because the row is client-rendered: it named the mode
9
+ * the user picked while every command still went to Git Bash.
10
+ *
11
+ * A same-origin POST to the host's own web server is the smallest bridge that
12
+ * crosses the process boundary without adding the Typert RPC dependency to this
13
+ * package. The fence is the same one `dsh-system-fonts`' catalogue route uses:
14
+ * the custom header is impossible for an `<img>`/`<form>`/navigation to send,
15
+ * a cross-origin fetch that set it would preflight and be refused, and the Host
16
+ * / Origin / Sec-Fetch-Site checks stop DNS rebinding and cross-site forms.
17
+ *
18
+ * @module @hwfee/dsh-git-bash/mode-route
19
+ */
20
+
21
+ import { MODES } from './mode.js'
22
+
23
+ /** Route the page posts the selected mode to. */
24
+ export const MODE_PATH = '/dsh-git-bash/mode'
25
+
26
+ /** The marker every legitimate request carries (see the header comment). */
27
+ export const MODE_MARKER_HEADER = 'x-dsh-git-bash'
28
+ export const MODE_MARKER_VALUE = '1'
29
+
30
+ /** Largest body accepted. A mode is a three-letter word; 1 KiB is generous. */
31
+ export const MAX_BODY_BYTES = 1024
32
+
33
+ function headerOf(request, name) {
34
+ const headers = request && request.headers
35
+ if (!headers || typeof headers !== 'object') return undefined
36
+ const value = headers[name]
37
+ if (Array.isArray(value)) return value.length === 1 ? String(value[0]) : undefined
38
+ return typeof value === 'string' ? value : undefined
39
+ }
40
+
41
+ function authorityOf(text, scheme) {
42
+ if (typeof text !== 'string' || text === '') return null
43
+ const absolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(text)
44
+ try {
45
+ const url = new URL(absolute ? text : (scheme || 'http:') + '//' + text)
46
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
47
+ return url.host.toLowerCase()
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ function hostnameOf(authority) {
54
+ const at = authority.lastIndexOf(':')
55
+ return at === -1 ? authority : authority.slice(0, at)
56
+ }
57
+
58
+ function portOf(authority) {
59
+ const at = authority.lastIndexOf(':')
60
+ return at === -1 ? '' : authority.slice(at + 1)
61
+ }
62
+
63
+ function isLoopbackAuthority(authority) {
64
+ const host = hostnameOf(authority)
65
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'
66
+ }
67
+
68
+ /**
69
+ * Decide whether a request may change the mode.
70
+ *
71
+ * Fails CLOSED on every branch, and checks only headers — the body is never
72
+ * read for a refused request.
73
+ *
74
+ * @param {object} request node:http request (only `headers` is read)
75
+ * @param {string | null} origin the listener's own `http://host:port`
76
+ * @returns {string | null} null when the request is ours, else a reason
77
+ */
78
+ export function refuseRequest(request, origin) {
79
+ const host = headerOf(request, 'host')
80
+ if (host === undefined) return 'no-host'
81
+ const authority = authorityOf(host, 'http:')
82
+ if (authority === null) return 'bad-host'
83
+
84
+ const own = origin === null || origin === undefined ? null : authorityOf(String(origin), 'http:')
85
+ if (own === null) {
86
+ if (!isLoopbackAuthority(authority)) return 'foreign-host'
87
+ } else if (authority !== own) {
88
+ const sameLoopbackPeer = isLoopbackAuthority(authority)
89
+ && isLoopbackAuthority(own)
90
+ && portOf(authority) === portOf(own)
91
+ if (!sameLoopbackPeer) return 'foreign-host'
92
+ }
93
+
94
+ const site = headerOf(request, 'sec-fetch-site')
95
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') return 'cross-site'
96
+
97
+ const requestOrigin = headerOf(request, 'origin')
98
+ if (requestOrigin !== undefined && authorityOf(requestOrigin, 'http:') !== authority) return 'foreign-origin'
99
+
100
+ if (headerOf(request, MODE_MARKER_HEADER) !== MODE_MARKER_VALUE) return 'missing-marker'
101
+
102
+ return null
103
+ }
104
+
105
+ function sendJson(response, status, body) {
106
+ const text = JSON.stringify(body)
107
+ response.writeHead(status, {
108
+ 'content-type': 'application/json; charset=utf-8',
109
+ 'content-length': Buffer.byteLength(text),
110
+ 'cache-control': 'no-store',
111
+ 'x-content-type-options': 'nosniff',
112
+ })
113
+ response.end(text)
114
+ }
115
+
116
+ function readBody(request) {
117
+ return new Promise((resolve, reject) => {
118
+ let size = 0
119
+ const chunks = []
120
+ request.on('data', (chunk) => {
121
+ size += chunk.length
122
+ if (size > MAX_BODY_BYTES) {
123
+ reject(new Error('body-too-large'))
124
+ request.destroy?.()
125
+ return
126
+ }
127
+ chunks.push(chunk)
128
+ })
129
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
130
+ request.on('error', reject)
131
+ })
132
+ }
133
+
134
+ /**
135
+ * The mode route.
136
+ *
137
+ * `POST { mode }` sets; `GET` reports the host's current value. The read half
138
+ * exists because the host's mode was otherwise INVISIBLE from the page: the
139
+ * row could show one value while the host ran another, and no diagnostic could
140
+ * tell them apart. It is fenced exactly like the write.
141
+ *
142
+ * @param {(mode: string) => void} onMode called with a VALIDATED mode
143
+ * @param {object} [options]
144
+ * @param {() => string | null} [options.origin] the listener's own origin
145
+ * @param {() => string} [options.readMode] the host's current mode
146
+ * @returns {{ kind: 'exact', path: string, handler: Function }} the route
147
+ */
148
+ export function createModeRoute(onMode, options = {}) {
149
+ const originOf = typeof options.origin === 'function' ? options.origin : () => null
150
+ const read = typeof options.readMode === 'function' ? options.readMode : () => 'auto'
151
+ const origin = () => {
152
+ try {
153
+ const value = originOf()
154
+ return typeof value === 'string' && value !== '' ? value : null
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ return {
161
+ kind: 'exact',
162
+ path: MODE_PATH,
163
+ handler: async (request, response) => {
164
+ if (request.method !== 'POST' && request.method !== 'GET') {
165
+ response.writeHead(405, { allow: 'GET, POST' })
166
+ response.end()
167
+ return
168
+ }
169
+ const refused = refuseRequest(request, origin())
170
+ if (refused !== null) {
171
+ sendJson(response, 403, { error: 'refused', reason: refused })
172
+ return
173
+ }
174
+ if (request.method === 'GET') {
175
+ sendJson(response, 200, { ok: true, mode: read() })
176
+ return
177
+ }
178
+ let body
179
+ try {
180
+ body = await readBody(request)
181
+ } catch (error) {
182
+ sendJson(response, 413, { error: String(error?.message ?? 'body') })
183
+ return
184
+ }
185
+ let parsed
186
+ try {
187
+ parsed = JSON.parse(body)
188
+ } catch {
189
+ sendJson(response, 400, { error: 'invalid-json' })
190
+ return
191
+ }
192
+ if (parsed === null || typeof parsed !== 'object' || typeof parsed.mode !== 'string') {
193
+ sendJson(response, 400, { error: 'invalid-mode' })
194
+ return
195
+ }
196
+ if (!MODES.includes(parsed.mode)) {
197
+ sendJson(response, 400, { error: 'unknown-mode', mode: parsed.mode })
198
+ return
199
+ }
200
+ onMode(parsed.mode)
201
+ sendJson(response, 200, { ok: true, mode: parsed.mode })
202
+ },
203
+ }
204
+ }
package/lib/mode.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The three shell modes, and the decision each one makes.
3
+ *
4
+ * THIS MODULE IS SHARED BY BOTH HALVES. The client imports it for the values and
5
+ * the storage helpers (`client/src/store.js`), and the host imports it for
6
+ * `chooseShell()` (`lib/index.js`). It is therefore DEPENDENCY-FREE — no
7
+ * `node:` imports — because it is also bundled into `client/client.js`. Executable
8
+ * discovery lives in `resolve.js` (host-only) and is injected here as a function.
9
+ *
10
+ * THE RULE THAT MATTERS IS ABOUT FALLBACK.
11
+ *
12
+ * auto may fall back, and its `source` always says which branch was taken
13
+ * and why ("git --exec-path (auto)" / "known path (auto fallback)").
14
+ * A fallback that does not announce itself is indistinguishable from
15
+ * the setting doing nothing.
16
+ * pwsh may not fall back to Git Bash.
17
+ * gitbash may not fall back to PowerShell. Handing PowerShell to a user who
18
+ * asked for Git Bash turns every `grep`, `$VAR` and `&&` into a
19
+ * confusing failure that looks like the tool is broken — worse than
20
+ * refusing to run.
21
+ *
22
+ * @module mode
23
+ */
24
+
25
+ /** The values the settings row may store, in display order. */
26
+ export const MODES = ['auto', 'pwsh', 'gitbash']
27
+
28
+ /** A fresh install follows the machine, which is what an unconfigured user means. */
29
+ export const DEFAULT_MODE = 'auto'
30
+
31
+ /** Mode → the label key in this plugin's dictionaries. */
32
+ export const MODE_LABELS = {
33
+ auto: 'shell.mode.auto',
34
+ pwsh: 'shell.mode.pwsh',
35
+ gitbash: 'shell.mode.gitbash',
36
+ }
37
+
38
+ /**
39
+ * Resolved shell family → the label key in this plugin's dictionaries.
40
+ *
41
+ * THE KINDS ARE NOT MODES. `pwsh` is one mode but two families (`pwsh` for
42
+ * PowerShell 7, `windows-powershell` for 5.1), and `gitbash` is one mode that
43
+ * resolves to `posix-bash` away from Windows. The row must name the thing that
44
+ * will actually run, not the setting the user clicked.
45
+ */
46
+ export const KIND_LABELS = {
47
+ gitbash: 'shell.kind.gitbash',
48
+ 'posix-bash': 'shell.kind.posix-bash',
49
+ pwsh: 'shell.kind.pwsh',
50
+ 'windows-powershell': 'shell.kind.windows-powershell',
51
+ }
52
+
53
+ /**
54
+ * Why a mode could not be satisfied, in the user's language.
55
+ *
56
+ * ONE SOURCE FOR TWO READERS: the settings row renders this, and the tool throws
57
+ * it. Two copies of these sentences would drift, and the row's job is to explain
58
+ * the same failure the model reports.
59
+ */
60
+ export const REASON_LABELS = {
61
+ 'gitbash-not-found': 'shell.reason.gitbash-not-found',
62
+ 'pwsh-not-found': 'shell.reason.pwsh-not-found',
63
+ 'no-shell-for-platform': 'shell.reason.no-shell-for-platform',
64
+ 'no-shell-found': 'shell.reason.no-shell-found',
65
+ }
66
+
67
+ /**
68
+ * The key the FIRST build of this plugin wrote a boolean under.
69
+ *
70
+ * It answered a DIFFERENT question ("keep the platform shell tool visible beside
71
+ * run_shell"), so it cannot be read as a mode directly. It is never deleted and
72
+ * never re-read as a value: {@link migrateLegacyMode} translates the one
73
+ * unambiguous case (`'false'` — the user asked to run only through Git Bash) and
74
+ * leaves everything else at the default. The string is the historical fact; it
75
+ * must not be renamed, or those users' choice is lost.
76
+ */
77
+ export const LEGACY_BOOLEAN_KEY = '@hwfee/dsh-git-bash:exposeNativeShell'
78
+
79
+ /** Whether a stored value is one of the three modes. */
80
+ export function isMode(value) {
81
+ return typeof value === 'string' && MODES.includes(value)
82
+ }
83
+
84
+ /**
85
+ * Coerce anything to a mode.
86
+ *
87
+ * A corrupt storage entry, a value from a future version, or an unknown string
88
+ * all land on `auto`: that is the setting that cannot be wrong, because it
89
+ * follows the machine. Running commands beats refusing over a bad preference.
90
+ *
91
+ * @param {unknown} value
92
+ * @returns {'auto' | 'pwsh' | 'gitbash'}
93
+ */
94
+ export function normalizeMode(value) {
95
+ return isMode(value) ? value : DEFAULT_MODE
96
+ }
97
+
98
+ /**
99
+ * Read the stored mode.
100
+ *
101
+ * Every failure path returns the default: private mode throws on the property
102
+ * READ itself (before any method runs), and storage may be disabled entirely. A
103
+ * settings page must render regardless.
104
+ *
105
+ * @param {Storage | undefined} storage the storage object, or undefined
106
+ * @param {string} key the mode key
107
+ * @returns {'auto' | 'pwsh' | 'gitbash'} the mode in force
108
+ */
109
+ export function readMode(storage, key) {
110
+ if (storage === null || storage === undefined) return DEFAULT_MODE
111
+ let raw
112
+ try {
113
+ raw = storage.getItem(key)
114
+ } catch {
115
+ return DEFAULT_MODE
116
+ }
117
+ return normalizeMode(raw)
118
+ }
119
+
120
+ /**
121
+ * Persist a mode.
122
+ *
123
+ * Silent on failure: quota-exceeded and private-mode throws must not break the
124
+ * settings page the user is clicking through.
125
+ *
126
+ * @param {Storage | undefined} storage the storage object, or undefined
127
+ * @param {string} key the mode key
128
+ * @param {string} mode the chosen mode
129
+ * @returns {boolean} whether the write landed
130
+ */
131
+ export function writeMode(storage, key, mode) {
132
+ if (storage === null || storage === undefined) return false
133
+ if (!isMode(mode)) return false
134
+ try {
135
+ storage.setItem(key, mode)
136
+ return true
137
+ } catch {
138
+ return false
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Translate the old boolean preference into the new mode key, once.
144
+ *
145
+ * The mapping is deliberately conservative, because the old key measured a
146
+ * different thing:
147
+ *
148
+ * 'false' the user turned the platform shell OFF, i.e. asked for Git Bash to
149
+ * be the only way commands run → `gitbash`
150
+ * 'true' the default; nothing was expressed about the shell → `auto`, which
151
+ * prefers Git Bash anyway, so behaviour is unchanged
152
+ * absent / anything else → no migration; {@link readMode} returns the default
153
+ *
154
+ * A value already present under the new key always wins, so the migration can
155
+ * only ever run once and can never overwrite a newer choice.
156
+ *
157
+ * @param {Storage | undefined} storage the storage object, or undefined
158
+ * @param {string} key the new mode key
159
+ * @returns {string | null} the migrated mode, or null when nothing was migrated
160
+ */
161
+ export function migrateLegacyMode(storage, key) {
162
+ if (storage === null || storage === undefined) return null
163
+ let current
164
+ try {
165
+ current = storage.getItem(key)
166
+ } catch {
167
+ return null
168
+ }
169
+ if (isMode(current)) return null
170
+
171
+ let legacy
172
+ try {
173
+ legacy = storage.getItem(LEGACY_BOOLEAN_KEY)
174
+ } catch {
175
+ return null
176
+ }
177
+ const migrated = legacy === 'false' ? 'gitbash' : legacy === 'true' ? DEFAULT_MODE : null
178
+ if (migrated === null) return null
179
+ try {
180
+ storage.setItem(key, migrated)
181
+ } catch {
182
+ // The value still applies for this session; the next launch retries.
183
+ }
184
+ return migrated
185
+ }
186
+
187
+ /**
188
+ * Choose the shell for a mode.
189
+ *
190
+ * `resolve(kind)` is injected so this module stays free of `node:` imports and
191
+ * the tests can describe a machine instead of probing one. Its kinds are the
192
+ * ones `resolve.js` produces: `gitbash`, `pwsh`, `windows-powershell`,
193
+ * `posix-bash`.
194
+ *
195
+ * @param {'auto' | 'pwsh' | 'gitbash' | string} mode the selected mode
196
+ * @param {object} deps
197
+ * @param {(kind: string) => ({ kind: string, path: string, source: string } | null)} deps.resolve
198
+ * resolves one shell kind, or null
199
+ * @param {string} [deps.platform] `process.platform`, injected for tests
200
+ * @returns {{ ok: true, shell: { kind: string, path: string, source: string } }
201
+ * | { ok: false, reason: string }} the decision — a failure is a VALUE, never
202
+ * an exception, because the settings row has to render it
203
+ */
204
+ export function chooseShell(mode, deps = {}) {
205
+ const resolve = typeof deps.resolve === 'function' ? deps.resolve : () => null
206
+ const platform = deps.platform ?? 'win32'
207
+ const requested = normalizeMode(mode)
208
+ const windows = platform === 'win32'
209
+
210
+ if (requested === 'gitbash') {
211
+ const found = resolve(windows ? 'gitbash' : 'posix-bash')
212
+ if (found === null) return { ok: false, reason: 'gitbash-not-found' }
213
+ return { ok: true, shell: found }
214
+ }
215
+
216
+ if (requested === 'pwsh') {
217
+ // Windows PowerShell is a Windows program. On POSIX the honest answer is
218
+ // "not available here", not "not installed".
219
+ if (!windows) return { ok: false, reason: 'no-shell-for-platform' }
220
+ const found = resolve('pwsh')
221
+ if (found === null) return { ok: false, reason: 'pwsh-not-found' }
222
+ return { ok: true, shell: found }
223
+ }
224
+
225
+ // AUTO.
226
+ if (windows) {
227
+ const gitbash = resolve('gitbash')
228
+ if (gitbash !== null) {
229
+ return { ok: true, shell: { ...gitbash, source: gitbash.source + ' (auto)' } }
230
+ }
231
+ const fallback = resolve('pwsh')
232
+ if (fallback !== null) {
233
+ return {
234
+ ok: true,
235
+ shell: { ...fallback, source: fallback.source + ' (auto fallback)' },
236
+ }
237
+ }
238
+ return { ok: false, reason: 'no-shell-found' }
239
+ }
240
+
241
+ const posixBash = resolve('posix-bash')
242
+ if (posixBash === null) return { ok: false, reason: 'no-shell-found' }
243
+ return { ok: true, shell: { ...posixBash, source: posixBash.source + ' (auto)' } }
244
+ }
package/lib/resolve.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Finding the executables: Git Bash, PowerShell, and the POSIX shell.
3
+ *
4
+ * THE RULE THAT MATTERS MOST IS ABOUT GIT BASH. On Windows `bash` is often on
5
+ * PATH as `C:\Windows\System32\bash.exe` — the WSL stub. It IS bash, it IS on
6
+ * PATH, and it is the WRONG shell: a different filesystem view (`/mnt/c/...`), a
7
+ * different user, and no `git` from the Windows install. A resolver that tries
8
+ * `where bash` first picks it and every subsequent command fails in a way that
9
+ * looks like the plugin is broken.
10
+ *
11
+ * So Git Bash is located from `git --exec-path`, which names the Git install the
12
+ * user actually works with; its `bin/bash.exe` is the shell Git itself ships.
13
+ *
14
+ * @module resolve
15
+ */
16
+
17
+ import { execFile, execFileSync } from 'node:child_process'
18
+ import { existsSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+
21
+ /**
22
+ * Git Bash locations to try after `git --exec-path`.
23
+ *
24
+ * A fixed list, not a PATH search: PATH is where the WSL stub hides.
25
+ */
26
+ export const GITBASH_CANDIDATES = [
27
+ 'C:/Program Files/Git/bin/bash.exe',
28
+ 'C:/Program Files/Git/usr/bin/bash.exe',
29
+ 'C:/Program Files (x86)/Git/bin/bash.exe',
30
+ 'C:/ProgramData/chocolatey/bin/bash.exe',
31
+ ]
32
+
33
+ /**
34
+ * PowerShell locations, newest first.
35
+ *
36
+ * PowerShell 7 (`pwsh.exe`) is tried before Windows PowerShell 5.1: it is the
37
+ * maintained one, and VS Code's own detection prefers it for the same reason.
38
+ * Both are reported with a distinct `kind` so the row can say which one ran.
39
+ */
40
+ export const PWSH_CANDIDATES = [
41
+ { path: 'C:/Program Files/PowerShell/7/pwsh.exe', kind: 'pwsh' },
42
+ { path: 'C:/Program Files/PowerShell/6/pwsh.exe', kind: 'pwsh' },
43
+ { path: 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe', kind: 'windows-powershell' },
44
+ ]
45
+
46
+ /** Normalize a Windows path to forward slashes, the shape `git --exec-path` uses. */
47
+ const slash = (value) => value.replaceAll('\\', '/')
48
+
49
+ /**
50
+ * Build a resolver bound to one environment.
51
+ *
52
+ * @param {object} [deps]
53
+ * @param {(path: string) => boolean} [deps.exists] filesystem probe, for tests
54
+ * @param {(command: string, args: string[]) => string} [deps.execSync] sync runner, for tests
55
+ * @param {NodeJS.ProcessEnv} [deps.env] the environment
56
+ * @param {string} [deps.platform] `process.platform`
57
+ * @returns {(kind: string) => object | null} resolves one shell kind
58
+ */
59
+ export function createResolver(deps = {}) {
60
+ const exists = deps.exists ?? existsSync
61
+ const runSync = deps.execSync ?? defaultExecSync
62
+ const env = deps.env ?? process.env
63
+ const platform = deps.platform ?? process.platform
64
+
65
+ /** Git Bash: the override, then `git --exec-path`, then the known paths. */
66
+ function gitbash() {
67
+ const override = env.DSH_GIT_BASH
68
+ if (typeof override === 'string' && override !== '' && exists(override)) {
69
+ return { kind: 'gitbash', path: slash(override), source: 'DSH_GIT_BASH' }
70
+ }
71
+ if (platform === 'win32') {
72
+ try {
73
+ const execPath = runSync('git', ['--exec-path']).trim()
74
+ if (execPath !== '') {
75
+ const fromGit = slash(join(dirname(dirname(dirname(execPath))), 'bin', 'bash.exe'))
76
+ if (exists(fromGit)) return { kind: 'gitbash', path: fromGit, source: 'git --exec-path' }
77
+ }
78
+ } catch {
79
+ // `git` is not on PATH; fall through to the known locations.
80
+ }
81
+ for (const candidate of GITBASH_CANDIDATES) {
82
+ if (exists(candidate)) return { kind: 'gitbash', path: slash(candidate), source: 'known path' }
83
+ }
84
+ return null
85
+ }
86
+ // POSIX: the bash on PATH IS the shell this mode means.
87
+ try {
88
+ const which = runSync('sh', ['-c', 'command -v bash']).trim()
89
+ if (which !== '') return { kind: 'posix-bash', path: which, source: 'PATH' }
90
+ } catch {
91
+ // No bash at all.
92
+ }
93
+ return null
94
+ }
95
+
96
+ /** PowerShell: 7 before 5.1, then PATH. */
97
+ function pwsh() {
98
+ if (platform !== 'win32') return null
99
+ for (const candidate of PWSH_CANDIDATES) {
100
+ if (exists(candidate.path)) {
101
+ return { kind: candidate.kind, path: slash(candidate.path), source: 'known path' }
102
+ }
103
+ }
104
+ try {
105
+ const which = runSync('where', ['pwsh']).trim().split(/\r?\n/)[0]
106
+ if (which !== '') return { kind: 'pwsh', path: slash(which), source: 'PATH' }
107
+ } catch {
108
+ // Not installed anywhere findable.
109
+ }
110
+ return null
111
+ }
112
+
113
+ /** The POSIX shell, on a POSIX platform. */
114
+ function posixBash() {
115
+ try {
116
+ const which = runSync('sh', ['-c', 'command -v bash']).trim()
117
+ if (which !== '') return { kind: 'posix-bash', path: which, source: 'PATH' }
118
+ } catch {
119
+ // Fall through.
120
+ }
121
+ return null
122
+ }
123
+
124
+ return function resolve(kind) {
125
+ switch (kind) {
126
+ case 'gitbash': return gitbash()
127
+ case 'pwsh': return pwsh()
128
+ case 'posix-bash': return posixBash()
129
+ default: return null
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Run a command synchronously, returning stdout.
136
+ *
137
+ * @param {string} command the executable
138
+ * @param {string[]} args its arguments
139
+ * @returns {string} stdout
140
+ */
141
+ function defaultExecSync(command, args) {
142
+ return execFileSync(command, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 })
143
+ }
144
+
145
+ /**
146
+ * Run a command asynchronously, resolving stdout.
147
+ *
148
+ * Kept for the tool path, which must not block the event loop.
149
+ *
150
+ * @param {string} command the executable
151
+ * @param {string[]} args its arguments
152
+ * @returns {Promise<string>} stdout
153
+ */
154
+ export function execAsync(command, args) {
155
+ return new Promise((resolve, reject) => {
156
+ execFile(command, args, { windowsHide: true, timeout: 5000 }, (error, stdout) => {
157
+ if (error) reject(error)
158
+ else resolve(stdout)
159
+ })
160
+ })
161
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git Bash as default shell",
4
+ "description": "Run shell commands in Git Bash instead of PowerShell. Resolves the real Git for Windows bash (not the WSL stub), exposes a run_shell tool, and shows which shell is in effect."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git Bash 默认 shell",
4
+ "description": "用 Git Bash 而不是 PowerShell 执行 shell 命令。自动定位真正的 Git for Windows bash(不用 WSL 的替身),提供 run_shell 工具,并在设置里显示当前用的是哪个 shell。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@hwfee/dsh-git-bash",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness 的 Git Bash 默认 shell:把 Windows 上的默认命令解释器从 PowerShell 换成 Git Bash,并暴露一个 run_shell 工具。自动从 git --exec-path 定位 Git Bash(WSL 的 bash 不算),找不到时降级而不是让插件消失。 / Git Bash as the default shell for DeepSeek Harness on Windows: resolves Git Bash from `git --exec-path` (never the WSL stub), exposes a run_shell tool, and degrades visibly instead of vanishing when the shell is missing.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "icon": "./icon.svg",
8
+ "license": "MIT",
9
+ "author": "HwFee <1744567281@qq.com>",
10
+ "publishConfig": {
11
+ "access": "public",
12
+ "registry": "https://registry.npmjs.org"
13
+ },
14
+ "engines": {
15
+ "node": "^22.19.0 || >=24.0.0"
16
+ },
17
+ "keywords": [
18
+ "deepseek",
19
+ "deepseek-harness",
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "git-bash",
23
+ "shell",
24
+ "windows"
25
+ ],
26
+ "dsh": {
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ },
30
+ "client": {
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-ui-renderer"
33
+ ],
34
+ "platform": "web",
35
+ "immediately": true
36
+ }
37
+ },
38
+ "exports": {
39
+ ".": {
40
+ "default": "./lib/index.js"
41
+ },
42
+ "./client": "./client/client.js",
43
+ "./cordis.patch.yml": "./cordis.patch.yml",
44
+ "./locale/*.json": "./locale/*.json",
45
+ "./icon": "./icon.svg",
46
+ "./package.json": "./package.json"
47
+ },
48
+ "files": [
49
+ "lib",
50
+ "client/client.js",
51
+ "locale",
52
+ "cordis.patch.yml",
53
+ "icon.svg",
54
+ "README.md"
55
+ ],
56
+ "scripts": {
57
+ "build": "node tools/build-client.mjs",
58
+ "build:check": "node tools/build-client.mjs --check",
59
+ "test": "node tools/build-client.mjs --check && node test/manifest.test.mjs && node test/mode.test.mjs && node test/mode-route.test.mjs && node test/resolve.test.mjs && node test/tool.test.mjs && node test/store.test.mjs && node test/settings-row.test.mjs"
60
+ },
61
+ "devDependencies": {
62
+ "esbuild": "0.25.10"
63
+ }
64
+ }