@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/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
+ }