@chronosun/dsh-wsl 0.0.0-stage → 1.5.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 +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
package/src/helper.mjs
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// Runs inside the distro. NDJSON over stdio; no network, no ports.
|
|
2
|
+
// Sanitises PATH (drops Windows interop entries) at startup — see PLAN §12.4.
|
|
3
|
+
const cleanPath = (process.env.PATH ?? '')
|
|
4
|
+
.split(':')
|
|
5
|
+
.filter((p) => p && !/^\/mnt\/[a-z](\/|$)/i.test(p))
|
|
6
|
+
.join(':')
|
|
7
|
+
process.env.PATH = cleanPath
|
|
8
|
+
|
|
9
|
+
import { createInterface } from 'node:readline'
|
|
10
|
+
import { promises as fsp } from 'node:fs'
|
|
11
|
+
import { execFile, spawn } from 'node:child_process'
|
|
12
|
+
import { promisify } from 'node:util'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile)
|
|
17
|
+
|
|
18
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
19
|
+
let jobs = new Map()
|
|
20
|
+
|
|
21
|
+
function hello() {
|
|
22
|
+
return {
|
|
23
|
+
v: 1,
|
|
24
|
+
pid: process.pid,
|
|
25
|
+
user: process.env.USER ?? process.env.LOGNAME ?? '',
|
|
26
|
+
home: process.env.HOME ?? '',
|
|
27
|
+
shell: process.env.SHELL || '/bin/bash',
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
path: process.env.PATH,
|
|
30
|
+
node: process.execPath,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function escapeRe(text) {
|
|
35
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal glob → RegExp: `**`, `*`, `?`, `[...]`, `{a,b}`. */
|
|
39
|
+
function globToRegExp(glob) {
|
|
40
|
+
let out = '^'
|
|
41
|
+
for (let i = 0; i < glob.length; i++) {
|
|
42
|
+
const c = glob[i]
|
|
43
|
+
if (c === '*') {
|
|
44
|
+
if (glob[i + 1] === '*') {
|
|
45
|
+
i += 1
|
|
46
|
+
if (glob[i + 1] === '/') { i += 1; out += '(?:.*/)?' } else out += '.*'
|
|
47
|
+
} else out += '[^/]*'
|
|
48
|
+
} else if (c === '?') {
|
|
49
|
+
out += '[^/]'
|
|
50
|
+
} else if (c === '[') {
|
|
51
|
+
const end = glob.indexOf(']', i + 1)
|
|
52
|
+
if (end === -1) out += '\\['
|
|
53
|
+
else { out += '[' + glob.slice(i + 1, end).replace(/^!/, '^') + ']'; i = end }
|
|
54
|
+
} else if (c === '{') {
|
|
55
|
+
const end = glob.indexOf('}', i + 1)
|
|
56
|
+
if (end === -1) out += '\\{'
|
|
57
|
+
else { out += '(?:' + glob.slice(i + 1, end).split(',').map(escapeRe).join('|') + ')'; i = end }
|
|
58
|
+
} else {
|
|
59
|
+
out += escapeRe(c)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return new RegExp(out + '$')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Depth-first file walk; VCS metadata and dependency trees are pruned. */
|
|
66
|
+
async function walk(root, visit) {
|
|
67
|
+
const stack = [root]
|
|
68
|
+
while (stack.length > 0) {
|
|
69
|
+
const dir = stack.pop()
|
|
70
|
+
let entries
|
|
71
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { continue }
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const full = path.join(dir, entry.name)
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue
|
|
76
|
+
stack.push(full)
|
|
77
|
+
} else if (entry.isFile()) {
|
|
78
|
+
if (visit(full) === false) return
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Files of a Git worktree, respecting `.gitignore` (the `--exclude-standard`
|
|
86
|
+
* set) and nested ignore files. Returns null outside a worktree, where the
|
|
87
|
+
* caller falls back to `walk` (which prunes `.git`/`node_modules`).
|
|
88
|
+
*/
|
|
89
|
+
async function listGitFiles(root) {
|
|
90
|
+
try {
|
|
91
|
+
const { stdout } = await execFileAsync('git', [
|
|
92
|
+
'-C', root,
|
|
93
|
+
'-c', 'core.quotepath=false',
|
|
94
|
+
'ls-files', '-z',
|
|
95
|
+
'--cached', '--others', '--exclude-standard',
|
|
96
|
+
], { maxBuffer: 64 * 1024 * 1024 })
|
|
97
|
+
return stdout.split('\0').filter(Boolean).map((rel) => path.join(root, rel))
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Prefer ripgrep's file walk when the distribution has it: it also honours
|
|
105
|
+
* `.ignore`, `.rgignore` and nested ignore files, which `git ls-files` does
|
|
106
|
+
* not. Falls back to the Git list.
|
|
107
|
+
*/
|
|
108
|
+
async function listFiles(root) {
|
|
109
|
+
try {
|
|
110
|
+
const { stdout } = await execFileAsync('rg', [
|
|
111
|
+
'--files', '--hidden', '-0',
|
|
112
|
+
'--glob', '!.git', '--glob', '!.git/**',
|
|
113
|
+
], { cwd: root, maxBuffer: 64 * 1024 * 1024 })
|
|
114
|
+
return stdout.split('\0').filter(Boolean).map((rel) => path.join(root, rel))
|
|
115
|
+
} catch {
|
|
116
|
+
return await listGitFiles(root)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Run one git command in a worktree with an optional private index. */
|
|
121
|
+
async function git(cwd, args, env) {
|
|
122
|
+
const { stdout } = await execFileAsync('git', ['-C', cwd, '-c', 'core.quotepath=false', ...args], {
|
|
123
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
124
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
125
|
+
})
|
|
126
|
+
return stdout.trim()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `git rev-parse --show-prefix` for a cwd below the repository root. */
|
|
130
|
+
async function gitPrefix(cwd) {
|
|
131
|
+
try {
|
|
132
|
+
return (await git(cwd, ['rev-parse', '--show-prefix'])).replace(/\r/g, '')
|
|
133
|
+
} catch {
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Snapshot the work tree's non-ignored state into a tree object. */
|
|
139
|
+
async function gitSnapshot(cwd) {
|
|
140
|
+
const dir = await fsp.mkdtemp(path.join(tmpdir(), 'dsh-wsl-git-'))
|
|
141
|
+
const index = path.join(dir, 'index')
|
|
142
|
+
const env = { GIT_INDEX_FILE: index }
|
|
143
|
+
try {
|
|
144
|
+
await git(cwd, ['read-tree', '--empty'], env)
|
|
145
|
+
await git(cwd, ['add', '-A', '--', '.', ':(exclude).agents/worktrees'], env)
|
|
146
|
+
const tree = await git(cwd, ['write-tree'], env)
|
|
147
|
+
return { tree }
|
|
148
|
+
} finally {
|
|
149
|
+
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** numstat between two trees, with paths relative to `cwd`. */
|
|
154
|
+
async function gitSummary(cwd, before, after) {
|
|
155
|
+
const prefix = await gitPrefix(cwd)
|
|
156
|
+
const raw = await git(cwd, ['diff-tree', '-r', '--no-commit-id', '--numstat', '--no-renames', before, after])
|
|
157
|
+
const files = []
|
|
158
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
159
|
+
const match = /^(\S+)\t(\S+)\t(.*)$/.exec(line)
|
|
160
|
+
if (!match) continue
|
|
161
|
+
let file = match[3]
|
|
162
|
+
if (prefix && file.startsWith(prefix)) file = file.slice(prefix.length)
|
|
163
|
+
const binary = match[1] === '-' && match[2] === '-'
|
|
164
|
+
files.push({ path: file, added: binary ? 0 : Number(match[1]) || 0, deleted: binary ? 0 : Number(match[2]) || 0, ...(binary ? { binary: true } : {}) })
|
|
165
|
+
}
|
|
166
|
+
return { files }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseUnifiedDiff(raw, pathValue, display, before, after, coarse) {
|
|
170
|
+
const hunks = []
|
|
171
|
+
let current = null
|
|
172
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
173
|
+
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
|
|
174
|
+
if (header) {
|
|
175
|
+
if (current) hunks.push(current)
|
|
176
|
+
current = {
|
|
177
|
+
oldStart: Number(header[1]),
|
|
178
|
+
oldLines: Number(header[2] ?? 1),
|
|
179
|
+
newStart: Number(header[3]),
|
|
180
|
+
newLines: Number(header[4] ?? 1),
|
|
181
|
+
lines: [],
|
|
182
|
+
}
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (!current) continue
|
|
186
|
+
if (line.startsWith('diff --git ') || line.startsWith('index ')) continue
|
|
187
|
+
if (line.startsWith('\\ No newline')) { current.lines.push(line); continue }
|
|
188
|
+
if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) current.lines.push(line)
|
|
189
|
+
}
|
|
190
|
+
if (current) hunks.push(current)
|
|
191
|
+
return { kind: 'text', path: pathValue, display, before, after, hunks, coarse }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One file's before/after comparison from two tree objects. */
|
|
195
|
+
async function gitDiffFile(cwd, before, after, file) {
|
|
196
|
+
const prefix = await gitPrefix(cwd)
|
|
197
|
+
const full = `${prefix}${file}`
|
|
198
|
+
const exists = async (tree) => {
|
|
199
|
+
try {
|
|
200
|
+
await git(cwd, ['cat-file', '-e', `${tree}:${full}`])
|
|
201
|
+
return true
|
|
202
|
+
} catch {
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const beforeExists = await exists(before)
|
|
207
|
+
const afterExists = await exists(after)
|
|
208
|
+
let size = 0
|
|
209
|
+
if (afterExists) {
|
|
210
|
+
try { size = Number(await git(cwd, ['cat-file', '-s', `${after}:${full}`])) || 0 } catch {}
|
|
211
|
+
}
|
|
212
|
+
if (size > 2 * 1024 * 1024) return { kind: 'oversized', path: file, display: file }
|
|
213
|
+
const raw = await git(cwd, ['diff', '--no-color', '--unified=3', before, after, '--', full])
|
|
214
|
+
if (/^Binary files /m.test(raw)) return { kind: 'binary', path: file, display: file }
|
|
215
|
+
return parseUnifiedDiff(raw, file, file, beforeExists, afterExists, false)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Directory basenames the `@` index never descends into. */
|
|
219
|
+
const INDEX_EXCLUDED = new Set([
|
|
220
|
+
'.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target',
|
|
221
|
+
'.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache',
|
|
222
|
+
'.mypy_cache', '.gradle',
|
|
223
|
+
])
|
|
224
|
+
|
|
225
|
+
function relativeParts(root, full) {
|
|
226
|
+
return full.slice(root.length + 1).replace(/\\/g, '/').split('/').filter(Boolean)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The bounded file+directory index used by `@` completion. In a Git worktree
|
|
231
|
+
* the file set is `git ls-files --exclude-standard` (`.gitignore` respected)
|
|
232
|
+
* and directories are derived from those paths; outside one it walks. Returns
|
|
233
|
+
* at most `max` entries, both with paths relative to `root`.
|
|
234
|
+
*/
|
|
235
|
+
async function listTree(root, max) {
|
|
236
|
+
const entries = new Map()
|
|
237
|
+
const files = await listFiles(root)
|
|
238
|
+
if (files !== null) {
|
|
239
|
+
for (const full of files.sort()) {
|
|
240
|
+
const parts = relativeParts(root, full)
|
|
241
|
+
if (parts.length === 0 || parts.some((part) => INDEX_EXCLUDED.has(part))) continue
|
|
242
|
+
entries.set(parts.join('/'), 'file')
|
|
243
|
+
for (let i = 1; i < parts.length; i += 1) entries.set(parts.slice(0, i).join('/'), 'directory')
|
|
244
|
+
if (entries.size >= max) break
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
const stack = [{ abs: root, rel: '' }]
|
|
248
|
+
while (stack.length > 0 && entries.size < max) {
|
|
249
|
+
const current = stack.pop()
|
|
250
|
+
let children
|
|
251
|
+
try { children = await fsp.readdir(current.abs, { withFileTypes: true }) } catch { continue }
|
|
252
|
+
for (const child of children) {
|
|
253
|
+
const rel = current.rel === '' ? child.name : `${current.rel}/${child.name}`
|
|
254
|
+
if (child.isDirectory()) {
|
|
255
|
+
if (INDEX_EXCLUDED.has(child.name)) continue
|
|
256
|
+
entries.set(rel, 'directory')
|
|
257
|
+
stack.push({ abs: path.join(current.abs, child.name), rel })
|
|
258
|
+
} else if (child.isFile()) {
|
|
259
|
+
entries.set(rel, 'file')
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { entries: [...entries].map(([entryPath, kind]) => ({ path: entryPath, kind })) }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function handle(req) {
|
|
268
|
+
switch (req.op) {
|
|
269
|
+
case 'ping':
|
|
270
|
+
return { pong: true, pid: process.pid, cwd: process.cwd() }
|
|
271
|
+
case 'stat': {
|
|
272
|
+
const s = await fsp.stat(req.path)
|
|
273
|
+
return { type: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other', size: s.size, mtime: s.mtimeMs, mode: (s.mode & 0o777).toString(8) }
|
|
274
|
+
}
|
|
275
|
+
case 'lstat': {
|
|
276
|
+
try {
|
|
277
|
+
const s = await fsp.lstat(req.path)
|
|
278
|
+
return {
|
|
279
|
+
type: s.isSymbolicLink() ? 'symlink' : s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other',
|
|
280
|
+
size: s.size,
|
|
281
|
+
mtime: s.mtimeMs,
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error.code === 'ENOENT') return { absent: true }
|
|
285
|
+
throw error
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
case 'realpath': {
|
|
289
|
+
try { return { path: await fsp.realpath(req.path) } } catch (error) {
|
|
290
|
+
if (error.code === 'ENOENT') return { path: null }
|
|
291
|
+
throw error
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
case 'gitSnapshot': {
|
|
295
|
+
return await gitSnapshot(req.cwd)
|
|
296
|
+
}
|
|
297
|
+
case 'gitSummary': {
|
|
298
|
+
return await gitSummary(req.cwd, req.before, req.after)
|
|
299
|
+
}
|
|
300
|
+
case 'gitDiffFile': {
|
|
301
|
+
return await gitDiffFile(req.cwd, req.before, req.after, req.path)
|
|
302
|
+
}
|
|
303
|
+
case 'tree': {
|
|
304
|
+
const { path: root, max = 50_000 } = req
|
|
305
|
+
return await listTree(root, max)
|
|
306
|
+
}
|
|
307
|
+
case 'glob': {
|
|
308
|
+
const { path: root, pattern = '**/*', max = 1000 } = req
|
|
309
|
+
const re = globToRegExp(pattern)
|
|
310
|
+
const base = root.replace(/\/+$/, '')
|
|
311
|
+
const found = []
|
|
312
|
+
let truncated = false
|
|
313
|
+
const visit = (file) => {
|
|
314
|
+
const rel = file.slice(base.length + 1)
|
|
315
|
+
const name = path.basename(file)
|
|
316
|
+
if (re.test(rel) || re.test(name)) {
|
|
317
|
+
found.push(file)
|
|
318
|
+
if (found.length >= max) { truncated = true; return false }
|
|
319
|
+
}
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
const gitFiles = await listFiles(root)
|
|
323
|
+
if (gitFiles === null) await walk(root, visit)
|
|
324
|
+
else for (const file of gitFiles.sort()) { if (visit(file) === false) break }
|
|
325
|
+
found.sort()
|
|
326
|
+
return { paths: found, truncated, total: found.length }
|
|
327
|
+
}
|
|
328
|
+
case 'grep': {
|
|
329
|
+
const { path: root, pattern, include, max = 250 } = req
|
|
330
|
+
let re
|
|
331
|
+
try { re = new RegExp(pattern) } catch (error) {
|
|
332
|
+
throw Object.assign(new Error(`invalid regular expression: ${error.message}`), { code: 'FS_BAD_PATTERN' })
|
|
333
|
+
}
|
|
334
|
+
const includeRe = include ? globToRegExp(include) : null
|
|
335
|
+
const files = []
|
|
336
|
+
const addFile = (file) => {
|
|
337
|
+
if (includeRe && !includeRe.test(path.basename(file))) return true
|
|
338
|
+
files.push(file)
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
const gitFiles = await listFiles(root)
|
|
342
|
+
if (gitFiles === null) await walk(root, addFile)
|
|
343
|
+
else for (const file of gitFiles.sort()) addFile(file)
|
|
344
|
+
const matches = []
|
|
345
|
+
let seen = 0
|
|
346
|
+
let truncated = false
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
let text
|
|
349
|
+
try {
|
|
350
|
+
const info = await fsp.stat(file)
|
|
351
|
+
if (info.size > 2 * 1024 * 1024) continue
|
|
352
|
+
text = await fsp.readFile(file, 'utf8')
|
|
353
|
+
} catch { continue }
|
|
354
|
+
if (text.includes('\u0000')) continue // binary
|
|
355
|
+
const lines = text.split('\n')
|
|
356
|
+
const hits = []
|
|
357
|
+
for (let i = 0; i < lines.length; i++) {
|
|
358
|
+
if (!re.test(lines[i])) continue
|
|
359
|
+
hits.push({ lineNumber: i + 1, line: lines[i].slice(0, 2000) })
|
|
360
|
+
seen += 1
|
|
361
|
+
if (seen >= max) { truncated = true; break }
|
|
362
|
+
}
|
|
363
|
+
if (hits.length > 0) matches.push({ path: file, matches: hits })
|
|
364
|
+
if (truncated) break
|
|
365
|
+
}
|
|
366
|
+
return { files: matches, truncated, total: seen }
|
|
367
|
+
}
|
|
368
|
+
case 'rm': {
|
|
369
|
+
await fsp.unlink(req.path)
|
|
370
|
+
return { removed: true }
|
|
371
|
+
}
|
|
372
|
+
case 'readBytes': {
|
|
373
|
+
const buf = await fsp.readFile(req.path)
|
|
374
|
+
const cap = req.maxBytes ?? 0
|
|
375
|
+
if (cap > 0 && buf.length > cap) {
|
|
376
|
+
throw Object.assign(new Error(`file is larger than ${cap} bytes`), { code: 'FS_TOO_LARGE' })
|
|
377
|
+
}
|
|
378
|
+
return { base64: buf.toString('base64'), bytes: buf.length }
|
|
379
|
+
}
|
|
380
|
+
case 'readRange': {
|
|
381
|
+
const handle = await fsp.open(req.path, 'r')
|
|
382
|
+
try {
|
|
383
|
+
const buf = Buffer.alloc(req.length)
|
|
384
|
+
const { bytesRead } = await handle.read(buf, 0, req.length, req.offset)
|
|
385
|
+
return { base64: buf.subarray(0, bytesRead).toString('base64'), bytes: bytesRead }
|
|
386
|
+
} finally {
|
|
387
|
+
await handle.close()
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
case 'read':
|
|
391
|
+
return { text: await fsp.readFile(req.path, 'utf8') }
|
|
392
|
+
case 'list':
|
|
393
|
+
return { entries: (await fsp.readdir(req.path, { withFileTypes: true })).map((e) => ({ name: e.name, dir: e.isDirectory(), link: e.isSymbolicLink() })) }
|
|
394
|
+
case 'write': {
|
|
395
|
+
const dir = path.dirname(req.path)
|
|
396
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
397
|
+
const tmp = path.join(dir, `.dsh-tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
398
|
+
const mode = req.mode ? parseInt(req.mode, 8) : undefined
|
|
399
|
+
try {
|
|
400
|
+
if (mode) await fsp.writeFile(tmp, req.text, { encoding: 'utf8', mode })
|
|
401
|
+
else await fsp.writeFile(tmp, req.text, 'utf8')
|
|
402
|
+
await fsp.rename(tmp, req.path)
|
|
403
|
+
} catch (error) {
|
|
404
|
+
await fsp.rm(tmp, { force: true }).catch(() => {})
|
|
405
|
+
throw error
|
|
406
|
+
}
|
|
407
|
+
return { bytes: Buffer.byteLength(req.text) }
|
|
408
|
+
}
|
|
409
|
+
case 'exec': {
|
|
410
|
+
// Bounded one-shot; the plugin's subprocess provider drives longer work.
|
|
411
|
+
return await new Promise((resolve) => {
|
|
412
|
+
const child = spawn(req.argv[0], req.argv.slice(1), { cwd: req.cwd, env: { ...process.env, ...(req.env ?? {}) } })
|
|
413
|
+
let out = '', err = '', truncated = false
|
|
414
|
+
const cap = req.maxBytes ?? 1 << 20
|
|
415
|
+
child.stdout.on('data', (d) => { if (out.length < cap) out += d.toString('utf8'); else truncated = true })
|
|
416
|
+
child.stderr.on('data', (d) => { if (err.length < cap) err += d.toString('utf8'); else truncated = true })
|
|
417
|
+
child.on('error', (e) => resolve({ code: -1, out, err: err + String(e), truncated }))
|
|
418
|
+
child.on('close', (code, signal) => resolve({ code, signal, out, err, truncated }))
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
case 'which': {
|
|
422
|
+
const { command } = req
|
|
423
|
+
const searchPath = (req.env?.PATH ?? process.env.PATH ?? '').split(':').filter(Boolean)
|
|
424
|
+
const candidates = command.startsWith('/')
|
|
425
|
+
? [command]
|
|
426
|
+
: searchPath.map((dir) => path.join(dir, command))
|
|
427
|
+
for (const candidate of candidates) {
|
|
428
|
+
if (/^\/mnt\/[a-z]\//i.test(candidate)) continue // interop binary: never hand it out
|
|
429
|
+
try {
|
|
430
|
+
await fsp.access(candidate, (await import('node:fs')).constants.X_OK)
|
|
431
|
+
const real = await fsp.realpath(candidate).catch(() => candidate)
|
|
432
|
+
return { path: candidate, real }
|
|
433
|
+
} catch {}
|
|
434
|
+
}
|
|
435
|
+
return { path: null }
|
|
436
|
+
}
|
|
437
|
+
case 'shutdown':
|
|
438
|
+
process.stdout.write(JSON.stringify({ id: req.id, result: { bye: true } }) + '\n', () => process.exit(0))
|
|
439
|
+
return undefined
|
|
440
|
+
default:
|
|
441
|
+
throw Object.assign(new Error(`unknown op: ${req.op}`), { code: 'EUNKNOWNOP' })
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
rl.on('line', (line) => {
|
|
446
|
+
if (!line.trim()) return
|
|
447
|
+
let req
|
|
448
|
+
try { req = JSON.parse(line) } catch { return }
|
|
449
|
+
handle(req).then(
|
|
450
|
+
(result) => { if (result !== undefined) process.stdout.write(JSON.stringify({ id: req.id, result }) + '\n') },
|
|
451
|
+
(error) => process.stdout.write(JSON.stringify({ id: req.id, error: { code: error.code ?? 'EFAIL', message: String(error.message ?? error) } }) + '\n'),
|
|
452
|
+
)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
process.stdout.write(JSON.stringify({ event: 'hello', result: hello() }) + '\n')
|
package/src/launch.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Builds the wsl.exe argv. Pure functions, no DSH dependency, unit-testable.
|
|
2
|
+
//
|
|
3
|
+
// Two hard-won constraints:
|
|
4
|
+
//
|
|
5
|
+
// 1. `wsl.exe` silently drops the arguments that follow a command
|
|
6
|
+
// (`sh -c 'echo $#' sh a b c` answers 0), so nothing may travel as a
|
|
7
|
+
// trailing argument — everything goes inside the single `-c` script.
|
|
8
|
+
// 2. `wsl.exe` expands `$VAR` in its command line against the *target* Linux
|
|
9
|
+
// environment before the script runs. A bare `$PATH` therefore arrives as
|
|
10
|
+
// the dirty default PATH, whose `Program Files (x86)` breaks the second
|
|
11
|
+
// parse. The script is base64-encoded so no `$` ever appears in argv.
|
|
12
|
+
|
|
13
|
+
export const CLEAN_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
14
|
+
|
|
15
|
+
// Used before the broker has resolved the distribution's login PATH: keep the
|
|
16
|
+
// usual Linux system dirs plus the user-space toolchain locations, but never
|
|
17
|
+
// `/mnt/*` (Windows interop binaries must stay unreachable).
|
|
18
|
+
const FALLBACK_PATH_EXPR = `"$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.hermes/node/bin:${CLEAN_PATH}"`
|
|
19
|
+
|
|
20
|
+
function shellQuote(value) {
|
|
21
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A `bash -c <script>` request keeps its command; anything else is exec'd. */
|
|
25
|
+
export function commandBody(argv) {
|
|
26
|
+
if (argv.length >= 3 && /(^|\/)bash$/.test(argv[0]) && argv[1] === '-c') return String(argv[2])
|
|
27
|
+
return 'exec ' + argv.map(shellQuote).join(' ')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildScript(spec, { nonce = Date.now().toString(36), path } = {}) {
|
|
31
|
+
const env = Object.entries(spec.env ?? {})
|
|
32
|
+
.filter(([, v]) => v !== undefined)
|
|
33
|
+
.map(([k, v]) => `export ${k}=${shellQuote(v)}; `)
|
|
34
|
+
.join('')
|
|
35
|
+
const body = commandBody(spec.argv)
|
|
36
|
+
const encoded = Buffer.from(body, 'utf8').toString('base64')
|
|
37
|
+
const file = `/tmp/.dsh-wsl-${nonce}.sh`
|
|
38
|
+
// `path` is the distribution's login PATH with `/mnt/*` removed, resolved by
|
|
39
|
+
// the broker. Until it exists, the fallback keeps user toolchains
|
|
40
|
+
// (`~/.local/bin`, cargo, …) reachable while still excluding Windows.
|
|
41
|
+
const pathExpr = path ? shellQuote(path) : FALLBACK_PATH_EXPR
|
|
42
|
+
// No `$` anywhere in argv: the script is base64-decoded inside WSL, so `$`
|
|
43
|
+
// in FALLBACK_PATH_EXPR expands there, never in wsl.exe's own command line.
|
|
44
|
+
return `PATH=${pathExpr}; export PATH; ${env}trap 'rm -f ${file}' EXIT; printf %s ${encoded} | base64 -d > ${file}; /bin/bash ${file}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildArgv(spec, { distro, user = '', cwd, path, nonce } = {}) {
|
|
48
|
+
const args = []
|
|
49
|
+
if (distro) args.push('-d', distro)
|
|
50
|
+
if (user) args.push('-u', user)
|
|
51
|
+
if (cwd) args.push('--cd', cwd)
|
|
52
|
+
args.push('--', '/bin/bash', '-c', buildScript(spec, { nonce, path }))
|
|
53
|
+
return args
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace under `/home/<user>` (or `/root`) names its Linux user; that is
|
|
3
|
+
* the identity the broker/terminal/session should use, independent of the
|
|
4
|
+
* distro's default user. Kept in its own module so live upgrades cannot hit an
|
|
5
|
+
* older cached `settings.mjs` without the named export.
|
|
6
|
+
*/
|
|
7
|
+
export function userFromLinuxPath(linuxPath) {
|
|
8
|
+
const value = String(linuxPath ?? '')
|
|
9
|
+
const home = /^\/home\/([^/]+)(?:\/|$)/.exec(value)
|
|
10
|
+
if (home) return home[1]
|
|
11
|
+
if (/^\/root(?:\/|$)/.test(value)) return 'root'
|
|
12
|
+
return ''
|
|
13
|
+
}
|