@chronosun/dsh-wsl 0.0.0-stage → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +233 -2
- package/client.js +645 -0
- package/cordis.patch.yml +79 -0
- package/fs-service.mjs +57 -0
- package/host.js +262 -0
- package/icon.svg +11 -0
- package/index.js +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +86 -4
- package/prompt.mjs +25 -0
- package/search-tools.mjs +91 -0
- package/src/broker.mjs +373 -0
- package/src/changes-paths.mjs +92 -0
- package/src/collector.mjs +47 -0
- package/src/doctor.mjs +237 -0
- package/src/file-references.mjs +214 -0
- package/src/fs.mjs +312 -0
- package/src/helper.mjs +455 -0
- package/src/launch.mjs +54 -0
- package/src/path-user.mjs +13 -0
- package/src/preset-default.mjs +277 -0
- package/src/registry.mjs +56 -0
- package/src/settings.mjs +51 -0
- package/src/subprocess.mjs +180 -0
- package/src/wsl-diff-v2.mjs +315 -0
- package/terminal-launcher.mjs +59 -0
- package/worktree-tool-v2.mjs +102 -0
- package/world.js +112 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Host-side default Agent preset for WSL workspaces.
|
|
2
|
+
//
|
|
3
|
+
// The Workspace UI's New Session path never names a preset (`startSession` /
|
|
4
|
+
// `openWorkspace` / `connectWorkspace` all omit it), so every session created
|
|
5
|
+
// in a `\\wsl.localhost\...` workspace used to fall back to the Host default
|
|
6
|
+
// (`ptc`/`standard`) and silently ran its tools on Windows. This patch rewrites
|
|
7
|
+
// the one request every Client creation path funnels through:
|
|
8
|
+
// `sessionController.create`.
|
|
9
|
+
//
|
|
10
|
+
// Cordis exposes a Service through a tracing Proxy; the original instance is
|
|
11
|
+
// reachable through the global `cordis.original` symbol. Patching the instance
|
|
12
|
+
// (not the Proxy) is what the Typert gateway resolves for each call, so it
|
|
13
|
+
// applies to every Client and every UI entry point, including a browser that
|
|
14
|
+
// was loaded before this plugin. An explicit `agentPreset` always wins.
|
|
15
|
+
//
|
|
16
|
+
// The patch is deliberately scoped by path: a Windows Workspace keeps the Host
|
|
17
|
+
// default. `agentPresets.resolve('wsl')` is checked once per process so a
|
|
18
|
+
// profile where the preset row failed to activate falls back instead of making
|
|
19
|
+
// every session creation fail.
|
|
20
|
+
|
|
21
|
+
export const WSL_PRESET = 'wsl'
|
|
22
|
+
|
|
23
|
+
const ORIGINAL = Symbol.for('cordis.original')
|
|
24
|
+
const PATCHED = Symbol.for('dsh-wsl/preset-default')
|
|
25
|
+
const CWD_PATCHED = Symbol.for('dsh-wsl/model-cwd')
|
|
26
|
+
|
|
27
|
+
// `\\wsl.localhost\<distro>\...`, `//wsl.localhost/...`, `\\wsl$\...`.
|
|
28
|
+
const WSL_UNC = /^[\\/]{2}\??(?:wsl\.localhost|wsl\$)[\\/]/i
|
|
29
|
+
// Extended-length form: `\\?\UNC\wsl.localhost\...`.
|
|
30
|
+
const WSL_EXTENDED_UNC = /^\\\\\?\\UNC\\(?:wsl\.localhost|wsl\$)[\\/]/i
|
|
31
|
+
|
|
32
|
+
/** True for a Windows UNC path that addresses a WSL distribution. */
|
|
33
|
+
export function isWslPath(value) {
|
|
34
|
+
if (typeof value !== 'string' || value.length === 0) return false
|
|
35
|
+
return WSL_UNC.test(value) || WSL_EXTENDED_UNC.test(value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translate a WSL UNC path to the Linux path the tools actually use.
|
|
40
|
+
* `\\\\wsl.localhost\\Ubuntu\\home\\me` -> `/home/me`; anything else -> undefined.
|
|
41
|
+
*/
|
|
42
|
+
export function linuxPathFromUnc(value) {
|
|
43
|
+
const raw = String(value ?? '')
|
|
44
|
+
if (raw.length === 0) return undefined
|
|
45
|
+
const extended = /^\\\\\?\\UNC\\(?:wsl\.localhost|wsl\$)\\([^\\]+)\\(.*)$/i.exec(raw)
|
|
46
|
+
if (extended) return '/' + extended[2].replace(/\\/g, '/')
|
|
47
|
+
const normal = /^[\\/]{2}\??(?:wsl\.localhost|wsl\$)[\\/]([^\\/]+)(?:[\\/](.*))?$/i.exec(raw)
|
|
48
|
+
if (!normal) return undefined
|
|
49
|
+
return '/' + (normal[2] ?? '').replace(/\\/g, '/')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve the Workspace path through the Host registry, tolerating absence. */
|
|
53
|
+
export function workspacePathOf(workspaceRegistry, workspaceId) {
|
|
54
|
+
if (workspaceId === undefined || workspaceId === null || workspaceId === '') return ''
|
|
55
|
+
try {
|
|
56
|
+
const workspace = workspaceRegistry?.get?.(workspaceId)
|
|
57
|
+
return typeof workspace?.path === 'string' ? workspace.path : ''
|
|
58
|
+
} catch {
|
|
59
|
+
return ''
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The preset a create request should use, or undefined to leave it to the Host. */
|
|
64
|
+
export function presetForRequest(ctx, request) {
|
|
65
|
+
if (!request || typeof request !== 'object') return undefined
|
|
66
|
+
if (request.agentPreset !== undefined) return undefined
|
|
67
|
+
const cwd = typeof request.cwd === 'string' ? request.cwd : ''
|
|
68
|
+
if (cwd !== '') return isWslPath(cwd) ? WSL_PRESET : undefined
|
|
69
|
+
let registry
|
|
70
|
+
try {
|
|
71
|
+
registry = ctx.get('workspaceRegistry')
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
return isWslPath(workspacePathOf(registry, request.workspaceId)) ? WSL_PRESET : undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function unwrap(value) {
|
|
79
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) return value
|
|
80
|
+
try {
|
|
81
|
+
return value[ORIGINAL] ?? value
|
|
82
|
+
} catch {
|
|
83
|
+
return value
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Install the request-rewriting patch on the Host `sessionController`.
|
|
89
|
+
*
|
|
90
|
+
* The service may not exist yet when the plugin applies; the `internal/service`
|
|
91
|
+
* event covers that without making the whole plugin row depend on it.
|
|
92
|
+
*
|
|
93
|
+
* @returns `{ installed, dispose }`; `installed` is a live getter.
|
|
94
|
+
*/
|
|
95
|
+
export function installSessionPresetDefault(ctx) {
|
|
96
|
+
const disposers = []
|
|
97
|
+
let installed = false
|
|
98
|
+
let wslPresetReady = false
|
|
99
|
+
let checking = null
|
|
100
|
+
|
|
101
|
+
async function presetAvailable() {
|
|
102
|
+
if (wslPresetReady) return true
|
|
103
|
+
if (checking === null) {
|
|
104
|
+
checking = (async () => {
|
|
105
|
+
let presets
|
|
106
|
+
try {
|
|
107
|
+
presets = ctx.get('agentPresets')
|
|
108
|
+
} catch {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
if (typeof presets?.resolve !== 'function') return false
|
|
112
|
+
try {
|
|
113
|
+
await presets.resolve(WSL_PRESET)
|
|
114
|
+
return true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
})().finally(() => { checking = null })
|
|
119
|
+
}
|
|
120
|
+
const ok = await checking
|
|
121
|
+
// A failed probe may mean the preset row is still mounting, so only the
|
|
122
|
+
// positive answer is cached for the life of the process.
|
|
123
|
+
if (ok) wslPresetReady = true
|
|
124
|
+
return ok
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const install = (service) => {
|
|
128
|
+
if (installed) return true
|
|
129
|
+
const raw = unwrap(service)
|
|
130
|
+
if (!raw || typeof raw.create !== 'function') return false
|
|
131
|
+
if (raw[PATCHED]) {
|
|
132
|
+
installed = true
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const original = raw.create
|
|
137
|
+
const patched = async function create(request) {
|
|
138
|
+
const preset = presetForRequest(ctx, request)
|
|
139
|
+
const next = preset === undefined || !(await presetAvailable())
|
|
140
|
+
? request
|
|
141
|
+
: { ...request, agentPreset: preset }
|
|
142
|
+
return original.call(raw, next)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
Object.defineProperty(raw, 'create', {
|
|
147
|
+
configurable: true,
|
|
148
|
+
enumerable: false,
|
|
149
|
+
writable: true,
|
|
150
|
+
value: patched,
|
|
151
|
+
})
|
|
152
|
+
} catch {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dispose = () => {
|
|
157
|
+
try { delete raw.create } catch {}
|
|
158
|
+
try { delete raw[PATCHED] } catch {}
|
|
159
|
+
}
|
|
160
|
+
Object.defineProperty(raw, PATCHED, {
|
|
161
|
+
configurable: true,
|
|
162
|
+
enumerable: false,
|
|
163
|
+
writable: true,
|
|
164
|
+
value: dispose,
|
|
165
|
+
})
|
|
166
|
+
disposers.push(dispose)
|
|
167
|
+
installed = true
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const current = ctx.get('sessionController')
|
|
172
|
+
if (current) install(current)
|
|
173
|
+
else ctx.on('internal/service', (name, value) => {
|
|
174
|
+
if (name === 'sessionController') install(value)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
get installed() { return installed },
|
|
179
|
+
dispose() {
|
|
180
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
181
|
+
installed = false
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Present a Linux working directory to the model in WSL-preset sessions.
|
|
188
|
+
*
|
|
189
|
+
* The session's durable `cwd` stays the UNC path on purpose: Host features
|
|
190
|
+
* (diff view, `@` references, open-in-app, workspace grouping) resolve it
|
|
191
|
+
* through the Windows filesystem. But the Agent's tools all run in WSL and
|
|
192
|
+
* every path it writes is Linux, so showing `\\\\wsl.localhost\\...` as its
|
|
193
|
+
* `Current working directory` is the one remaining Windows-shaped input that
|
|
194
|
+
* makes the model emit Windows paths.
|
|
195
|
+
*
|
|
196
|
+
* Patch only `WorkingDirectoryService.contextText`, the model-facing string:
|
|
197
|
+
* `get`/`set`/the durable projection keep the UNC value, so no Host feature
|
|
198
|
+
* breaks. Only sessions whose recorded preset is `wsl` are rewritten.
|
|
199
|
+
*
|
|
200
|
+
* @returns `{ installed, dispose }`; `installed` is a live getter.
|
|
201
|
+
*/
|
|
202
|
+
export function installModelCwdTranslation(ctx) {
|
|
203
|
+
const disposers = []
|
|
204
|
+
let installed = false
|
|
205
|
+
let presetsService
|
|
206
|
+
|
|
207
|
+
const presetOf = (session) => {
|
|
208
|
+
try {
|
|
209
|
+
presetsService ??= ctx.get('agentPresets')
|
|
210
|
+
return presetsService?.presetForSession?.(session)
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const install = (service) => {
|
|
217
|
+
if (installed) return true
|
|
218
|
+
const raw = unwrap(service)
|
|
219
|
+
if (!raw || typeof raw.contextText !== 'function' || typeof raw.get !== 'function') return false
|
|
220
|
+
if (raw[CWD_PATCHED]) {
|
|
221
|
+
installed = true
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const original = raw.contextText
|
|
226
|
+
const patched = function contextText(session) {
|
|
227
|
+
const text = original.call(raw, session)
|
|
228
|
+
try {
|
|
229
|
+
if (presetOf(session) !== WSL_PRESET) return text
|
|
230
|
+
const linux = linuxPathFromUnc(raw.get(session))
|
|
231
|
+
if (linux === undefined) return text
|
|
232
|
+
return `Current working directory: ${JSON.stringify(linux)}.`
|
|
233
|
+
} catch {
|
|
234
|
+
return text
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
Object.defineProperty(raw, 'contextText', {
|
|
240
|
+
configurable: true,
|
|
241
|
+
enumerable: false,
|
|
242
|
+
writable: true,
|
|
243
|
+
value: patched,
|
|
244
|
+
})
|
|
245
|
+
} catch {
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const dispose = () => {
|
|
250
|
+
try { delete raw.contextText } catch {}
|
|
251
|
+
try { delete raw[CWD_PATCHED] } catch {}
|
|
252
|
+
}
|
|
253
|
+
Object.defineProperty(raw, CWD_PATCHED, {
|
|
254
|
+
configurable: true,
|
|
255
|
+
enumerable: false,
|
|
256
|
+
writable: true,
|
|
257
|
+
value: dispose,
|
|
258
|
+
})
|
|
259
|
+
disposers.push(dispose)
|
|
260
|
+
installed = true
|
|
261
|
+
return true
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const current = ctx.get('workingDirectory')
|
|
265
|
+
if (current) install(current)
|
|
266
|
+
else ctx.on('internal/service', (name, value) => {
|
|
267
|
+
if (name === 'workingDirectory') install(value)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
get installed() { return installed },
|
|
272
|
+
dispose() {
|
|
273
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
274
|
+
installed = false
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/registry.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// One broker per (distro, user), shared by the world and the panel.
|
|
2
|
+
// ESM module cache makes this a process-wide singleton for every row of this
|
|
3
|
+
// package, so the panel observes the very connections sessions use.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { WslBroker } from './broker.mjs'
|
|
6
|
+
|
|
7
|
+
const helperSource = readFileSync(new URL('./helper.mjs', import.meta.url), 'utf8')
|
|
8
|
+
|
|
9
|
+
class WslRegistry {
|
|
10
|
+
#brokers = new Map()
|
|
11
|
+
#idleMinutes = 15
|
|
12
|
+
|
|
13
|
+
set idleMinutes(minutes) {
|
|
14
|
+
const n = Number(minutes)
|
|
15
|
+
if (!Number.isFinite(n) || n < 0) return
|
|
16
|
+
this.#idleMinutes = n
|
|
17
|
+
for (const broker of this.#brokers.values()) broker.setIdleMs(n * 60_000)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get idleMinutes() { return this.#idleMinutes }
|
|
21
|
+
|
|
22
|
+
key(distro, user) { return `${distro}|${user}` }
|
|
23
|
+
|
|
24
|
+
get(distro = '', user = '') {
|
|
25
|
+
const key = this.key(distro, user)
|
|
26
|
+
let broker = this.#brokers.get(key)
|
|
27
|
+
if (!broker) {
|
|
28
|
+
broker = new WslBroker({ distro, user, idleMs: this.#idleMinutes * 60_000, helperSource })
|
|
29
|
+
broker.on('state', (s) => this.#log(`[${key}] ${s.state}`))
|
|
30
|
+
this.#brokers.set(key, broker)
|
|
31
|
+
}
|
|
32
|
+
return broker
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
peek(distro, user) { return this.#brokers.get(this.key(distro, user)) }
|
|
36
|
+
|
|
37
|
+
list() {
|
|
38
|
+
return [...this.#brokers.entries()].map(([key, broker]) => ({
|
|
39
|
+
key,
|
|
40
|
+
...broker.info,
|
|
41
|
+
hello: broker.hello
|
|
42
|
+
? { user: broker.hello.user, home: broker.hello.home, node: broker.hello.node, cwd: broker.hello.cwd }
|
|
43
|
+
: undefined,
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async disconnectAll() {
|
|
48
|
+
for (const broker of this.#brokers.values()) await broker.disconnect('shutdown').catch(() => {})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#log(message) {
|
|
52
|
+
try { process.stdout.write(`[dsh-wsl] ${message}\n`) } catch {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const registry = new WslRegistry()
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Small user settings store for the panel/doctor: per-distro login user and a
|
|
2
|
+
// default distro. Deliberately separate from the profile patch (the user owns
|
|
3
|
+
// that file); the plugin only reads/writes its own JSON under ~/.dsh.
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export const settingsFile = join(homedir(), '.dsh', 'remote-wsl-settings.json')
|
|
9
|
+
|
|
10
|
+
function normalize(raw) {
|
|
11
|
+
const idle = Number(raw?.idleDays)
|
|
12
|
+
return {
|
|
13
|
+
users: { ...(raw && typeof raw.users === 'object' && raw.users !== null ? raw.users : {}) },
|
|
14
|
+
defaultDistro: typeof raw?.defaultDistro === 'string' ? raw.defaultDistro : '',
|
|
15
|
+
idleDays: Number.isFinite(idle) && idle >= 0 ? idle : 5,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readSettings() {
|
|
20
|
+
try {
|
|
21
|
+
return normalize(JSON.parse(readFileSync(settingsFile, 'utf8')))
|
|
22
|
+
} catch {
|
|
23
|
+
return normalize({})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeSettings(patch = {}) {
|
|
28
|
+
const current = readSettings()
|
|
29
|
+
const next = normalize({
|
|
30
|
+
...current,
|
|
31
|
+
...patch,
|
|
32
|
+
users: { ...current.users, ...(patch.users ?? {}) },
|
|
33
|
+
})
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(dirname(settingsFile), { recursive: true })
|
|
36
|
+
const tmp = `${settingsFile}.tmp`
|
|
37
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
38
|
+
renameSync(tmp, settingsFile)
|
|
39
|
+
} catch {}
|
|
40
|
+
return next
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Per-distro login user; empty means the distribution default. */
|
|
44
|
+
export function userFor(distro) {
|
|
45
|
+
const settings = readSettings()
|
|
46
|
+
return settings.users[distro ?? ''] ?? settings.users[''] ?? ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function defaultDistro() {
|
|
50
|
+
return readSettings().defaultDistro
|
|
51
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// WSL execution world for the ctx.subprocess seam.
|
|
2
|
+
// Written as a factory so it can be tested without DSH; the plugin wraps it in
|
|
3
|
+
// a `SubprocessRuntime` subclass (see index.js).
|
|
4
|
+
import { spawn as nodeSpawn, execFile } from 'node:child_process'
|
|
5
|
+
import { appendFileSync } from 'node:fs'
|
|
6
|
+
import { Collector } from './collector.mjs'
|
|
7
|
+
import { parseWslUnc } from './broker.mjs'
|
|
8
|
+
import { buildArgv } from './launch.mjs'
|
|
9
|
+
|
|
10
|
+
const LOG = 'C:\\Users\\Yunson\\dsh-wsl-plugin\\wsl-world.log'
|
|
11
|
+
const DEBUG = process.env.DSH_WSL_DEBUG === '1'
|
|
12
|
+
const trace = (line) => { if (DEBUG) try { appendFileSync(LOG, `${new Date().toISOString()} sub: ${line}\n`) } catch {} }
|
|
13
|
+
|
|
14
|
+
const WSL_EXE = `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\wsl.exe`
|
|
15
|
+
|
|
16
|
+
/** Accept a Linux path, a `\\wsl.localhost\...` path, or a `C:\...` path. */
|
|
17
|
+
export function toLinuxCwd(cwd, distro) {
|
|
18
|
+
if (!cwd) return undefined
|
|
19
|
+
if (cwd.startsWith('/')) return cwd
|
|
20
|
+
const unc = parseWslUnc(cwd)
|
|
21
|
+
if (unc) {
|
|
22
|
+
if (unc.distro.toLowerCase() !== distro.toLowerCase()) {
|
|
23
|
+
throw Object.assign(new Error(`cwd 属于另一个发行版:${unc.distro}`), { code: 'FS_BAD_PATH' })
|
|
24
|
+
}
|
|
25
|
+
return unc.linuxPath
|
|
26
|
+
}
|
|
27
|
+
const drive = /^([A-Za-z]):[\\/](.*)$/.exec(cwd)
|
|
28
|
+
if (drive) return `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}`
|
|
29
|
+
return cwd
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function collect(mode) {
|
|
33
|
+
if (mode === 'pipe' || mode === 'inherit') return { kind: mode, collector: null }
|
|
34
|
+
if (mode && typeof mode.maxBytes === 'number') return { kind: 'collect', collector: new Collector(mode.maxBytes) }
|
|
35
|
+
throw Object.assign(new Error('subprocess-wsl: invalid stdio mode'), { code: 'SUBPROCESS_INVALID_SPEC' })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createWslSubprocess({ broker, distro, user = '', ptyDelegate, onExit } = {}) {
|
|
39
|
+
const live = new Set()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Everything travels inside ONE base64-encoded `-c` script (see launch.mjs):
|
|
43
|
+
* wsl.exe drops trailing arguments and pre-expands `$VAR` against the dirty
|
|
44
|
+
* target environment, so nothing else survives intact.
|
|
45
|
+
*/
|
|
46
|
+
function argvFor(spec, cwd) {
|
|
47
|
+
const args = buildArgv(spec, { distro, user, cwd, path: broker?.cleanPath })
|
|
48
|
+
trace(`spawn ${JSON.stringify(args)}`)
|
|
49
|
+
return args
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validate(spec) {
|
|
53
|
+
if (!spec || !Array.isArray(spec.argv) || spec.argv.length === 0) {
|
|
54
|
+
throw Object.assign(new Error('subprocess-wsl: argv must name a program'), { code: 'SUBPROCESS_INVALID_SPEC' })
|
|
55
|
+
}
|
|
56
|
+
if (spec.signal?.aborted) throw Object.assign(new Error('subprocess-wsl: aborted'), { code: 'SUBPROCESS_ABORTED' })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
/** @inheritdoc */
|
|
61
|
+
async resolveExecutable(command, env, signal) {
|
|
62
|
+
signal?.throwIfAborted()
|
|
63
|
+
if (!command) throw Object.assign(new Error('subprocess-wsl: empty command'), { code: 'SUBPROCESS_INVALID_SPEC' })
|
|
64
|
+
if (command.includes('/') && !command.startsWith('/')) {
|
|
65
|
+
throw Object.assign(new Error(`subprocess-wsl: relative path is ambiguous: ${command}`), { code: 'SUBPROCESS_INVALID_SPEC' })
|
|
66
|
+
}
|
|
67
|
+
const found = await broker.call('which', { command, env }, { signal })
|
|
68
|
+
if (!found?.path) {
|
|
69
|
+
throw Object.assign(new Error(`subprocess-wsl: command not found in ${distro}: ${command}`), { code: 'SUBPROCESS_EXECUTABLE_NOT_FOUND' })
|
|
70
|
+
}
|
|
71
|
+
return found.path
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/** @inheritdoc */
|
|
75
|
+
async terminalEnvironment(signal) {
|
|
76
|
+
signal?.throwIfAborted()
|
|
77
|
+
const shell = broker.hello?.shell ?? '/bin/bash'
|
|
78
|
+
return { platform: 'posix', defaultShell: shell }
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/** @inheritdoc */
|
|
82
|
+
spawn(spec) {
|
|
83
|
+
validate(spec)
|
|
84
|
+
const cwd = toLinuxCwd(spec.cwd, distro)
|
|
85
|
+
const stdinMode = spec.stdio.stdin
|
|
86
|
+
const out = collect(spec.stdio.stdout)
|
|
87
|
+
const err = collect(spec.stdio.stderr)
|
|
88
|
+
const stdio = [
|
|
89
|
+
stdinMode === 'pipe' ? 'pipe' : stdinMode === 'ignore' ? 'ignore' : 'pipe',
|
|
90
|
+
out.kind === 'inherit' ? 'inherit' : 'pipe',
|
|
91
|
+
err.kind === 'inherit' ? 'inherit' : 'pipe',
|
|
92
|
+
]
|
|
93
|
+
const child = nodeSpawn(WSL_EXE, argvFor(spec, cwd), { windowsHide: true, stdio })
|
|
94
|
+
if (out.kind === 'pipe' || err.kind === 'pipe') {
|
|
95
|
+
// Raw streams belong to the caller; nothing is buffered here.
|
|
96
|
+
}
|
|
97
|
+
if (out.collector) child.stdout.on('data', (d) => out.collector.write(d))
|
|
98
|
+
if (err.collector) child.stderr.on('data', (d) => err.collector.write(d))
|
|
99
|
+
if (typeof stdinMode === 'object' && stdinMode && typeof stdinMode.data === 'string') {
|
|
100
|
+
child.stdin.end(stdinMode.data)
|
|
101
|
+
}
|
|
102
|
+
if (spec.signal) {
|
|
103
|
+
const onAbort = () => { try { terminate() } catch {} }
|
|
104
|
+
spec.signal.addEventListener('abort', onAbort, { once: true })
|
|
105
|
+
child.once('close', () => spec.signal.removeEventListener('abort', onAbort))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let settled = false
|
|
109
|
+
let terminated = false
|
|
110
|
+
const done = new Promise((resolve, reject) => {
|
|
111
|
+
child.on('error', (error) => { if (!settled) { settled = true; reject(error) } })
|
|
112
|
+
child.on('close', (code, signal) => {
|
|
113
|
+
if (settled) return
|
|
114
|
+
settled = true
|
|
115
|
+
live.delete(handle)
|
|
116
|
+
onExit?.(handle)
|
|
117
|
+
resolve({ exitCode: code, signal: signal ?? null })
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function terminate() {
|
|
122
|
+
if (terminated || settled) return
|
|
123
|
+
terminated = true
|
|
124
|
+
if (process.platform === 'win32' && child.pid) {
|
|
125
|
+
// Kill the whole tree: wsl.exe plus the Windows-side relay.
|
|
126
|
+
execFile('taskkill', ['/T', '/F', '/PID', String(child.pid)], () => {})
|
|
127
|
+
} else {
|
|
128
|
+
try { child.kill('SIGKILL') } catch {}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const handle = {
|
|
133
|
+
stdin: child.stdin ?? undefined,
|
|
134
|
+
stdout: out.kind === 'pipe' ? child.stdout : undefined,
|
|
135
|
+
stderr: err.kind === 'pipe' ? child.stderr : undefined,
|
|
136
|
+
control: undefined,
|
|
137
|
+
collected: {
|
|
138
|
+
stdout: out.collector ? { readFrom: (o) => out.collector.readFrom(o) } : undefined,
|
|
139
|
+
stderr: err.collector ? { readFrom: (o) => err.collector.readFrom(o) } : undefined,
|
|
140
|
+
},
|
|
141
|
+
done,
|
|
142
|
+
terminate,
|
|
143
|
+
async waitForExit() { await done.catch(() => {}); return true },
|
|
144
|
+
}
|
|
145
|
+
live.add(handle)
|
|
146
|
+
done.catch(() => {}).finally(() => live.delete(handle))
|
|
147
|
+
return handle
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/** @inheritdoc */
|
|
151
|
+
async spawnTerminal(spec, delegate = ptyDelegate) {
|
|
152
|
+
if (!delegate) {
|
|
153
|
+
throw Object.assign(new Error('subprocess-wsl: no PTY delegate mounted; the sidebar terminal is unavailable'), { code: 'SUBPROCESS_UNSUPPORTED' })
|
|
154
|
+
}
|
|
155
|
+
const cwd = toLinuxCwd(spec.cwd, distro)
|
|
156
|
+
const args = ['-d', distro]
|
|
157
|
+
if (user) args.push('-u', user)
|
|
158
|
+
if (cwd) args.push('--cd', cwd)
|
|
159
|
+
args.push('--', ...spec.argv)
|
|
160
|
+
// Delegate to the host's real PTY: wsl.exe allocates the tty, the
|
|
161
|
+
// Linux-side bash inherits it. This is why the world needs the local
|
|
162
|
+
// provider captured before it is shadowed.
|
|
163
|
+
return delegate.spawnTerminal({
|
|
164
|
+
...spec,
|
|
165
|
+
argv: [WSL_EXE, ...args],
|
|
166
|
+
cwd: process.env.SystemRoot ?? 'C:\\Windows',
|
|
167
|
+
env: { ...(spec.env ?? {}) },
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** Terminate everything this provider still manages (service disposal). */
|
|
172
|
+
async dispose() {
|
|
173
|
+
for (const handle of [...live]) {
|
|
174
|
+
try { handle.terminate() } catch {}
|
|
175
|
+
}
|
|
176
|
+
for (const handle of [...live]) await handle.waitForExit().catch(() => {})
|
|
177
|
+
live.clear()
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|