dsh-plugin-wsl-env 0.7.5 → 0.8.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/CHANGELOG.md +278 -0
- package/CONTRIBUTING.md +129 -39
- package/README.md +20 -20
- package/docs/ARCHITECTURE.md +6 -3
- package/docs/LIMITATIONS.md +13 -0
- package/docs/README.zh.md +20 -20
- package/lib/agent-confined.d.ts +42 -0
- package/lib/agent-confined.js +25 -41
- package/lib/agent-errors.d.ts +31 -0
- package/lib/agent-errors.js +19 -16
- package/lib/agent-exec.d.ts +159 -0
- package/lib/agent-exec.js +151 -165
- package/lib/agent-protocol.d.ts +192 -0
- package/lib/agent-protocol.js +76 -100
- package/lib/agent-shared.d.ts +27 -0
- package/lib/agent-shared.js +12 -12
- package/lib/agent.d.ts +353 -0
- package/lib/agent.js +536 -552
- package/lib/auto-preset.d.ts +110 -0
- package/lib/auto-preset.js +157 -141
- package/lib/bwrap.d.ts +90 -0
- package/lib/bwrap.js +18 -18
- package/lib/file-reference-wsl.d.ts +160 -0
- package/lib/file-reference-wsl.js +192 -173
- package/lib/fs-decisions.d.ts +107 -0
- package/lib/fs-decisions.js +64 -60
- package/lib/fs-routing.d.ts +182 -0
- package/lib/fs-routing.js +173 -116
- package/lib/fs-substrate.d.ts +258 -0
- package/lib/fs-substrate.js +248 -248
- package/lib/fs.d.ts +10 -0
- package/lib/fs.js +5 -0
- package/lib/fsio-agent.d.ts +307 -0
- package/lib/fsio-agent.js +414 -410
- package/lib/fsio-text.d.ts +115 -0
- package/lib/fsio-text.js +104 -84
- package/lib/git-route.d.ts +100 -0
- package/lib/git-route.js +46 -47
- package/lib/index.d.ts +1003 -0
- package/lib/index.js +1101 -1003
- package/lib/listing.d.ts +114 -0
- package/lib/listing.js +68 -55
- package/lib/paths.d.ts +182 -0
- package/lib/paths.js +134 -109
- package/lib/picker.d.ts +266 -0
- package/lib/picker.js +371 -351
- package/lib/ports.d.ts +44 -0
- package/lib/ports.js +39 -29
- package/lib/preset-choice.d.ts +33 -0
- package/lib/preset-choice.js +15 -7
- package/lib/probe-cache.d.ts +40 -0
- package/lib/probe-cache.js +35 -29
- package/lib/sandbox-core.d.ts +79 -0
- package/lib/sandbox-core.js +71 -88
- package/lib/sandbox.d.ts +48 -0
- package/lib/sandbox.js +4 -4
- package/lib/search-exec.d.ts +96 -0
- package/lib/search-exec.js +52 -79
- package/lib/search-route.d.ts +75 -0
- package/lib/search-route.js +30 -31
- package/lib/shell-env.d.ts +88 -0
- package/lib/shell-env.js +78 -74
- package/lib/shell.d.ts +10 -0
- package/lib/shell.js +5 -0
- package/lib/subprocess.d.ts +313 -0
- package/lib/subprocess.js +267 -262
- package/lib/terminal-activity.d.ts +98 -0
- package/lib/terminal-activity.js +29 -39
- package/lib/terminal-route.d.ts +27 -0
- package/lib/terminal-route.js +16 -8
- package/lib/watcher.d.ts +77 -0
- package/lib/watcher.js +120 -126
- package/lib/workspace-files-route.d.ts +132 -0
- package/lib/workspace-files-route.js +73 -81
- package/lib/workspace-files-wsl.d.ts +257 -0
- package/lib/workspace-files-wsl.js +311 -251
- package/lib/wsl.d.ts +266 -0
- package/lib/wsl.js +144 -155
- package/package.json +68 -20
package/lib/fs-substrate.js
CHANGED
|
@@ -27,268 +27,268 @@
|
|
|
27
27
|
* boundary, so this module is unit-testable in a bare checkout against the
|
|
28
28
|
* real agent script.
|
|
29
29
|
*
|
|
30
|
+
* This is a TypeScript source built to `lib/fs-substrate.js`; edit THIS file
|
|
31
|
+
* and run `pnpm run build` — the artifact under `lib/` is generated, and
|
|
32
|
+
* `pnpm test` fails when it drifts.
|
|
33
|
+
*
|
|
30
34
|
* @module dsh-plugin-wsl/fs-substrate
|
|
31
35
|
*/
|
|
32
|
-
|
|
33
36
|
import { applyLiteralEdit, FsCodedError, normalizeLineEndings, restoreLineEndings } from "./fsio-text.js";
|
|
34
37
|
import { DistroFs } from "./fsio-agent.js";
|
|
35
38
|
import { posixToUnc, uncToPosix } from "./paths.js";
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The provider's stat/lstat row, type-spelled the way the tool layer reads it.
|
|
41
|
+
*
|
|
42
|
+
* @param info - the distro stat row.
|
|
43
|
+
* @returns the provider-shaped row.
|
|
44
|
+
*/
|
|
38
45
|
function toInfo({ version, type, size }) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
return {
|
|
47
|
+
version,
|
|
48
|
+
type: type === "f" ? "file" : type === "d" ? "directory" : type === "l" ? "symlink" : "other",
|
|
49
|
+
size,
|
|
50
|
+
};
|
|
44
51
|
}
|
|
45
|
-
|
|
46
52
|
/**
|
|
47
53
|
* One distro's provider-shaped filesystem face.
|
|
48
54
|
*/
|
|
49
55
|
export class AgentSubstrate {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*/
|
|
61
|
-
constructor({ agent, agentFor, distro, diffBasisMaxBytes = 10 * 1024 * 1024 }) {
|
|
62
|
-
this.distro = distro;
|
|
63
|
-
this.diffBasisMaxBytes = diffBasisMaxBytes;
|
|
64
|
-
this.agent = agent;
|
|
65
|
-
this.agentFor = agentFor;
|
|
66
|
-
this.fs = new DistroFs({ agent, distro });
|
|
67
|
-
/** Per-targetKey tail promise: the host backend's mutation serialization. */
|
|
68
|
-
this.locks = new Map();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The agent a mutation runs on under this resolved policy.
|
|
73
|
-
* @param {{mode: string, workspaceRoot: string}|undefined} policy - the
|
|
74
|
-
* resolved file-effect policy, or undefined when the caller pre-checked.
|
|
75
|
-
* @returns {Promise<object>} the agent to publish through.
|
|
76
|
-
*/
|
|
77
|
-
async mutationAgent(policy) {
|
|
78
|
-
if (this.agentFor === undefined) return this.agent;
|
|
79
|
-
return this.agentFor(policy);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
|
83
|
-
async lock(targetKey, op) {
|
|
84
|
-
const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(op, op);
|
|
85
|
-
const tail = run.then(() => undefined, () => undefined);
|
|
86
|
-
this.locks.set(targetKey, tail);
|
|
87
|
-
try {
|
|
88
|
-
return await run;
|
|
89
|
-
} finally {
|
|
90
|
-
if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey);
|
|
56
|
+
/**
|
|
57
|
+
* @param deps - the substrate's collaborators.
|
|
58
|
+
*/
|
|
59
|
+
constructor({ agent, agentFor, distro, diffBasisMaxBytes = 10 * 1024 * 1024 }) {
|
|
60
|
+
this.distro = distro;
|
|
61
|
+
this.diffBasisMaxBytes = diffBasisMaxBytes;
|
|
62
|
+
this.agent = agent;
|
|
63
|
+
this.agentFor = agentFor;
|
|
64
|
+
this.fs = new DistroFs({ agent, distro });
|
|
65
|
+
this.locks = new Map();
|
|
91
66
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
* @param {string} linuxPath - the absolute Linux path to resolve.
|
|
104
|
-
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
105
|
-
* @returns {Promise<{displayPath: string, targetKey: string}>}
|
|
106
|
-
*/
|
|
107
|
-
async resolve(linuxPath, signal) {
|
|
108
|
-
const canonical = await this.fs.canonicalPath(linuxPath, signal);
|
|
109
|
-
return { displayPath: canonical, targetKey: posixToUnc(this.distro, canonical) };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Follow-stat one target; `undefined` when absent.
|
|
114
|
-
* @param {object} target - the resolved target.
|
|
115
|
-
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
116
|
-
* @returns {Promise<{version: string, type: string, size: number}|undefined>}
|
|
117
|
-
*/
|
|
118
|
-
async stat(target, signal) {
|
|
119
|
-
const info = await this.fs.stat(this.linuxOf(target), { signal });
|
|
120
|
-
return info === null ? undefined : toInfo(info);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* No-follow stat one path; `undefined` when absent, `symlink` for a link.
|
|
125
|
-
* @param {string} linuxPath - the absolute Linux path to inspect.
|
|
126
|
-
* @param {AbortSignal} [signal] - cancels the round trip.
|
|
127
|
-
* @returns {Promise<{version: string, type: string, size: number}|undefined>}
|
|
128
|
-
*/
|
|
129
|
-
async lstat(linuxPath, signal) {
|
|
130
|
-
if (String(linuxPath).trim().length === 0) {
|
|
131
|
-
throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
67
|
+
/**
|
|
68
|
+
* The agent a mutation runs on under this resolved policy.
|
|
69
|
+
*
|
|
70
|
+
* @param policy - the resolved file-effect policy, or undefined when the
|
|
71
|
+
* caller pre-checked.
|
|
72
|
+
* @returns the agent to publish through.
|
|
73
|
+
*/
|
|
74
|
+
async mutationAgent(policy) {
|
|
75
|
+
if (this.agentFor === undefined)
|
|
76
|
+
return this.agent;
|
|
77
|
+
return this.agentFor(policy);
|
|
132
78
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* @returns {Promise<AsyncGenerator<string>>} the chunk stream.
|
|
152
|
-
*/
|
|
153
|
-
async streamText(target, signal) {
|
|
154
|
-
return this.fs.streamWholeText(target, signal);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Read a whole regular file as raw bytes, bounded.
|
|
159
|
-
* @param {object} target - the resolved target.
|
|
160
|
-
* @param {AbortSignal|undefined} signal - aborts the read.
|
|
161
|
-
* @param {number} maxBytes - inclusive byte cap.
|
|
162
|
-
* @returns {Promise<Uint8Array>} the bytes.
|
|
163
|
-
*/
|
|
164
|
-
async readBytes(target, signal, maxBytes) {
|
|
165
|
-
return this.fs.readWholeBytes(target, signal, maxBytes);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Read one byte window of a regular file.
|
|
170
|
-
* @param {object} target - the resolved target.
|
|
171
|
-
* @param {{offset: number, length: number}} range - the window.
|
|
172
|
-
* @param {AbortSignal} [signal] - aborts the read.
|
|
173
|
-
* @returns {Promise<Uint8Array>} the window's bytes.
|
|
174
|
-
*/
|
|
175
|
-
async readByteRange(target, range, signal) {
|
|
176
|
-
return this.fs.readByteWindow(target, range, signal);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* List one directory's direct children.
|
|
181
|
-
* @param {object} target - the resolved directory.
|
|
182
|
-
* @param {AbortSignal} [signal] - aborts between children.
|
|
183
|
-
* @returns {Promise<Array<{name: string, type: string, target: {displayPath: string, targetKey: string}, version?: string, size?: number}>>}
|
|
184
|
-
*/
|
|
185
|
-
async listDir(target, signal) {
|
|
186
|
-
return this.fs.listChildren(target, signal);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Write a whole text file with the host backend's orchestration: probe,
|
|
191
|
-
* guard, best-effort diff basis, atomic publication, outcome shape. The
|
|
192
|
-
* guard is FORWARDED — a guarded create publishes with the distro-side
|
|
193
|
-
* no-replace link (atomic), and a `replaceIfVersion` write carries its
|
|
194
|
-
* version into the op, where the agent re-verifies it one syscall before
|
|
195
|
-
* the rename and refuses with `FS_STALE_VERSION` on a mismatch.
|
|
196
|
-
*
|
|
197
|
-
* @param {object} target - the resolved target.
|
|
198
|
-
* @param {string} content - the full new file content.
|
|
199
|
-
* @param {{kind: string, version?: string}|undefined} expected - the write intent.
|
|
200
|
-
* @param {AbortSignal} [signal] - cancels the write.
|
|
201
|
-
* @param {{mode: string, workspaceRoot: string}|undefined} [policy] - the
|
|
202
|
-
* resolved policy this write was fenced with; selects the confined
|
|
203
|
-
* resident when stage-two routing is wired.
|
|
204
|
-
* @returns {Promise<{operation: string, version: string, before: string|null, after: string}>}
|
|
205
|
-
*/
|
|
206
|
-
async writeText(target, content, expected, signal, policy) {
|
|
207
|
-
const mutationAgent = await this.mutationAgent(policy);
|
|
208
|
-
return this.lock(target.targetKey, async () => {
|
|
209
|
-
const linuxPath = this.linuxOf(target);
|
|
210
|
-
const existing = await this.fs.stat(linuxPath, { signal });
|
|
211
|
-
if (existing && existing.type !== "f") {
|
|
212
|
-
throw new FsCodedError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
213
|
-
}
|
|
214
|
-
if (expected?.kind === "replaceIfVersion") {
|
|
215
|
-
if (!existing) throw new FsCodedError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
216
|
-
if (existing.version !== expected.version) {
|
|
217
|
-
throw new FsCodedError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
79
|
+
/**
|
|
80
|
+
* Run `op` with exclusive access to `targetKey` (FIFO per key).
|
|
81
|
+
*
|
|
82
|
+
* @template T
|
|
83
|
+
* @param targetKey - the mutation's serialization key.
|
|
84
|
+
* @param op - the exclusive section.
|
|
85
|
+
* @returns the section's outcome.
|
|
86
|
+
*/
|
|
87
|
+
async lock(targetKey, op) {
|
|
88
|
+
const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(op, op);
|
|
89
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
90
|
+
this.locks.set(targetKey, tail);
|
|
91
|
+
try {
|
|
92
|
+
return await run;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (this.locks.get(targetKey) === tail)
|
|
96
|
+
this.locks.delete(targetKey);
|
|
218
97
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const mutationAgent = await this.mutationAgent(policy);
|
|
261
|
-
return this.lock(target.targetKey, async () => {
|
|
262
|
-
const linuxPath = this.linuxOf(target);
|
|
263
|
-
const existing = await this.fs.stat(linuxPath, { signal });
|
|
264
|
-
if (!existing) {
|
|
265
|
-
// A file that is not there NOW splits by evidence: an edit grounded
|
|
266
|
-
// in a read (it carries the expected version) lost its file, so the
|
|
267
|
-
// guard is stale; an edit with no read behind it names a file that
|
|
268
|
-
// never answered the stat - the read path's missing-file dialect.
|
|
269
|
-
if (expected) {
|
|
270
|
-
throw new FsCodedError(`cannot edit "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The Linux path a resolved target names, falling back to its display.
|
|
101
|
+
*
|
|
102
|
+
* @param target - the resolved target.
|
|
103
|
+
* @returns the Linux path to operate on.
|
|
104
|
+
*/
|
|
105
|
+
linuxOf(target) {
|
|
106
|
+
return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve one absolute Linux path to the target shape the provider returns:
|
|
110
|
+
* the canonical Linux spelling the model sees and the UNC identity the
|
|
111
|
+
* harness keys on. Symlinks resolve here, where they live.
|
|
112
|
+
*
|
|
113
|
+
* @param linuxPath - the absolute Linux path to resolve.
|
|
114
|
+
* @param signal - cancels the round trip.
|
|
115
|
+
*/
|
|
116
|
+
async resolve(linuxPath, signal) {
|
|
117
|
+
const canonical = await this.fs.canonicalPath(linuxPath, signal);
|
|
118
|
+
return { displayPath: canonical, targetKey: posixToUnc(this.distro, canonical) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Follow-stat one target; `undefined` when absent.
|
|
122
|
+
*
|
|
123
|
+
* @param target - the resolved target.
|
|
124
|
+
* @param signal - cancels the round trip.
|
|
125
|
+
*/
|
|
126
|
+
async stat(target, signal) {
|
|
127
|
+
const info = await this.fs.stat(this.linuxOf(target), { signal });
|
|
128
|
+
return info === null ? undefined : toInfo(info);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* No-follow stat one path; `undefined` when absent, `symlink` for a link.
|
|
132
|
+
*
|
|
133
|
+
* @param linuxPath - the absolute Linux path to inspect.
|
|
134
|
+
* @param signal - cancels the round trip.
|
|
135
|
+
*/
|
|
136
|
+
async lstat(linuxPath, signal) {
|
|
137
|
+
if (String(linuxPath).trim().length === 0) {
|
|
138
|
+
throw new FsCodedError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
271
139
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
140
|
+
const info = await this.fs.stat(linuxPath, { follow: false, signal });
|
|
141
|
+
return info === null ? undefined : toInfo(info);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Read a whole regular UTF-8 file.
|
|
145
|
+
*
|
|
146
|
+
* @param target - the resolved target.
|
|
147
|
+
* @param signal - aborts the read.
|
|
148
|
+
* @returns the full decoded text.
|
|
149
|
+
*/
|
|
150
|
+
async readText(target, signal) {
|
|
151
|
+
return this.fs.readWholeText(target, signal);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Stream a whole regular UTF-8 file as decoded chunks.
|
|
155
|
+
*
|
|
156
|
+
* @param target - the resolved target.
|
|
157
|
+
* @param signal - aborts the stream.
|
|
158
|
+
* @returns the chunk stream.
|
|
159
|
+
*/
|
|
160
|
+
async streamText(target, signal) {
|
|
161
|
+
return this.fs.streamWholeText(target, signal);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Read a whole regular file as raw bytes, bounded.
|
|
165
|
+
*
|
|
166
|
+
* @param target - the resolved target.
|
|
167
|
+
* @param signal - aborts the read.
|
|
168
|
+
* @param maxBytes - inclusive byte cap.
|
|
169
|
+
* @returns the bytes.
|
|
170
|
+
*/
|
|
171
|
+
async readBytes(target, signal, maxBytes) {
|
|
172
|
+
return this.fs.readWholeBytes(target, signal, maxBytes);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Read one byte window of a regular file.
|
|
176
|
+
*
|
|
177
|
+
* @param target - the resolved target.
|
|
178
|
+
* @param range - the window.
|
|
179
|
+
* @param signal - aborts the read.
|
|
180
|
+
* @returns the window's bytes.
|
|
181
|
+
*/
|
|
182
|
+
async readByteRange(target, range, signal) {
|
|
183
|
+
return this.fs.readByteWindow(target, range, signal);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* List one directory's direct children.
|
|
187
|
+
*
|
|
188
|
+
* @param target - the resolved directory.
|
|
189
|
+
* @param signal - aborts between children.
|
|
190
|
+
*/
|
|
191
|
+
async listDir(target, signal) {
|
|
192
|
+
return this.fs.listChildren(target, signal);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Write a whole text file with the host backend's orchestration: probe,
|
|
196
|
+
* guard, best-effort diff basis, atomic publication, outcome shape. The
|
|
197
|
+
* guard is FORWARDED — a guarded create publishes with the distro-side
|
|
198
|
+
* no-replace link (atomic), and a `replaceIfVersion` write carries its
|
|
199
|
+
* version into the op, where the agent re-verifies it one syscall before
|
|
200
|
+
* the rename and refuses with `FS_STALE_VERSION` on a mismatch.
|
|
201
|
+
*
|
|
202
|
+
* @param target - the resolved target.
|
|
203
|
+
* @param content - the full new file content.
|
|
204
|
+
* @param expected - the write intent.
|
|
205
|
+
* @param signal - cancels the write.
|
|
206
|
+
* @param policy - the resolved policy this write was fenced with; selects
|
|
207
|
+
* the confined resident when stage-two routing is wired.
|
|
208
|
+
*/
|
|
209
|
+
async writeText(target, content, expected, signal, policy) {
|
|
210
|
+
const mutationAgent = await this.mutationAgent(policy);
|
|
211
|
+
return this.lock(target.targetKey, async () => {
|
|
212
|
+
const linuxPath = this.linuxOf(target);
|
|
213
|
+
const existing = await this.fs.stat(linuxPath, { signal });
|
|
214
|
+
if (existing && existing.type !== "f") {
|
|
215
|
+
throw new FsCodedError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
216
|
+
}
|
|
217
|
+
if (expected?.kind === "replaceIfVersion") {
|
|
218
|
+
if (!existing)
|
|
219
|
+
throw new FsCodedError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
220
|
+
if (existing.version !== expected.version) {
|
|
221
|
+
throw new FsCodedError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else if (expected?.kind === "createIfAbsent" && existing) {
|
|
225
|
+
throw new FsCodedError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
226
|
+
}
|
|
227
|
+
const before = existing !== null && Buffer.byteLength(content, "utf8") < this.diffBasisMaxBytes
|
|
228
|
+
? await this.fs.readTextForDiff(target, this.diffBasisMaxBytes, signal)
|
|
229
|
+
: null;
|
|
230
|
+
await this.fs.writeFileAtomic(linuxPath, content, {
|
|
231
|
+
mode: existing?.mode,
|
|
232
|
+
createIfAbsent: expected?.kind === "createIfAbsent" ? { displayPath: target.displayPath } : undefined,
|
|
233
|
+
expectedVersion: expected?.kind === "replaceIfVersion" ? expected.version : undefined,
|
|
234
|
+
signal,
|
|
235
|
+
agent: mutationAgent,
|
|
236
|
+
});
|
|
237
|
+
const after = await this.fs.stat(linuxPath, { signal });
|
|
238
|
+
return {
|
|
239
|
+
operation: existing ? "update" : "create",
|
|
240
|
+
version: after ? after.version : `missing:${target.targetKey}`,
|
|
241
|
+
before,
|
|
242
|
+
after: normalizeLineEndings(content),
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Edit a text file with the host backend's orchestration: probe, version
|
|
248
|
+
* guard, LF-normalized read, literal replacement, line-ending restore,
|
|
249
|
+
* atomic publication, outcome shape. The version the edit was based on
|
|
250
|
+
* rides the write op and is re-verified distro-side one syscall before the
|
|
251
|
+
* rename, so a concurrent writer wins instead of being clobbered.
|
|
252
|
+
*
|
|
253
|
+
* @param target - the resolved target.
|
|
254
|
+
* @param edit - the literal request.
|
|
255
|
+
* @param expected - the version guard.
|
|
256
|
+
* @param signal - cancels the edit.
|
|
257
|
+
* @param policy - the resolved policy this edit was fenced with; selects
|
|
258
|
+
* the confined resident when stage-two routing is wired.
|
|
259
|
+
*/
|
|
260
|
+
async editText(target, edit, expected, signal, policy) {
|
|
261
|
+
const mutationAgent = await this.mutationAgent(policy);
|
|
262
|
+
return this.lock(target.targetKey, async () => {
|
|
263
|
+
const linuxPath = this.linuxOf(target);
|
|
264
|
+
const existing = await this.fs.stat(linuxPath, { signal });
|
|
265
|
+
if (!existing) {
|
|
266
|
+
// A file that is not there NOW splits by evidence: an edit grounded
|
|
267
|
+
// in a read (it carries the expected version) lost its file, so the
|
|
268
|
+
// guard is stale; an edit with no read behind it names a file that
|
|
269
|
+
// never answered the stat - the read path's missing-file dialect.
|
|
270
|
+
if (expected) {
|
|
271
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
272
|
+
}
|
|
273
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": file does not exist`, "FS_NOT_FOUND");
|
|
274
|
+
}
|
|
275
|
+
if (existing.type !== "f") {
|
|
276
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
277
|
+
}
|
|
278
|
+
if (expected && existing.version !== expected.version) {
|
|
279
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
280
|
+
}
|
|
281
|
+
const original = await this.fs.readForEdit(target, signal);
|
|
282
|
+
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath);
|
|
283
|
+
const content = restoreLineEndings(edited.content, original.lineEndings);
|
|
284
|
+
await this.fs.writeFileAtomic(linuxPath, content, { mode: existing.mode, expectedVersion: existing.version, signal, agent: mutationAgent });
|
|
285
|
+
const after = await this.fs.stat(linuxPath, { signal });
|
|
286
|
+
return {
|
|
287
|
+
version: after ? after.version : `missing:${target.targetKey}`,
|
|
288
|
+
before: original.content,
|
|
289
|
+
after: edited.content,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
}
|
|
292
293
|
}
|
|
293
|
-
|
|
294
294
|
export default AgentSubstrate;
|
package/lib/fs.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.fs` entrypoint — load as `dsh-plugin-wsl/fs` in a profile patch.
|
|
3
|
+
*
|
|
4
|
+
* This is a TypeScript source built to `lib/fs.js`; edit THIS file and run
|
|
5
|
+
* `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
|
|
6
|
+
* fails when it drifts.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-plugin-wsl/fs
|
|
9
|
+
*/
|
|
10
|
+
export { WslFileSystem as default, WslFileSystem } from "./index.js";
|
package/lib/fs.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `ctx.fs` entrypoint — load as `dsh-plugin-wsl/fs` in a profile patch.
|
|
3
|
+
*
|
|
4
|
+
* This is a TypeScript source built to `lib/fs.js`; edit THIS file and run
|
|
5
|
+
* `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
|
|
6
|
+
* fails when it drifts.
|
|
7
|
+
*
|
|
3
8
|
* @module dsh-plugin-wsl/fs
|
|
4
9
|
*/
|
|
5
10
|
export { WslFileSystem as default, WslFileSystem } from "./index.js";
|