@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.
- package/README.md +64 -2
- package/client/client.js +504 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +16 -0
- package/lib/index.js +558 -0
- package/lib/mode-route.js +204 -0
- package/lib/mode.js +244 -0
- package/lib/resolve.js +161 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
|
@@ -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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hwfee/dsh-git-bash",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|