@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
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 +1288 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +13 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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
|
+
}
|