@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/fs.mjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// `ctx.fs` backed by WSL distributions, so the model's read/write/edit work on
|
|
2
|
+
// Linux paths and Linux permissions instead of the Windows UNC share.
|
|
3
|
+
//
|
|
4
|
+
// Multi-distro: every target key carries its distribution (`wsl:<distro>|<path>`),
|
|
5
|
+
// so one provider can serve workspaces from several distributions at once.
|
|
6
|
+
//
|
|
7
|
+
// Written as a factory (no DSH imports) so it can be tested on its own.
|
|
8
|
+
import { posix } from 'node:path'
|
|
9
|
+
|
|
10
|
+
function fail(code, message, extra) {
|
|
11
|
+
return Object.assign(new Error(message), { code, ...extra })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Version = mtime+size; enough to detect a stale edit without hashing. */
|
|
15
|
+
function versionOf(info) {
|
|
16
|
+
return `${Math.floor(info.mtime)}-${info.size ?? 0}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hostPathToLinux(value) {
|
|
20
|
+
const raw = String(value ?? '')
|
|
21
|
+
if (raw.startsWith('/')) return raw
|
|
22
|
+
const unc = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\(.*)$/i.exec(raw)
|
|
23
|
+
if (unc) return `/${unc[2].replace(/\\/g, '/')}`
|
|
24
|
+
const drive = /^([A-Za-z]):[\\/](.*)$/.exec(raw)
|
|
25
|
+
if (drive) return `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}`
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function distroFromHostPath(value) {
|
|
30
|
+
const raw = String(value ?? '')
|
|
31
|
+
const unc = /^\\\\[?]?(?:wsl\.localhost|wsl\$)\\([^\\]+)\\/i.exec(raw)
|
|
32
|
+
return unc ? unc[1] : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Apply ordered hunks to text, preserving its line-ending style. */
|
|
36
|
+
export function applyHunks(text, hunks) {
|
|
37
|
+
const crlf = text.includes('\r\n')
|
|
38
|
+
const eol = crlf ? '\r\n' : '\n'
|
|
39
|
+
const lines = text.split(/\r?\n/)
|
|
40
|
+
const ordered = [...hunks].sort((a, b) => b.lineNumber - a.lineNumber)
|
|
41
|
+
for (const hunk of ordered) {
|
|
42
|
+
const index = hunk.lineNumber - 1
|
|
43
|
+
const oldLines = hunk.oldLines ?? []
|
|
44
|
+
const newLines = hunk.newLines ?? []
|
|
45
|
+
const actual = lines.slice(index, index + oldLines.length)
|
|
46
|
+
if (actual.join('\n') !== oldLines.join('\n')) {
|
|
47
|
+
throw fail('FS_PATCH_MISMATCH', `hunk at line ${hunk.lineNumber} does not match the current content`)
|
|
48
|
+
}
|
|
49
|
+
lines.splice(index, oldLines.length, ...newLines)
|
|
50
|
+
}
|
|
51
|
+
return lines.join(eol)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param brokerFor (distro) => broker — one persistent connection per distro
|
|
56
|
+
* @param defaultDistro used when a call carries no distribution (empty = WSL default)
|
|
57
|
+
*/
|
|
58
|
+
export function createWslFileSystem({ brokerFor, defaultDistro = '', defaultCwd = '/' }) {
|
|
59
|
+
const brokerOf = (distro) => brokerFor(distro || defaultDistro)
|
|
60
|
+
|
|
61
|
+
const target = (distro, path) => ({
|
|
62
|
+
targetKey: `wsl:${distro}|${path}`,
|
|
63
|
+
displayPath: path,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const parse = (t) => {
|
|
67
|
+
const raw = String(t?.targetKey ?? '')
|
|
68
|
+
const body = raw.startsWith('wsl:') ? raw.slice(4) : raw
|
|
69
|
+
const at = body.indexOf('|')
|
|
70
|
+
if (at < 0) return { distro: defaultDistro, path: t?.displayPath ?? body }
|
|
71
|
+
return { distro: body.slice(0, at), path: body.slice(at + 1) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const normalize = (path, base = defaultCwd) => {
|
|
75
|
+
if (!path) return base
|
|
76
|
+
const joined = path.startsWith('/') ? path : posix.join(base, path)
|
|
77
|
+
return posix.normalize(joined).replace(/\/+$/, '') || '/'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const statOf = async (path, distro) => {
|
|
81
|
+
try {
|
|
82
|
+
return await brokerOf(distro).call('stat', { path })
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code === 'ENOENT') return null
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
/** @inheritdoc */
|
|
91
|
+
async resolve(path, opts = {}) {
|
|
92
|
+
opts.signal?.throwIfAborted()
|
|
93
|
+
const distro = distroFromHostPath(opts.cwd) ?? defaultDistro
|
|
94
|
+
const base = hostPathToLinux(opts.cwd) ?? defaultCwd
|
|
95
|
+
const lexical = normalize(String(path ?? ''), base)
|
|
96
|
+
const real = await brokerOf(distro).call('realpath', { path: lexical }).catch(() => ({ path: null }))
|
|
97
|
+
return target(distro, real?.path ?? lexical)
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
processPath(t) {
|
|
101
|
+
return parse(t).path
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
processPathFromHostPath(hostPath) {
|
|
105
|
+
return hostPathToLinux(hostPath)
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
fileUrl(t) {
|
|
109
|
+
return 'file://' + parse(t).path.split('/').map((s) => encodeURIComponent(s)).join('/')
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
contains(parent, child) {
|
|
113
|
+
const p = parse(parent).path.replace(/\/+$/, '') || '/'
|
|
114
|
+
const c = parse(child).path.replace(/\/+$/, '') || '/'
|
|
115
|
+
if (p === '/') return true
|
|
116
|
+
return c === p || c.startsWith(p + '/')
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** @inheritdoc */
|
|
120
|
+
async stat(t, signal) {
|
|
121
|
+
signal?.throwIfAborted()
|
|
122
|
+
const { distro, path } = parse(t)
|
|
123
|
+
const info = await statOf(path, distro)
|
|
124
|
+
if (!info) return undefined
|
|
125
|
+
return { version: versionOf(info), type: info.type, size: info.size }
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/** @inheritdoc */
|
|
129
|
+
async lstat(path, opts = {}, signal) {
|
|
130
|
+
signal?.throwIfAborted()
|
|
131
|
+
const distro = distroFromHostPath(opts.cwd) ?? defaultDistro
|
|
132
|
+
const base = hostPathToLinux(opts.cwd) ?? defaultCwd
|
|
133
|
+
const lexical = normalize(path, base)
|
|
134
|
+
const info = await brokerOf(distro).call('lstat', { path: lexical })
|
|
135
|
+
if (info.absent) return undefined
|
|
136
|
+
return { version: versionOf(info), type: info.type, size: info.size }
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/** @inheritdoc */
|
|
140
|
+
async readText(t, signal) {
|
|
141
|
+
signal?.throwIfAborted()
|
|
142
|
+
const { distro, path } = parse(t)
|
|
143
|
+
try {
|
|
144
|
+
return (await brokerOf(distro).call('read', { path })).text
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error.code === 'ENOENT') throw fail('FS_NOT_FOUND', `no such file: ${path}`)
|
|
147
|
+
if (error.code === 'EISDIR') throw fail('FS_NOT_REGULAR_FILE', `not a regular file: ${path}`)
|
|
148
|
+
throw error
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/** @inheritdoc */
|
|
153
|
+
async *streamText(t, signal) {
|
|
154
|
+
const text = await this.readText(t, signal)
|
|
155
|
+
for (let at = 0; at < text.length; at += 64 * 1024) {
|
|
156
|
+
signal?.throwIfAborted()
|
|
157
|
+
yield text.slice(at, at + 64 * 1024)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** @inheritdoc */
|
|
162
|
+
async readBytes(t, _signal, maxBytes) {
|
|
163
|
+
const { distro, path } = parse(t)
|
|
164
|
+
const result = await brokerOf(distro).call('readBytes', { path, maxBytes })
|
|
165
|
+
return Uint8Array.from(Buffer.from(result.base64, 'base64'))
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** @inheritdoc */
|
|
169
|
+
async readByteRange(t, range, signal) {
|
|
170
|
+
signal?.throwIfAborted()
|
|
171
|
+
const { distro, path } = parse(t)
|
|
172
|
+
const result = await brokerOf(distro).call('readRange', { path, offset: range.offset, length: range.length })
|
|
173
|
+
return Uint8Array.from(Buffer.from(result.base64, 'base64'))
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/** @inheritdoc */
|
|
177
|
+
async listDir(t, signal) {
|
|
178
|
+
signal?.throwIfAborted()
|
|
179
|
+
const { distro, path } = parse(t)
|
|
180
|
+
const listing = await brokerOf(distro).call('list', { path })
|
|
181
|
+
const entries = listing.entries.map((entry) => ({
|
|
182
|
+
name: entry.name,
|
|
183
|
+
type: entry.dir ? 'directory' : entry.link ? 'other' : 'file',
|
|
184
|
+
target: target(distro, posix.join(path, entry.name)),
|
|
185
|
+
}))
|
|
186
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/** @inheritdoc */
|
|
190
|
+
async writeText(t, content, expected, signal) {
|
|
191
|
+
signal?.throwIfAborted()
|
|
192
|
+
const { distro, path } = parse(t)
|
|
193
|
+
const broker = brokerOf(distro)
|
|
194
|
+
const before = await statOf(path, distro)
|
|
195
|
+
if (expected?.kind === 'createIfAbsent' && before) {
|
|
196
|
+
throw fail('FS_EXISTS', `file already exists: ${path}`)
|
|
197
|
+
}
|
|
198
|
+
if (expected?.kind === 'replaceIfVersion') {
|
|
199
|
+
if (!before) throw fail('FS_STALE_VERSION', `file disappeared: ${path}`)
|
|
200
|
+
if (versionOf(before) !== expected.version) {
|
|
201
|
+
throw fail('FS_STALE_VERSION', `file changed since it was read: ${path}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const previous = before ? await this.readText(t, signal).catch(() => null) : null
|
|
205
|
+
await broker.call('write', { path, text: content })
|
|
206
|
+
const after = await statOf(path, distro)
|
|
207
|
+
return {
|
|
208
|
+
operation: before ? 'update' : 'create',
|
|
209
|
+
version: after ? versionOf(after) : '0-0',
|
|
210
|
+
before: previous,
|
|
211
|
+
after: content,
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/** @inheritdoc */
|
|
216
|
+
async editText(t, edit, expected, signal) {
|
|
217
|
+
signal?.throwIfAborted()
|
|
218
|
+
const { distro, path } = parse(t)
|
|
219
|
+
const before = await statOf(path, distro)
|
|
220
|
+
if (!before) throw fail('FS_NOT_FOUND', `no such file: ${path}`)
|
|
221
|
+
if (expected?.version && versionOf(before) !== expected.version) {
|
|
222
|
+
throw fail('FS_STALE_VERSION', `file changed since it was read: ${path}`)
|
|
223
|
+
}
|
|
224
|
+
const current = await this.readText(t, signal)
|
|
225
|
+
const { oldString, newString, replaceAll = false } = edit
|
|
226
|
+
const occurrences = oldString === '' ? 0 : current.split(oldString).length - 1
|
|
227
|
+
if (occurrences === 0) throw fail('FS_EDIT_NO_MATCH', `oldString not found in ${path}`)
|
|
228
|
+
if (occurrences > 1 && !replaceAll) {
|
|
229
|
+
throw fail('FS_EDIT_NOT_UNIQUE', `oldString appears ${occurrences} times in ${path}`)
|
|
230
|
+
}
|
|
231
|
+
const after = replaceAll ? current.split(oldString).join(newString) : current.replace(oldString, newString)
|
|
232
|
+
await brokerOf(distro).call('write', { path, text: after })
|
|
233
|
+
const info = await statOf(path, distro)
|
|
234
|
+
return { version: info ? versionOf(info) : '0-0', before: current, after }
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
/** @inheritdoc */
|
|
238
|
+
async patchText(t, hunks, expected, signal) {
|
|
239
|
+
signal?.throwIfAborted()
|
|
240
|
+
const { distro, path } = parse(t)
|
|
241
|
+
const before = await statOf(path, distro)
|
|
242
|
+
if (!before) throw fail('FS_STALE_VERSION', `file disappeared before patching: ${path}`)
|
|
243
|
+
if (expected?.version && versionOf(before) !== expected.version) {
|
|
244
|
+
throw fail('FS_STALE_VERSION', `file changed since it was read: ${path}`)
|
|
245
|
+
}
|
|
246
|
+
const current = await this.readText(t, signal)
|
|
247
|
+
const after = applyHunks(current, hunks)
|
|
248
|
+
await brokerOf(distro).call('write', { path, text: after })
|
|
249
|
+
const info = await statOf(path, distro)
|
|
250
|
+
return { version: info ? versionOf(info) : '0-0', before: current, after }
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
/** @inheritdoc */
|
|
254
|
+
async deleteFile(t, expected, signal) {
|
|
255
|
+
signal?.throwIfAborted()
|
|
256
|
+
const { distro, path } = parse(t)
|
|
257
|
+
const info = await statOf(path, distro)
|
|
258
|
+
if (!info) throw fail('FS_NOT_FOUND', `no such file: ${path}`)
|
|
259
|
+
if (info.type !== 'file') throw fail('FS_NOT_REGULAR_FILE', `not a regular file: ${path}`)
|
|
260
|
+
if (expected?.version && versionOf(info) !== expected.version) {
|
|
261
|
+
throw fail('FS_STALE_VERSION', `file changed since it was read: ${path}`)
|
|
262
|
+
}
|
|
263
|
+
const before = await this.readText(t, signal).catch(() => null)
|
|
264
|
+
await brokerOf(distro).call('rm', { path })
|
|
265
|
+
return { before }
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
/** @inheritdoc */
|
|
269
|
+
async moveFile(source, destination, options = {}, signal) {
|
|
270
|
+
signal?.throwIfAborted()
|
|
271
|
+
const from = parse(source)
|
|
272
|
+
const to = parse(destination)
|
|
273
|
+
if (from.distro === to.distro && from.path === to.path) throw fail('FS_SAME_TARGET', 'source and destination are the same file')
|
|
274
|
+
|
|
275
|
+
const beforeText = await this.readText(source, signal)
|
|
276
|
+
const text = options.hunks?.length ? applyHunks(beforeText, options.hunks) : beforeText
|
|
277
|
+
|
|
278
|
+
const destStat = await statOf(to.path, to.distro)
|
|
279
|
+
const guard = options.expectedDestination
|
|
280
|
+
if (guard?.kind === 'createIfAbsent' && destStat) throw fail('FS_EXISTS', `destination exists: ${to.path}`)
|
|
281
|
+
if (guard?.kind === 'replaceIfVersion') {
|
|
282
|
+
if (!destStat) throw fail('FS_STALE_VERSION', `destination disappeared: ${to.path}`)
|
|
283
|
+
if (versionOf(destStat) !== guard.version) throw fail('FS_STALE_VERSION', `destination changed: ${to.path}`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await brokerOf(to.distro).call('write', { path: to.path, text })
|
|
287
|
+
const destInfo = await statOf(to.path, to.distro)
|
|
288
|
+
const destinationOutcome = {
|
|
289
|
+
operation: destStat ? 'update' : 'create',
|
|
290
|
+
version: destInfo ? versionOf(destInfo) : '0-0',
|
|
291
|
+
before: destStat ? await this.readText(destination, signal).catch(() => null) : null,
|
|
292
|
+
after: text,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
await brokerOf(from.distro).call('rm', { path: from.path })
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return {
|
|
299
|
+
sourceBefore: beforeText,
|
|
300
|
+
destination: destinationOutcome,
|
|
301
|
+
sourceDeleted: false,
|
|
302
|
+
sourceFailure: { code: error.code ?? 'FS_MOVE_SOURCE', message: String(error.message ?? error) },
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { sourceBefore: beforeText, destination: destinationOutcome, sourceDeleted: true }
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
async watch() {
|
|
309
|
+
throw fail('FS_UNSUPPORTED', 'the WSL filesystem exposes no watcher; collect once instead of watching')
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|