@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Self-update installer for the W2M DSH plugin (v0.2.3).
3
+ *
4
+ * This is the highest-risk module in the project: it modifies the profile that
5
+ * the *running* DSH is loaded from. Every step below is therefore ordered
6
+ * "verify, back up, act, and be able to undo", and nothing here may ever leave
7
+ * the profile worse off than it found it.
8
+ *
9
+ * ## The invariants (asserted by test/update-install.test.mjs)
10
+ *
11
+ * 1. **Verify before touching anything.** The tarball's sha256 must equal
12
+ * `expectedSha256` (from the release's `SHA256SUMS`). On a mismatch this
13
+ * function writes *zero bytes* -- no staging directory, no backup.
14
+ * 2. **Back up first.** `package.json` and `pnpm-lock.yaml` are copied before
15
+ * the package manager runs. Any failure afterwards restores them
16
+ * byte-for-byte (and removes a lockfile that did not exist before).
17
+ * 3. **Stage atomically.** The tarball is copied to a temp file in the same
18
+ * directory, then renamed into place, so the package manager can never
19
+ * read half a download.
20
+ * 4. **Use a supported entry point.** `dsh plugin --profile <p> add <tarball>`
21
+ * when the DSH CLI is available, otherwise the runtime's own pnpm. Which
22
+ * one ran is reported as `installedVia`.
23
+ * 5. **dryRun has no side effects at all** -- no files, no package manager.
24
+ * 6. **Failures are never silent.** pnpm's reason is kept (tail, 4 KiB) in
25
+ * `error`/`stderrTail`. Do **not** narrow this to stderr: pnpm 11.7.0 was
26
+ * measured reporting `[ENOENT] ...` on **stdout** with an empty stderr, so
27
+ * the implementation prefers stderr and falls back to stdout, recording
28
+ * which one it used in `detailStream`. Trusting stderr alone degrades a
29
+ * real cause into "exited with code 1" -- the exact silent failure this
30
+ * point exists to prevent.
31
+ * 7. **A rollback is not a time machine.** Restoring `package.json` and
32
+ * `pnpm-lock.yaml` returns the *manifest* to its previous state; it does
33
+ * not undo what pnpm already did to `node_modules/`. So the result also
34
+ * carries `rollbackComplete` (does the installed tree match the restored
35
+ * manifest again?), the installed version before and after, and a
36
+ * `reconciliation` command for the case where it does not. Without those,
37
+ * "rolled back" would be an untrue reassurance: a profile whose manifest
38
+ * was restored but whose tree is missing the package no longer boots DSH.
39
+ *
40
+ * ## What this module deliberately never does
41
+ *
42
+ * * **Never deletes the profile directory** (or anything outside its own
43
+ * staging/backup directories). A profile that loses `cordis.yml`,
44
+ * `pnpm-workspace.yaml` or `.plugin-manager/` stops booting DSH entirely;
45
+ * that is not a recoverable state to leave a user in.
46
+ * * **Never edits `node_modules/` by hand.** Dependency changes go through the
47
+ * package manager so `pnpm-lock.yaml` stays truthful; hand-editing produces
48
+ * a profile that installs differently the next time pnpm runs.
49
+ * * **Never rewrites `dsh.profile.bundles`.** That list decides what DSH
50
+ * loads. Rewriting it to "help" the update would silently change the user's
51
+ * profile -- verified: real pnpm leaves that block untouched.
52
+ * * **Never restarts DSH.** Loading the new version is the caller's (and
53
+ * ultimately the user's) decision; a surprise restart mid-session is worse
54
+ * than being one version behind.
55
+ */
56
+
57
+ import { spawn } from 'node:child_process';
58
+ import { createHash, randomUUID } from 'node:crypto';
59
+ import { createReadStream } from 'node:fs';
60
+ import {
61
+ copyFileSync,
62
+ existsSync,
63
+ mkdirSync,
64
+ readFileSync,
65
+ readdirSync,
66
+ renameSync,
67
+ rmSync,
68
+ statSync,
69
+ } from 'node:fs';
70
+ import { homedir } from 'node:os';
71
+ import { basename, join, resolve } from 'node:path';
72
+ import { gunzipSync } from 'node:zlib';
73
+
74
+ /** How much of the installer's stderr is preserved when it fails. */
75
+ export const DEFAULT_STDERR_TAIL_BYTES = 4096;
76
+
77
+ /** Directory this module owns inside the profile. Nothing else may live here. */
78
+ export const STAGING_DIR_NAME = '.w2m-update';
79
+
80
+ /** Package the update replaces. */
81
+ export const DEFAULT_PACKAGE_NAME = '@twinsearth/w2m-dsh-plugin';
82
+
83
+ /** Files restored on rollback. `pnpm-workspace.yaml` is not here on purpose:
84
+ * pnpm reads it but does not rewrite it (verified against pnpm 11.7.0). */
85
+ export const BACKED_UP_FILES = ['package.json', 'pnpm-lock.yaml'];
86
+
87
+ /**
88
+ * Last `maxBytes` bytes of a stream, as text.
89
+ *
90
+ * Truncation is on a byte boundary, so a multi-byte character straddling the
91
+ * cut becomes U+FFFD. That is the right trade for a diagnostic tail: the reader
92
+ * wants the end of the message, not a guarantee about its first character.
93
+ *
94
+ * @param {string|Buffer} text
95
+ * @param {number} [maxBytes]
96
+ * @returns {{text: string, truncatedBytes: number}}
97
+ */
98
+ export function tailBytes(text, maxBytes = DEFAULT_STDERR_TAIL_BYTES) {
99
+ const buffer = Buffer.isBuffer(text) ? text : Buffer.from(String(text ?? ''), 'utf8');
100
+ if (buffer.length <= maxBytes) return { text: buffer.toString('utf8'), truncatedBytes: 0 };
101
+ return {
102
+ text: buffer.subarray(buffer.length - maxBytes).toString('utf8'),
103
+ truncatedBytes: buffer.length - maxBytes,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Streaming sha256 of a file, lowercase hex.
109
+ *
110
+ * Read in chunks rather than whole: a release tarball is small today, but the
111
+ * check must not be the thing that runs the process out of memory.
112
+ *
113
+ * @param {string} file
114
+ * @returns {Promise<string>}
115
+ */
116
+ export function sha256File(file) {
117
+ return new Promise((resolvePromise, rejectPromise) => {
118
+ const hash = createHash('sha256');
119
+ const stream = createReadStream(file);
120
+ stream.on('error', rejectPromise);
121
+ stream.on('data', (chunk) => hash.update(chunk));
122
+ stream.on('end', () => resolvePromise(hash.digest('hex')));
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Read `package/package.json` out of a packed tarball.
128
+ *
129
+ * Only what the update needs: the version, so the caller can report
130
+ * `to`. A tarball we cannot parse is not fatal -- `to` becomes null -- because
131
+ * the checksum already decided whether the bytes are the right bytes.
132
+ *
133
+ * @param {string} tarballPath
134
+ * @param {{maxUncompressedBytes?: number}} [options]
135
+ * @returns {{name: string|null, version: string|null}|null}
136
+ */
137
+ export function readTarballManifest(tarballPath, options = {}) {
138
+ const maxUncompressedBytes = options.maxUncompressedBytes ?? 64 * 1024 * 1024;
139
+ let tar;
140
+ try {
141
+ tar = gunzipSync(readFileSync(tarballPath), { maxOutputLength: maxUncompressedBytes });
142
+ } catch {
143
+ return null;
144
+ }
145
+
146
+ /** @param {Buffer} header @param {number} start @param {number} length */
147
+ const readString = (header, start, length) => {
148
+ const slice = header.subarray(start, start + length);
149
+ const end = slice.indexOf(0);
150
+ return slice.subarray(0, end === -1 ? slice.length : end).toString('utf8').trim();
151
+ };
152
+
153
+ let offset = 0;
154
+ while (offset + 512 <= tar.length) {
155
+ const header = tar.subarray(offset, offset + 512);
156
+ if (header.every((byte) => byte === 0)) break; // end-of-archive marker
157
+
158
+ const name = readString(header, 0, 100);
159
+ const prefix = readString(header, 345, 155);
160
+ const full = prefix === '' ? name : `${prefix}/${name}`;
161
+ const size = Number.parseInt(readString(header, 124, 12) || '0', 8) || 0;
162
+ const typeflag = String.fromCharCode(header[156] ?? 0x30);
163
+ const dataStart = offset + 512;
164
+
165
+ if (full === 'package/package.json' && (typeflag === '0' || typeflag === '\u0000')) {
166
+ try {
167
+ const manifest = JSON.parse(tar.subarray(dataStart, dataStart + size).toString('utf8'));
168
+ return {
169
+ name: typeof manifest.name === 'string' ? manifest.name : null,
170
+ version: typeof manifest.version === 'string' ? manifest.version : null,
171
+ };
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+ offset = dataStart + Math.ceil(size / 512) * 512;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /** `%DSH_HOME%` (or `~/.dsh`). */
182
+ export function dshHome(env = process.env) {
183
+ const explicit = env.DSH_HOME && env.DSH_HOME.trim() !== '' ? env.DSH_HOME.trim() : null;
184
+ return explicit ?? join(homedir(), '.dsh');
185
+ }
186
+
187
+ /**
188
+ * Locate the DSH CLI (`dsh`), which is the supported entry point.
189
+ *
190
+ * `W2M_DSH_CLI` exists so a deployment can pin the exact host binary; PATH is
191
+ * the fallback. Returning null is normal -- on this machine `dsh` is not on
192
+ * PATH at all -- and simply selects the pnpm path.
193
+ *
194
+ * @param {Record<string,string|undefined>} [env]
195
+ * @returns {string|null}
196
+ */
197
+ export function resolveDshPath(env = process.env) {
198
+ const explicit = env.W2M_DSH_CLI && env.W2M_DSH_CLI.trim() !== '' ? env.W2M_DSH_CLI.trim() : null;
199
+ if (explicit) return explicit;
200
+
201
+ const isWin = process.platform === 'win32';
202
+ const names = isWin ? ['dsh.cmd', 'dsh.exe', 'dsh'] : ['dsh'];
203
+ const dirs = String(env.PATH ?? '')
204
+ .split(isWin ? ';' : ':')
205
+ .filter((dir) => dir.trim() !== '');
206
+ for (const dir of dirs) {
207
+ for (const name of names) {
208
+ const candidate = join(dir, name);
209
+ try {
210
+ if (statSync(candidate).isFile()) return candidate;
211
+ } catch {
212
+ /* keep looking */
213
+ }
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+
219
+ /**
220
+ * Locate the pnpm that ships with the DSH runtime.
221
+ *
222
+ * The runtime directory name is not hard-coded: `dsh-runtimes/*` is scanned, so
223
+ * a second runtime (or a renamed primary) is found too. `W2M_PNPM` pins it.
224
+ *
225
+ * @param {Record<string,string|undefined>} [env]
226
+ * @returns {string|null}
227
+ */
228
+ export function resolveRuntimePnpm(env = process.env) {
229
+ const explicit = env.W2M_PNPM && env.W2M_PNPM.trim() !== '' ? env.W2M_PNPM.trim() : null;
230
+ if (explicit) return existsSync(explicit) ? explicit : null;
231
+
232
+ const runtimesDir = join(dshHome(env), 'dsh-runtimes');
233
+ let runtimes;
234
+ try {
235
+ runtimes = readdirSync(runtimesDir);
236
+ } catch {
237
+ return null;
238
+ }
239
+ for (const runtime of runtimes) {
240
+ const candidate = join(runtimesDir, runtime, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs');
241
+ if (existsSync(candidate)) return candidate;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Decide which supported entry point to use, and with exactly which argv.
248
+ *
249
+ * Pure and exported so the choice can be asserted without running anything.
250
+ *
251
+ * @param {object} input
252
+ * @param {string} input.profileDir
253
+ * @param {string} input.profileName
254
+ * @param {string} input.stagedTarball
255
+ * @param {string|null} [input.dshPath]
256
+ * @param {string|null} [input.pnpmPath]
257
+ * @param {string} [input.nodePath]
258
+ * @returns {{via: 'dsh'|'pnpm', argv: string[], cwd: string}|null}
259
+ */
260
+ export function planInstall(input) {
261
+ const { profileDir, profileName, stagedTarball } = input;
262
+ const dshPath = input.dshPath ?? null;
263
+ const pnpmPath = input.pnpmPath ?? null;
264
+ const nodePath = input.nodePath ?? process.execPath;
265
+
266
+ if (dshPath) {
267
+ return {
268
+ via: 'dsh',
269
+ // `dsh plugin --profile <name> add <tarball>` is the supported surface;
270
+ // everything below it (pnpm, the lockfile) is the host's business.
271
+ argv: [dshPath, 'plugin', '--profile', profileName, 'add', stagedTarball],
272
+ cwd: profileDir,
273
+ };
274
+ }
275
+ if (pnpmPath) {
276
+ return {
277
+ via: 'pnpm',
278
+ // pnpm.mjs needs internal Node APIs, hence --expose-internals.
279
+ argv: [nodePath, '--expose-internals', pnpmPath, 'add', stagedTarball, '--dir', profileDir],
280
+ cwd: profileDir,
281
+ };
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * The production runner: start a process with an argv array, capture its
288
+ * streams, never involve a shell.
289
+ *
290
+ * @param {object} [options]
291
+ * @param {typeof spawn} [options.spawnImpl]
292
+ * @param {number} [options.timeoutMs]
293
+ * @returns {(spec: {argv: string[], cwd: string}) => Promise<{code: number|null, stdout: string, stderr: string, spawnError: {code: string, message: string}|null, timedOut: boolean}>}
294
+ */
295
+ export function createChildProcessRunner(options = {}) {
296
+ const spawnImpl = options.spawnImpl ?? spawn;
297
+ const timeoutMs = options.timeoutMs ?? 15 * 60 * 1000;
298
+ const captureLimit = options.captureLimit ?? 256 * 1024;
299
+
300
+ return (spec) =>
301
+ new Promise((resolvePromise) => {
302
+ let child;
303
+ try {
304
+ child = spawnImpl(spec.argv[0], spec.argv.slice(1), {
305
+ cwd: spec.cwd,
306
+ shell: false,
307
+ windowsHide: true,
308
+ stdio: ['ignore', 'pipe', 'pipe'],
309
+ env: spec.env ?? process.env,
310
+ });
311
+ } catch (error) {
312
+ resolvePromise({
313
+ code: null,
314
+ stdout: '',
315
+ stderr: '',
316
+ spawnError: { code: error?.code ?? 'SPAWN_FAILED', message: String(error?.message ?? error) },
317
+ timedOut: false,
318
+ });
319
+ return;
320
+ }
321
+
322
+ let stdout = '';
323
+ let stderr = '';
324
+ let timedOut = false;
325
+ let settled = false;
326
+ const timer =
327
+ timeoutMs > 0
328
+ ? setTimeout(() => {
329
+ timedOut = true;
330
+ try {
331
+ child.kill('SIGTERM');
332
+ } catch {
333
+ /* already gone */
334
+ }
335
+ }, timeoutMs)
336
+ : null;
337
+
338
+ const append = (current, chunk) =>
339
+ current.length >= captureLimit ? current : (current + chunk.toString('utf8')).slice(-captureLimit);
340
+
341
+ child.stdout?.on('data', (chunk) => {
342
+ stdout = append(stdout, chunk);
343
+ });
344
+ child.stderr?.on('data', (chunk) => {
345
+ stderr = append(stderr, chunk);
346
+ });
347
+ child.on('error', (error) => {
348
+ if (settled) return;
349
+ settled = true;
350
+ if (timer) clearTimeout(timer);
351
+ resolvePromise({
352
+ code: null,
353
+ stdout,
354
+ stderr,
355
+ spawnError: { code: error?.code ?? 'SPAWN_FAILED', message: String(error?.message ?? error) },
356
+ timedOut,
357
+ });
358
+ });
359
+ child.on('close', (code) => {
360
+ if (settled) return;
361
+ settled = true;
362
+ if (timer) clearTimeout(timer);
363
+ resolvePromise({ code, stdout, stderr, spawnError: null, timedOut });
364
+ });
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Install an update into a profile.
370
+ *
371
+ * @param {object} options
372
+ * @param {string} options.tarballPath Downloaded release tarball.
373
+ * @param {string} options.profileDir Profile to update (e.g. `<DSH_HOME>/profiles/naudl`).
374
+ * @param {string} options.expectedSha256 Expected sha256 from `SHA256SUMS`.
375
+ * @param {(spec: {argv: string[], cwd: string, via: string}) => Promise<object>} [options.runner]
376
+ * @param {boolean} [options.dryRun] Report only: no writes, no package manager.
377
+ * @param {string} [options.backupDir] Where the pre-update files are copied.
378
+ * @param {string} [options.stagingDir] Where the tarball is staged.
379
+ * @param {string} [options.profileName] Profile name for `dsh plugin --profile`.
380
+ * @param {string|null} [options.dshPath] null forces the pnpm path.
381
+ * @param {string|null} [options.pnpmPath]
382
+ * @param {string} [options.nodePath]
383
+ * @param {number} [options.stderrTailBytes]
384
+ * @param {Date} [options.now] Injectable clock (deterministic backup directory).
385
+ * @returns {Promise<object>} `{ok, from, to, installedVia, backupPath, rolledBack, error?}`
386
+ */
387
+ export async function installUpdate(options = {}) {
388
+ const {
389
+ tarballPath,
390
+ profileDir,
391
+ expectedSha256,
392
+ dryRun = false,
393
+ stderrTailBytes = DEFAULT_STDERR_TAIL_BYTES,
394
+ now = new Date(),
395
+ } = options;
396
+
397
+ /** Ordered account of what happened; the caller may show it verbatim. */
398
+ const steps = [];
399
+ /** @param {string} message */
400
+ const step = (message) => {
401
+ steps.push(message);
402
+ };
403
+
404
+ /**
405
+ * @param {string} message
406
+ * @param {object} [extra]
407
+ */
408
+ const failure = (message, extra = {}) => ({
409
+ ok: false,
410
+ dryRun,
411
+ from: extra.from ?? null,
412
+ to: extra.to ?? null,
413
+ installedVia: extra.installedVia ?? null,
414
+ backupPath: extra.backupPath ?? null,
415
+ rolledBack: extra.rolledBack ?? false,
416
+ error: message,
417
+ stderrTail: extra.stderrTail ?? null,
418
+ sha256: extra.sha256 ?? null,
419
+ stagedPath: extra.stagedPath ?? null,
420
+ argv: extra.argv ?? null,
421
+ steps,
422
+ });
423
+
424
+ // ---- 0. inputs --------------------------------------------------------
425
+ if (typeof tarballPath !== 'string' || tarballPath === '') {
426
+ return failure('installUpdate: tarballPath is required');
427
+ }
428
+ if (typeof profileDir !== 'string' || profileDir === '') {
429
+ return failure('installUpdate: profileDir is required');
430
+ }
431
+ if (typeof expectedSha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(expectedSha256.trim())) {
432
+ // No checksum, no install: this is the one gate that cannot be skipped.
433
+ return failure(
434
+ 'installUpdate: expectedSha256 must be a 64-character hex sha256 (from the release SHA256SUMS)',
435
+ );
436
+ }
437
+ const absoluteProfile = resolve(profileDir);
438
+ if (!existsSync(absoluteProfile)) {
439
+ return failure(`installUpdate: profile directory does not exist: ${absoluteProfile}`);
440
+ }
441
+ if (!existsSync(tarballPath)) {
442
+ return failure(`installUpdate: tarball does not exist: ${tarballPath}`);
443
+ }
444
+
445
+ const profileName = options.profileName ?? basename(absoluteProfile);
446
+ const stagingDir = options.stagingDir
447
+ ? resolve(options.stagingDir)
448
+ : join(absoluteProfile, STAGING_DIR_NAME);
449
+ const stagedTarball = join(stagingDir, basename(tarballPath));
450
+ const dshPath = options.dshPath !== undefined ? options.dshPath : resolveDshPath();
451
+ const pnpmPath = options.pnpmPath !== undefined ? options.pnpmPath : resolveRuntimePnpm();
452
+ const nodePath = options.nodePath ?? process.execPath;
453
+ const runner = options.runner ?? createChildProcessRunner(options.runnerOptions);
454
+
455
+ // ---- 1. verify --------------------------------------------------------
456
+ let actualSha256;
457
+ try {
458
+ actualSha256 = await sha256File(tarballPath);
459
+ } catch (error) {
460
+ return failure(`installUpdate: could not read the tarball: ${error?.message ?? error}`);
461
+ }
462
+ const expected = expectedSha256.trim().toLowerCase();
463
+ if (actualSha256 !== expected) {
464
+ // The critical property: nothing has been written at this point, and
465
+ // nothing will be.
466
+ step(`sha256 mismatch: expected ${expected}, got ${actualSha256}`);
467
+ return failure(
468
+ `sha256 mismatch: expected ${expected} but the tarball hashes to ${actualSha256}; nothing was written`,
469
+ { sha256: actualSha256 },
470
+ );
471
+ }
472
+ step(`sha256 verified: ${actualSha256}`);
473
+
474
+ const manifest = readTarballManifest(tarballPath);
475
+ const to = manifest?.version ?? null;
476
+ const packageName = manifest?.name ?? options.packageName ?? DEFAULT_PACKAGE_NAME;
477
+ const from = readInstalledVersion(absoluteProfile, packageName);
478
+
479
+ const plan = planInstall({
480
+ profileDir: absoluteProfile,
481
+ profileName,
482
+ stagedTarball,
483
+ dshPath,
484
+ pnpmPath,
485
+ nodePath,
486
+ });
487
+
488
+ // ---- 5. dryRun --------------------------------------------------------
489
+ if (dryRun) {
490
+ step('dry run: no files written, no package manager executed');
491
+ return {
492
+ ok: plan !== null,
493
+ dryRun: true,
494
+ from,
495
+ to,
496
+ installedVia: plan?.via ?? null,
497
+ backupPath: null,
498
+ rolledBack: false,
499
+ error: plan
500
+ ? null
501
+ : 'installUpdate: no supported installer available (set W2M_DSH_CLI to the dsh CLI, or W2M_PNPM to the runtime pnpm)',
502
+ stderrTail: null,
503
+ sha256: actualSha256,
504
+ stagedPath: stagedTarball,
505
+ argv: plan?.argv ?? null,
506
+ plan,
507
+ steps,
508
+ };
509
+ }
510
+
511
+ // Refuse *before* writing anything: without a supported installer there is
512
+ // nothing to try, so creating a staging directory and a backup would be
513
+ // clutter at best and a half-applied profile at worst.
514
+ if (!plan) {
515
+ step('no supported installer available; nothing was written');
516
+ return failure(
517
+ 'installUpdate: no supported installer available (set W2M_DSH_CLI to the dsh CLI, or W2M_PNPM to the runtime pnpm)',
518
+ { sha256: actualSha256, from, to },
519
+ );
520
+ }
521
+
522
+ // ---- 3. stage atomically ---------------------------------------------
523
+ let tempStage = null;
524
+ try {
525
+ mkdirSync(stagingDir, { recursive: true });
526
+ if (resolve(tarballPath) !== resolve(stagedTarball)) {
527
+ tempStage = `${stagedTarball}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
528
+ copyFileSync(tarballPath, tempStage);
529
+ renameSync(tempStage, stagedTarball);
530
+ tempStage = null;
531
+ step(`staged ${basename(tarballPath)} into ${stagingDir}`);
532
+ } else {
533
+ step('tarball already staged in place');
534
+ }
535
+ } catch (error) {
536
+ if (tempStage) safeRemove(tempStage);
537
+ return failure(`installUpdate: could not stage the tarball: ${error?.message ?? error}`, {
538
+ sha256: actualSha256,
539
+ });
540
+ }
541
+
542
+ // ---- 2. back up -------------------------------------------------------
543
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
544
+ const backupPath = options.backupDir
545
+ ? resolve(options.backupDir)
546
+ : join(stagingDir, 'backups', stamp);
547
+ /** @type {Array<{name: string, existed: boolean, sha256: string|null}>} */
548
+ const backups = [];
549
+ try {
550
+ mkdirSync(backupPath, { recursive: true });
551
+ for (const name of BACKED_UP_FILES) {
552
+ const source = join(absoluteProfile, name);
553
+ if (existsSync(source)) {
554
+ copyFileSync(source, join(backupPath, name));
555
+ backups.push({ name, existed: true, sha256: await sha256File(source) });
556
+ } else {
557
+ backups.push({ name, existed: false, sha256: null });
558
+ }
559
+ }
560
+ step(`backed up ${backups.filter((entry) => entry.existed).map((entry) => entry.name).join(', ') || 'nothing'} to ${backupPath}`);
561
+ } catch (error) {
562
+ return failure(`installUpdate: could not back up the profile: ${error?.message ?? error}`, {
563
+ sha256: actualSha256,
564
+ backupPath,
565
+ });
566
+ }
567
+
568
+ // ---- 4. act -----------------------------------------------------------
569
+ // Snapshot what the resolved tree looks like *before* the package manager can
570
+ // touch it. Rollback restores the manifest and the lockfile; it cannot undo a
571
+ // half-written `node_modules`, so this is what lets the result say whether the
572
+ // profile is actually back in a usable state instead of assuming it.
573
+ const installedBeforeRun = readInstalledVersion(absoluteProfile, packageName);
574
+ let result;
575
+ try {
576
+ result = await runner({ argv: plan.argv, cwd: plan.cwd, via: plan.via });
577
+ } catch (error) {
578
+ result = {
579
+ code: null,
580
+ stdout: '',
581
+ stderr: '',
582
+ spawnError: { code: 'RUNNER_THREW', message: String(error?.message ?? error) },
583
+ timedOut: false,
584
+ };
585
+ }
586
+
587
+ const stderrTail = tailBytes(result?.stderr ?? '', stderrTailBytes);
588
+ const stdoutTail = tailBytes(result?.stdout ?? '', stderrTailBytes);
589
+ const spawnFailed = result?.spawnError ?? null;
590
+ const succeeded = !spawnFailed && result?.code === 0;
591
+
592
+ if (!succeeded) {
593
+ // Which stream carries the explanation is not something to assume: measured
594
+ // against pnpm 11.7.0 (via the runtime's pnpm.mjs, piped stdio, Windows),
595
+ // resolution errors are printed to **stdout**, not stderr -- the opposite of
596
+ // the usual convention. Keeping only stderr would have thrown away the one
597
+ // sentence that says why the update failed, so the tail of whichever stream
598
+ // actually said something is what reaches `error`.
599
+ const detail =
600
+ stderrTail.text.trim() !== ''
601
+ ? { stream: 'stderr', tail: stderrTail }
602
+ : stdoutTail.text.trim() !== ''
603
+ ? { stream: 'stdout', tail: stdoutTail }
604
+ : null;
605
+ const reason = spawnFailed
606
+ ? `could not run ${plan.via} (${spawnFailed.code}: ${spawnFailed.message})`
607
+ : result?.timedOut
608
+ ? `${plan.via} timed out`
609
+ : `${plan.via} exited with code ${result?.code}`;
610
+ const rolledBack = rollback(absoluteProfile, backupPath, backups, step);
611
+ const installedAfterRollback = readInstalledVersion(absoluteProfile, packageName);
612
+ const treeIntact = installedAfterRollback === installedBeforeRun;
613
+ const reconciliation = treeIntact
614
+ ? null
615
+ : `node_modules no longer matches the restored manifest (installed version was ${
616
+ installedBeforeRun ?? 'absent'
617
+ }, is now ${installedAfterRollback ?? 'absent'}); reconcile it with \`${reconcileCommand({
618
+ via: plan.via,
619
+ profileDir: absoluteProfile,
620
+ profileName,
621
+ pnpmPath,
622
+ nodePath,
623
+ })}\``;
624
+ step(
625
+ `failed: ${reason}; rollback ${rolledBack ? 'restored the previous files' : 'FAILED'}${
626
+ reconciliation ? `; ${reconciliation}` : '; resolved tree is unchanged'
627
+ }`,
628
+ );
629
+ return {
630
+ ok: false,
631
+ dryRun: false,
632
+ from,
633
+ to,
634
+ installedVia: plan.via,
635
+ backupPath,
636
+ rolledBack,
637
+ /** Files are restored **and** the resolved tree still matches them. */
638
+ rollbackComplete: rolledBack && treeIntact,
639
+ installedVersionBefore: installedBeforeRun,
640
+ installedVersionAfterRollback: installedAfterRollback,
641
+ reconciliation,
642
+ error: `${reason}${detail ? `\n--- ${plan.via} ${detail.stream} (tail) ---\n${detail.tail.text}` : ''}${
643
+ reconciliation ? `\n${reconciliation}` : ''
644
+ }`,
645
+ stderrTail: stderrTail.text,
646
+ stdoutTail: stdoutTail.text,
647
+ detailStream: detail?.stream ?? null,
648
+ stderrTruncatedBytes: stderrTail.truncatedBytes,
649
+ stdoutTruncatedBytes: stdoutTail.truncatedBytes,
650
+ sha256: actualSha256,
651
+ stagedPath: stagedTarball,
652
+ argv: plan.argv,
653
+ exitCode: result?.code ?? null,
654
+ steps,
655
+ };
656
+ }
657
+
658
+ step(`${plan.via} completed: ${plan.argv.slice(0, 2).join(' ')} …`);
659
+ // Verify the outcome instead of trusting the exit code: "pnpm said 0" and
660
+ // "the profile now has the new version" are different statements, and only
661
+ // the second one is what the caller asked for.
662
+ const installedVersion = readInstalledVersion(absoluteProfile, packageName);
663
+ step(`installed version is now ${installedVersion ?? '<not found in node_modules>'}`);
664
+ return {
665
+ ok: true,
666
+ dryRun: false,
667
+ from,
668
+ to,
669
+ installedVersion,
670
+ installedVia: plan.via,
671
+ backupPath,
672
+ rolledBack: false,
673
+ error: null,
674
+ stderrTail: stderrTail.text || null,
675
+ sha256: actualSha256,
676
+ stagedPath: stagedTarball,
677
+ argv: plan.argv,
678
+ exitCode: 0,
679
+ steps,
680
+ };
681
+ }
682
+
683
+ /**
684
+ * The command a human (or the caller) should run to make `node_modules` agree
685
+ * with the restored manifest again.
686
+ *
687
+ * Only produced when the tree is known to disagree, and only ever *reported*:
688
+ * running it automatically would be a second takeover of a profile that is
689
+ * already in an uncertain state.
690
+ *
691
+ * @param {{via: string, profileDir: string, profileName: string, pnpmPath: string|null, nodePath: string}} input
692
+ * @returns {string}
693
+ */
694
+ export function reconcileCommand(input) {
695
+ if (input.via === 'dsh') {
696
+ return `dsh plugin --profile ${input.profileName} install`;
697
+ }
698
+ if (input.pnpmPath) {
699
+ return `${input.nodePath} --expose-internals ${input.pnpmPath} install --frozen-lockfile --dir ${input.profileDir}`;
700
+ }
701
+ return `pnpm install --frozen-lockfile --dir ${input.profileDir}`;
702
+ }
703
+
704
+ /**
705
+ * Restore the backed-up files, byte for byte.
706
+ *
707
+ * A file that did not exist before is removed again: "the lockfile pnpm just
708
+ * created" must not survive a rolled-back update, or the next install would
709
+ * trust a lock that describes a version we never applied.
710
+ *
711
+ * @param {string} profileDir
712
+ * @param {string} backupPath
713
+ * @param {Array<{name: string, existed: boolean}>} backups
714
+ * @param {(message: string) => void} [step]
715
+ * @returns {boolean} Whether every file is back to its pre-update state.
716
+ */
717
+ export function rollback(profileDir, backupPath, backups, step = () => {}) {
718
+ let ok = true;
719
+ for (const entry of backups) {
720
+ const target = join(profileDir, entry.name);
721
+ try {
722
+ if (entry.existed) {
723
+ copyFileSync(join(backupPath, entry.name), target);
724
+ step(`restored ${entry.name}`);
725
+ } else if (existsSync(target)) {
726
+ rmSync(target, { force: true });
727
+ step(`removed ${entry.name} (did not exist before the update)`);
728
+ }
729
+ } catch (error) {
730
+ ok = false;
731
+ step(`could not restore ${entry.name}: ${error?.message ?? error}`);
732
+ }
733
+ }
734
+ return ok;
735
+ }
736
+
737
+ /**
738
+ * Version currently installed in the profile, or null.
739
+ *
740
+ * Read-only, and tolerant: a profile without `node_modules` yet is a normal
741
+ * first-install situation, not an error.
742
+ *
743
+ * @param {string} profileDir
744
+ * @param {string} packageName
745
+ * @returns {string|null}
746
+ */
747
+ export function readInstalledVersion(profileDir, packageName) {
748
+ try {
749
+ const manifestPath = join(profileDir, 'node_modules', ...packageName.split('/'), 'package.json');
750
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
751
+ return typeof manifest.version === 'string' ? manifest.version : null;
752
+ } catch {
753
+ return null;
754
+ }
755
+ }
756
+
757
+ /** @param {string} file */
758
+ function safeRemove(file) {
759
+ try {
760
+ rmSync(file, { force: true });
761
+ } catch {
762
+ /* best effort */
763
+ }
764
+ }