dsh-plugin-wsl-env 0.1.9 → 0.2.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 +121 -0
- package/README.md +19 -2
- package/agent/wsl-agent.sh +156 -0
- package/docs/ARCHITECTURE.md +8 -0
- package/docs/CONFIGURATION.md +8 -0
- package/docs/LIMITATIONS.md +10 -5
- package/docs/PARITY.md +70 -0
- package/docs/README.zh.md +29 -20
- package/lib/agent-errors.js +33 -0
- package/lib/agent-exec.js +194 -0
- package/lib/agent-protocol.js +116 -0
- package/lib/agent-shared.js +32 -0
- package/lib/agent.js +414 -0
- package/lib/fs-publish.js +42 -0
- package/lib/index.js +192 -7
- package/lib/ports.js +61 -0
- package/lib/sandbox.js +3 -1
- package/lib/shell-env.js +28 -2
- package/lib/subprocess.js +15 -0
- package/lib/terminal-route.js +31 -0
- package/lib/watcher.js +127 -0
- package/package.json +10 -4
package/lib/agent.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of the resident in-distro agent.
|
|
3
|
+
*
|
|
4
|
+
* One {@link WslAgent} instance owns one long-lived `wsl.exe` process per
|
|
5
|
+
* distro. The lifetime rules, in order of importance:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Correctness before speed.** Any agent failure is answered by at most
|
|
8
|
+
* one rebuild; the requests that were in flight ride the rebuild. A second
|
|
9
|
+
* failure marks the agent permanently dead and every future call rejects
|
|
10
|
+
* with {@link AgentUnavailableError} — the executor's cue to fall back to
|
|
11
|
+
* the one-shot `wsl.exe` path it shipped with, so the worst case of the
|
|
12
|
+
* agent is "today's behaviour, plus one failed round trip".
|
|
13
|
+
* 2. **The in-distro timeout is the real one.** The EXEC frame carries the
|
|
14
|
+
* timeout in whole seconds and the agent SIGTERMs the child itself, so a
|
|
15
|
+
* timed-out command dies where it lives instead of leaving an orphan
|
|
16
|
+
* behind a dead `wsl.exe`. A host-side watchdog only guards an agent that
|
|
17
|
+
* stopped answering at all.
|
|
18
|
+
* 3. **Idle agents go away.** `idleMs` after the last exchange the process is
|
|
19
|
+
* killed; the next call starts a fresh one. WSL VMs are shared, but every
|
|
20
|
+
* process is a promise someone has to keep.
|
|
21
|
+
*
|
|
22
|
+
* The transport is injectable so the lifecycle and the fallback decision are
|
|
23
|
+
* unit-testable without a distro.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-plugin-wsl/agent
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { createInterface } from "node:readline";
|
|
30
|
+
import { AgentUnavailableError, CwdError } from "./agent-errors.js";
|
|
31
|
+
import { AGENT_NAME, PROTOCOL_VERSION, encodeExecFrame, encodeKill, encodeSetEnv, parseAgentLine } from "./agent-protocol.js";
|
|
32
|
+
|
|
33
|
+
/** How long a rebuilt agent has to re-answer the handshake before giving up. */
|
|
34
|
+
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
35
|
+
|
|
36
|
+
/** Extra time the host watchdog waits past the request's own in-distro timeout. */
|
|
37
|
+
const WATCHDOG_GRACE_MS = 13000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} options - the spawn options.
|
|
41
|
+
* @param {string} options.wslPath - the `wsl.exe` path.
|
|
42
|
+
* @param {string} options.distro - the distro name.
|
|
43
|
+
* @param {string} options.scriptPath - the agent script as a Linux path inside
|
|
44
|
+
* the distro (a `/mnt/<drive>/...` translation of the package's
|
|
45
|
+
* `agent/wsl-agent.sh`).
|
|
46
|
+
* @returns {import("node:child_process").ChildProcess} the spawned process.
|
|
47
|
+
*/
|
|
48
|
+
function spawnDefault({ wslPath, distro, scriptPath }) {
|
|
49
|
+
return spawn(wslPath, ["-d", distro, "--exec", "sh", scriptPath], {
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The resident agent for one distro.
|
|
57
|
+
*/
|
|
58
|
+
export class WslAgent {
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} config - the agent configuration.
|
|
61
|
+
* @param {string} config.distro - the distro this agent serves.
|
|
62
|
+
* @param {string} config.scriptPath - the agent script as a Linux path
|
|
63
|
+
* inside the distro (see {@link spawnDefault}).
|
|
64
|
+
* @param {string} [config.wslPath] - the `wsl.exe` path.
|
|
65
|
+
* @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
|
|
66
|
+
* @param {(options: {wslPath: string, distro: string, scriptPath: string}) => import("node:child_process").ChildProcess} [config.spawnTransport]
|
|
67
|
+
* injectable process factory for tests.
|
|
68
|
+
*/
|
|
69
|
+
constructor(config) {
|
|
70
|
+
this.distro = config.distro;
|
|
71
|
+
this.scriptPath = config.scriptPath;
|
|
72
|
+
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
73
|
+
this.idleMs = config.idleMs ?? 600000;
|
|
74
|
+
this.configuredSpawn = config.spawnTransport;
|
|
75
|
+
/** @type {import("node:child_process").ChildProcess|null} */
|
|
76
|
+
this.process = null;
|
|
77
|
+
/** @type {"idle"|"starting"|"ready"|"dead"} */
|
|
78
|
+
this.state = "idle";
|
|
79
|
+
/** @type {Promise<void>|null} */
|
|
80
|
+
this.startPromise = null;
|
|
81
|
+
this.nextRequestId = 0;
|
|
82
|
+
/** @type {Map<string, {lines: string[], resolve: Function, reject: Function, watchdog: NodeJS.Timeout|null, signal: AbortSignal|null, onAbort: Function|null}>} */
|
|
83
|
+
this.pending = new Map();
|
|
84
|
+
this.rebuildUsed = false;
|
|
85
|
+
/** @type {NodeJS.Timeout|null} */
|
|
86
|
+
this.idleTimer = null;
|
|
87
|
+
this.lastError = "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether the agent is permanently out — the executor's fallback cue.
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
get unavailable() {
|
|
95
|
+
return this.state === "dead";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Human-readable reason for the last transition to `dead`, for logging. */
|
|
99
|
+
get unavailableReason() {
|
|
100
|
+
return this.lastError;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start the process and wait for the handshake. Re-entrant: concurrent
|
|
105
|
+
* callers share the one start.
|
|
106
|
+
* @returns {Promise<void>} resolved once the state is `ready`.
|
|
107
|
+
*/
|
|
108
|
+
start() {
|
|
109
|
+
if (this.state === "dead") {
|
|
110
|
+
return Promise.reject(new AgentUnavailableError(this.lastError));
|
|
111
|
+
}
|
|
112
|
+
if (this.state === "ready") return Promise.resolve();
|
|
113
|
+
if (this.startPromise) return this.startPromise;
|
|
114
|
+
this.state = "starting";
|
|
115
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
116
|
+
const child = (this.process = (this.configuredSpawn ?? spawnDefault)({
|
|
117
|
+
wslPath: this.wslPath,
|
|
118
|
+
distro: this.distro,
|
|
119
|
+
scriptPath: this.scriptPath,
|
|
120
|
+
}));
|
|
121
|
+
const handshakeTimer = setTimeout(() => {
|
|
122
|
+
this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
|
|
123
|
+
child.kill();
|
|
124
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
125
|
+
const settleStart = (error) => {
|
|
126
|
+
clearTimeout(handshakeTimer);
|
|
127
|
+
this.startPromise = null;
|
|
128
|
+
if (error) {
|
|
129
|
+
this.state = this.rebuildUsed ? "dead" : "idle";
|
|
130
|
+
if (this.rebuildUsed) this.lastError = this.lastError || String(error);
|
|
131
|
+
reject(error instanceof AgentUnavailableError ? error : new AgentUnavailableError(String(error)));
|
|
132
|
+
} else {
|
|
133
|
+
this.state = "ready";
|
|
134
|
+
resolve();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
child.on("error", (error) => {
|
|
138
|
+
this.lastError = String(error);
|
|
139
|
+
settleStart(error);
|
|
140
|
+
});
|
|
141
|
+
child.on("exit", (code, signalName) => {
|
|
142
|
+
if (this.state === "starting") {
|
|
143
|
+
this.lastError = `agent exited during handshake (code=${code} signal=${signalName ?? "none"})`;
|
|
144
|
+
settleStart(new Error(this.lastError));
|
|
145
|
+
}
|
|
146
|
+
this.handleProcessExit(code, signalName);
|
|
147
|
+
});
|
|
148
|
+
const out = createInterface({ input: child.stdout });
|
|
149
|
+
out.on("line", (line) => this.handleLine(line, settleStart, handshakeTimer));
|
|
150
|
+
child.stderr?.on("data", (chunk) => {
|
|
151
|
+
// Diagnostics only; `wsl.exe` and the shell chatter here on failure.
|
|
152
|
+
this.lastError = String(chunk).trim().slice(0, 2000) || this.lastError;
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
return this.startPromise;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One stdout line from the agent. The first must be a matching HELLO.
|
|
160
|
+
* @private
|
|
161
|
+
*/
|
|
162
|
+
handleLine(line, settleStart, handshakeTimer) {
|
|
163
|
+
const message = parseAgentLine(line);
|
|
164
|
+
switch (message.type) {
|
|
165
|
+
case "hello": {
|
|
166
|
+
if (message.name !== AGENT_NAME || message.version !== PROTOCOL_VERSION) {
|
|
167
|
+
this.lastError = `agent handshake mismatch: got ${message.name}@${message.version}, want ${AGENT_NAME}@${PROTOCOL_VERSION}`;
|
|
168
|
+
this.process?.kill();
|
|
169
|
+
settleStart(new Error(this.lastError));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
clearTimeout(handshakeTimer);
|
|
173
|
+
settleStart();
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "result": {
|
|
177
|
+
const pending = this.pending.get(message.id);
|
|
178
|
+
if (!pending) break; // answered after its caller gave up — noise
|
|
179
|
+
this.clearPending(pending);
|
|
180
|
+
this.pending.delete(message.id);
|
|
181
|
+
this.rearmIdleTimer();
|
|
182
|
+
pending.resolve({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr });
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "agentError": {
|
|
186
|
+
const pending = this.pending.get(message.id);
|
|
187
|
+
if (!pending) break;
|
|
188
|
+
this.clearPending(pending);
|
|
189
|
+
this.pending.delete(message.id);
|
|
190
|
+
this.rearmIdleTimer();
|
|
191
|
+
// A cwd rejection is the command's own failure, not the agent's — it
|
|
192
|
+
// becomes a CwdError so the execution layer can report it exactly like
|
|
193
|
+
// the one-shot path's relay text.
|
|
194
|
+
if (message.reason === "cwd") {
|
|
195
|
+
pending.reject(new CwdError(message.message));
|
|
196
|
+
} else {
|
|
197
|
+
pending.reject(new AgentUnavailableError(`agent rejected request ${message.id} (${message.reason}): ${message.message}`));
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
default:
|
|
202
|
+
// Garbage after the handshake is logged and dropped: a late RES for an
|
|
203
|
+
// abandoned request is expected noise, not a broken pipe.
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The process died. Requests in flight ride exactly one rebuild; a second
|
|
210
|
+
* death is permanent.
|
|
211
|
+
* @private
|
|
212
|
+
*/
|
|
213
|
+
handleProcessExit(code, signalName) {
|
|
214
|
+
this.process = null;
|
|
215
|
+
if (this.pending.size === 0) {
|
|
216
|
+
if (this.state !== "dead") this.state = "idle";
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!this.rebuildUsed) {
|
|
220
|
+
this.rebuildUsed = true;
|
|
221
|
+
this.lastError = `agent exited unexpectedly (code=${code} signal=${signalName ?? "none"}); rebuilding once`;
|
|
222
|
+
this.state = "idle";
|
|
223
|
+
this.startPromise = null;
|
|
224
|
+
const carrying = [...this.pending.values()];
|
|
225
|
+
this.pending.clear();
|
|
226
|
+
this.start()
|
|
227
|
+
.then(() => {
|
|
228
|
+
for (const pending of carrying) {
|
|
229
|
+
const id = `r${this.nextRequestId++}`;
|
|
230
|
+
this.pending.set(id, pending);
|
|
231
|
+
this.writeLines(relabelFrame(pending.lines, id));
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
.catch((error) => {
|
|
235
|
+
for (const pending of carrying) {
|
|
236
|
+
this.clearPending(pending);
|
|
237
|
+
pending.reject(error);
|
|
238
|
+
}
|
|
239
|
+
this.state = "dead";
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.lastError = `agent exited again (code=${code} signal=${signalName ?? "none"}); falling back permanently`;
|
|
244
|
+
this.state = "dead";
|
|
245
|
+
for (const pending of this.pending.values()) {
|
|
246
|
+
this.clearPending(pending);
|
|
247
|
+
pending.reject(new AgentUnavailableError(this.lastError));
|
|
248
|
+
}
|
|
249
|
+
this.pending.clear();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Run one command inside the distro through the agent.
|
|
254
|
+
* @param {object} request - the execution request.
|
|
255
|
+
* @param {string} request.cwd - Linux path to run in.
|
|
256
|
+
* @param {string[]} request.argv - the argv, already bwrap-wrapped when the
|
|
257
|
+
* caller confines.
|
|
258
|
+
* @param {number} [request.timeoutMs] - in-distro timeout, whole seconds
|
|
259
|
+
* upward; `<= 0` or omitted means none.
|
|
260
|
+
* @param {Record<string, string>} [request.env] - extra environment for this
|
|
261
|
+
* request only; values lose trailing newlines (protocol limitation).
|
|
262
|
+
* @param {AbortSignal} [request.signal] - cancels the request (KILL + reject).
|
|
263
|
+
* @returns {Promise<{exitCode: number, stdout: Buffer, stderr: Buffer}>}
|
|
264
|
+
* @throws {AgentUnavailableError} when the agent is out for this process.
|
|
265
|
+
*/
|
|
266
|
+
async exec({ cwd, argv, timeoutMs = 0, env, signal }) {
|
|
267
|
+
if (this.state === "dead") {
|
|
268
|
+
throw new AgentUnavailableError(this.lastError);
|
|
269
|
+
}
|
|
270
|
+
await this.start();
|
|
271
|
+
const id = `r${this.nextRequestId++}`;
|
|
272
|
+
const lines = [
|
|
273
|
+
...(env ? Object.entries(env).map(([key, value]) => encodeSetEnv(key, value)) : []),
|
|
274
|
+
...encodeExecFrame({ id, cwd, argv, timeoutMs }),
|
|
275
|
+
];
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const pending = { lines, resolve, reject, watchdog: null, signal, onAbort: null };
|
|
278
|
+
this.pending.set(id, pending);
|
|
279
|
+
if (signal) {
|
|
280
|
+
if (signal.aborted) {
|
|
281
|
+
this.pending.delete(id);
|
|
282
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
pending.onAbort = () => {
|
|
286
|
+
this.writeLines([encodeKill(id)]);
|
|
287
|
+
this.clearPending(pending);
|
|
288
|
+
this.pending.delete(id);
|
|
289
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
290
|
+
};
|
|
291
|
+
signal.addEventListener("abort", pending.onAbort, { once: true });
|
|
292
|
+
}
|
|
293
|
+
if (timeoutMs > 0) {
|
|
294
|
+
// The in-distro timeout does the killing; this only catches an agent
|
|
295
|
+
// that stopped answering even to its own timer.
|
|
296
|
+
pending.watchdog = setTimeout(() => {
|
|
297
|
+
this.lastError = `agent did not answer request ${id} within ${timeoutMs + WATCHDOG_GRACE_MS}ms`;
|
|
298
|
+
this.process?.kill();
|
|
299
|
+
}, timeoutMs + WATCHDOG_GRACE_MS);
|
|
300
|
+
}
|
|
301
|
+
this.writeLines(lines);
|
|
302
|
+
this.rearmIdleTimer();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Liveness probe, also used to warm the agent up.
|
|
308
|
+
* @param {AbortSignal} [signal] - cancels the wait.
|
|
309
|
+
* @returns {Promise<void>} resolved on PONG.
|
|
310
|
+
*/
|
|
311
|
+
async ping(signal) {
|
|
312
|
+
if (this.state === "dead") throw new AgentUnavailableError(this.lastError);
|
|
313
|
+
await this.start();
|
|
314
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
315
|
+
return new Promise((resolve, reject) => {
|
|
316
|
+
const id = `r${this.nextRequestId++}`;
|
|
317
|
+
const lines = encodeExecFrame({ id, cwd: "/", argv: ["true"], timeoutMs: 0 });
|
|
318
|
+
const pending = {
|
|
319
|
+
lines,
|
|
320
|
+
resolve: () => resolve(),
|
|
321
|
+
reject,
|
|
322
|
+
watchdog: setTimeout(() => {
|
|
323
|
+
this.lastError = "agent did not answer ping";
|
|
324
|
+
this.process?.kill();
|
|
325
|
+
}, HANDSHAKE_TIMEOUT_MS),
|
|
326
|
+
signal: null,
|
|
327
|
+
onAbort: null,
|
|
328
|
+
};
|
|
329
|
+
this.pending.set(id, pending);
|
|
330
|
+
this.writeLines(lines);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Shut the agent down: SHUTDOWN first, then kill after a short grace. The
|
|
336
|
+
* agent becomes reusable — a later `exec` starts a fresh process.
|
|
337
|
+
* @returns {Promise<void>}
|
|
338
|
+
*/
|
|
339
|
+
async close() {
|
|
340
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
341
|
+
this.idleTimer = null;
|
|
342
|
+
const child = this.process;
|
|
343
|
+
if (!child) return;
|
|
344
|
+
this.state = "idle";
|
|
345
|
+
this.process = null;
|
|
346
|
+
this.writeLinesSafe(child, ["SHUTDOWN"]);
|
|
347
|
+
await new Promise((resolve) => {
|
|
348
|
+
const timer = setTimeout(() => {
|
|
349
|
+
child.kill("SIGKILL");
|
|
350
|
+
resolve();
|
|
351
|
+
}, 1000);
|
|
352
|
+
child.once("exit", () => {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
resolve();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @private */
|
|
360
|
+
writeLines(lines) {
|
|
361
|
+
const child = this.process;
|
|
362
|
+
if (!child || !child.stdin.writable) return;
|
|
363
|
+
child.stdin.write(`${lines.join("\n")}\n`);
|
|
364
|
+
this.rearmIdleTimer();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** @private */
|
|
368
|
+
writeLinesSafe(child, lines) {
|
|
369
|
+
try {
|
|
370
|
+
child.stdin?.write(`${lines.join("\n")}\n`);
|
|
371
|
+
} catch {
|
|
372
|
+
// A dead pipe is what close() is cleaning up after.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @private */
|
|
377
|
+
rearmIdleTimer() {
|
|
378
|
+
if (!this.idleMs) return;
|
|
379
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
380
|
+
this.idleTimer = setTimeout(() => {
|
|
381
|
+
this.idleTimer = null;
|
|
382
|
+
if (this.pending.size === 0) void this.close();
|
|
383
|
+
}, this.idleMs);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** @private */
|
|
387
|
+
clearPending(pending) {
|
|
388
|
+
if (pending.watchdog) clearTimeout(pending.watchdog);
|
|
389
|
+
if (pending.signal && pending.onAbort) pending.signal.removeEventListener("abort", pending.onAbort);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Rewrite the request id inside a stored frame so a resent request does not
|
|
395
|
+
* collide with ids the new process has already seen. SETENV lines pass
|
|
396
|
+
* through unchanged (they are id-less and idempotent).
|
|
397
|
+
* @param {string[]} lines - the stored frame.
|
|
398
|
+
* @param {string} newId - the new request id.
|
|
399
|
+
* @returns {string[]} the relabelled frame.
|
|
400
|
+
*/
|
|
401
|
+
export function relabelFrame(lines, newId) {
|
|
402
|
+
return lines.map((line) => {
|
|
403
|
+
const separator = line.indexOf("|");
|
|
404
|
+
if (separator === -1) return line;
|
|
405
|
+
const kind = line.slice(0, separator);
|
|
406
|
+
if (kind !== "EXEC" && kind !== "KILL") return line;
|
|
407
|
+
const rest = line.slice(separator + 1);
|
|
408
|
+
const pipe = rest.indexOf("|");
|
|
409
|
+
const tail = pipe === -1 ? "" : rest.slice(pipe);
|
|
410
|
+
return `${kind}|${newId}${tail}`;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export default WslAgent;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-distro publication step for `WslFileSystem.writeText`/`editText`.
|
|
3
|
+
*
|
|
4
|
+
* Today's publication is two round trips: `copyModeInDistro` (one `wsl.exe`
|
|
5
|
+
* chmod) plus the staged file's rename over the 9p share. Under the resident
|
|
6
|
+
* agent it collapses into ONE `exec`: a small POSIX shell step that copies the
|
|
7
|
+
* replaced file's mode onto the staged temp (`chmod --reference`, skipped for
|
|
8
|
+
* a creation, where there is nothing to copy from) and renames it into place
|
|
9
|
+
* atomically — the rename preserving the inode's mode bits, exactly as the
|
|
10
|
+
* old path arranged.
|
|
11
|
+
*
|
|
12
|
+
* Pure by construction: this module only builds the argv; execution and
|
|
13
|
+
* fallback belong to the caller.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-wsl/fs-publish
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The publication script. `$1` is the target's Linux path (may not exist —
|
|
20
|
+
* a creation), `$2` the staged temp's Linux path (always exists).
|
|
21
|
+
* @returns {string} the `sh -c` body.
|
|
22
|
+
*/
|
|
23
|
+
export function publicationScript() {
|
|
24
|
+
return [
|
|
25
|
+
// Creation: there is no source mode; the staged file keeps its umask mode,
|
|
26
|
+
// which is exactly what the old two-step path produced for new files.
|
|
27
|
+
'if [ -e "$1" ]; then',
|
|
28
|
+
' chmod --reference="$1" "$2" || exit 3',
|
|
29
|
+
'fi',
|
|
30
|
+
'mv -f "$2" "$1" || exit 4',
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The argv to execute inside the distro for one publication.
|
|
36
|
+
* @param {string} targetLinuxPath - the file being written.
|
|
37
|
+
* @param {string} stagedLinuxPath - the staged temp holding the new content.
|
|
38
|
+
* @returns {string[]} the argv, ready for the agent (or a one-shot `wsl.exe`).
|
|
39
|
+
*/
|
|
40
|
+
export function publicationArgv(targetLinuxPath, stagedLinuxPath) {
|
|
41
|
+
return ["sh", "-c", publicationScript(), "publish", targetLinuxPath, stagedLinuxPath];
|
|
42
|
+
}
|