@avantf/dsh-plugin-base 0.5.3 → 0.6.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.
@@ -0,0 +1,1214 @@
1
+ /**
2
+ * The built-in `runtime-deps` provider: ONE shared, SPEC-ADDRESSED store of runtime npm packages,
3
+ * shared by every plugin that declares the same dependency set.
4
+ *
5
+ * The layout is `<home>/<item.target.root>/<key>/` (the canonical `item.target.root` is
6
+ * {@link RUNTIME_DEPS_DIR}):
7
+ *
8
+ * ```text
9
+ * <key>/
10
+ * package.json # generated from the spec (dependencies + host-provided peers)
11
+ * pnpm-lock.yaml # whatever lockfile the selected manager wrote (or the caller's copy)
12
+ * node_modules/
13
+ * .runtime.json # key, packages, lock hash, platform, ABI, attempts, integrity
14
+ * .ready # only after verify + the core's atomic rename
15
+ * install.json # the provisioner's own version manifest (written by the core)
16
+ * ```
17
+ *
18
+ * `key` is `sha256(canonical packages + verify entries + lockfile hash + platform + arch + Node ABI +
19
+ * manager name/version per attempt + attempt (source + env + allowScripts) + peer name/range/resolved
20
+ * host copy (dir + version))`.
21
+ * A changed package/verify/lock/env/platform/peer range is therefore a NEW root; an existing root is
22
+ * never mutated. The key is a digest of the DECLARED spec, not of the bytes a manager fetched: without
23
+ * a caller lockfile the same key can install different package versions at different times or on
24
+ * different machines. What the root does guarantee is "installed once, then never rewritten locally".
25
+ *
26
+ * The provider names no package: everything it installs, verifies and links comes from
27
+ * {@link RuntimeDepsSpec}. Host singletons are declared in `spec.peers` and are LINKED from the
28
+ * caller's tree, never fetched from a registry (they are written as optional peer dependencies and
29
+ * the managers are told not to auto-install peers); both the range and the version actually linked
30
+ * into the root are part of what the root has to satisfy, and a reuse compares the actual link
31
+ * TARGET with the resolved host copy. A copy fallback (a filesystem that refuses symlinks) has no
32
+ * target to compare: its identity is version-level, and `.runtime.json` says so in `copiedPeers`.
33
+ *
34
+ * A failed install records every attempt (in the error and in the provisioner's report/status),
35
+ * removes its own staging tree and leaves no root behind — a failed item is a
36
+ * report, never a blocked plugin mount. Garbage collection is EXPLICIT (`gcRuntimeDeps` on
37
+ * `./internal`), never at startup.
38
+ *
39
+ * @module providers/runtime_deps
40
+ */
41
+ import { spawn } from 'node:child_process';
42
+ import { createHash } from 'node:crypto';
43
+ import { createRequire } from 'node:module';
44
+ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
45
+ import { ProvisionError, reasonOf } from '../errors.js';
46
+ import { defaultFs, exists, linkOrCopy } from '../fs.js';
47
+ import { sriOfSha256 } from '../integrity.js';
48
+ import { assertSafeRelativePath, versionSegment } from '../layout.js';
49
+ import { defaultLock, pidIsAlive } from '../lock.js';
50
+ import { decodeJson } from '../manifest.js';
51
+ import { assertPackageName } from '../package-name.js';
52
+ import { assertRange, satisfiesRange } from '../semver.js';
53
+ import { readStatus } from '../state.js';
54
+ import { packageVersionOf, resolvePeerDir } from './npm.js';
55
+ /** The kind the runtime-dependency provider claims. */
56
+ export const RUNTIME_DEPS_KIND = 'runtime-deps';
57
+ /**
58
+ * The canonical `item.target.root` for the shared store. A caller may put the store elsewhere, but
59
+ * then it is a different shared root: the store is shared through the HOME + target.root pair.
60
+ */
61
+ export const RUNTIME_DEPS_DIR = 'deps/runtime';
62
+ /** The per-root record, written into staging before `.ready`. */
63
+ const RECORD_FILE = '.runtime.json';
64
+ /** The completion marker; the root is immutable once it exists. */
65
+ const READY_FILE = '.ready';
66
+ /** Per-key locks live here, beside (not inside) the roots they protect. */
67
+ const LOCKS_DIR = '.locks';
68
+ /** Staging entries are SIBLINGS of the key roots (same path depth), so a manager's relative
69
+ * symlinks (npm `file:` deps, pnpm's `node_modules` links) survive the atomic rename unchanged. */
70
+ const STAGING_PREFIX = '.staging-';
71
+ /** The record/completion schema this build writes and reads. */
72
+ const RECORD_SCHEMA_VERSION = 1;
73
+ /** How long a waiter waits for a concurrent writer of the same key. */
74
+ const DEFAULT_LOCK_TIMEOUT_MS = 300_000;
75
+ /** A lock whose holder pid is gone may be reclaimed after this age. */
76
+ const DEFAULT_STALE_LOCK_MS = 10_000;
77
+ /** Cap on the manager output kept for diagnostics (each stream). */
78
+ const MAX_OUTPUT_CHARS = 16_384;
79
+ /** Staging sequence, monotonic per process (the `.staging-…` sibling names stay unique). */
80
+ let stagingSequence = 0;
81
+ /** Lockfile names a package manager actually consumes. */
82
+ const MANAGER_LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'];
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ // Pure helpers
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ function isRecord(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
88
+ }
89
+ function text(value) {
90
+ return typeof value === 'string' && value !== '' ? value : undefined;
91
+ }
92
+ function sortedRecord(value) {
93
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
94
+ }
95
+ /** JSON with object keys sorted recursively; the content-address input must not depend on insertion order. */
96
+ function canonicalJson(value) {
97
+ if (value === undefined)
98
+ return 'null';
99
+ if (value === null || typeof value !== 'object')
100
+ return JSON.stringify(value);
101
+ if (Array.isArray(value))
102
+ return `[${value.map(entry => canonicalJson(entry)).join(',')}]`;
103
+ const record = value;
104
+ const entries = Object.keys(record)
105
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
106
+ .map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
107
+ return `{${entries.join(',')}}`;
108
+ }
109
+ function sha256Hex(value) {
110
+ return createHash('sha256').update(value).digest('hex');
111
+ }
112
+ function encodeJson(value) {
113
+ return new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`);
114
+ }
115
+ function tail(value, limit = 400) {
116
+ const trimmed = value.trim();
117
+ if (trimmed.length <= limit)
118
+ return trimmed;
119
+ return `…${trimmed.slice(trimmed.length - limit)}`;
120
+ }
121
+ /**
122
+ * Scrub credentials out of text that came from a package manager.
123
+ *
124
+ * A failed attempt's `stderr`/`stdout` ends up in `status.json.last_error`, in the log and in the
125
+ * item's reason, and a manager happily echoes the URL it talked to — userinfo, a scoped-registry
126
+ * `_authToken`, a proxy/bearer token, a `password=`/`token=` parameter. The attempt's `env` VALUES are
127
+ * never persisted (only their keys), so this is the other half of the same promise. Scrubbing is
128
+ * deliberately blunt: a false positive costs a masked word in a diagnostic, a miss costs a token.
129
+ */
130
+ function redact(value) {
131
+ return (value
132
+ // `Authorization: Bearer …` / `Basic …` FIRST: the scheme word itself is not the secret, and the
133
+ // auth-ish `key: value` rule below would otherwise strip the scheme and leave the token behind.
134
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{4,}/giu, '$1 ***')
135
+ // `//registry.example/:_authToken=…`, `_auth=…`, `_password=…`, `token: …`
136
+ .replace(/\b(_authToken|_auth|_password|_token|token|password|secret|authorization)\b(\s*[:=]\s*)"?[^\s"&,;]+"?/giu, '$1$2***')
137
+ // `https://user:pass@host/…`, `http://token@host/…`
138
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/giu, '$1***@'));
139
+ }
140
+ /** The shared store root for one item. */
141
+ export function runtimeDepsStore(home, targetRoot = RUNTIME_DEPS_DIR) {
142
+ assertSafeRelativePath(targetRoot);
143
+ // `join` is the SAME normalisation the core's `publish()` applies; normalising separators here
144
+ // instead would make probe and publish disagree on a POSIX path such as `deps\\runtime`.
145
+ const store = join(home, targetRoot);
146
+ if (store === home)
147
+ throw new ProvisionError('invalid-option', `runtime-deps 的 target.root 不能是 home 本身:${JSON.stringify(targetRoot)}`);
148
+ return store;
149
+ }
150
+ /** The per-key lock path; the lock files sit beside the roots, never inside one. */
151
+ export function runtimeDepsLockPath(store, key) {
152
+ return join(store, LOCKS_DIR, `${key}.lock`);
153
+ }
154
+ /** Parse and validate the generic spec. Base tells no package what to do; it only checks shapes. */
155
+ function parseSpec(item) {
156
+ const raw = item.spec;
157
+ if (!isRecord(raw))
158
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec 必须是对象:${item.id}`);
159
+ const packagesRaw = raw['packages'];
160
+ if (!isRecord(packagesRaw) || Object.keys(packagesRaw).length === 0) {
161
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.packages 必须是非空对象:${item.id}`);
162
+ }
163
+ const packages = {};
164
+ for (const [name, spec] of Object.entries(packagesRaw)) {
165
+ assertPackageName(name);
166
+ if (typeof spec !== 'string' || spec.trim() === '') {
167
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.packages["${name}"] 必须是非空的依赖说明:${item.id}`);
168
+ }
169
+ packages[name] = spec;
170
+ }
171
+ const sourcesRaw = raw['sources'];
172
+ if (!Array.isArray(sourcesRaw) || sourcesRaw.length === 0) {
173
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.sources 必须是非空数组:${item.id}`);
174
+ }
175
+ const sources = [];
176
+ const sourceIds = new Set();
177
+ for (const [index, candidate] of sourcesRaw.entries()) {
178
+ if (!isRecord(candidate))
179
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.sources[${String(index)}] 必须是对象:${item.id}`);
180
+ const id = text(candidate['id']);
181
+ if (id === undefined)
182
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.sources[${String(index)}].id 缺失:${item.id}`);
183
+ if (sourceIds.has(id))
184
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.sources 中 id 重复:${id}(${item.id})`);
185
+ sourceIds.add(id);
186
+ const registry = text(candidate['registry']);
187
+ const cache = text(candidate['cache']);
188
+ if (registry === undefined && cache === undefined) {
189
+ throw new ProvisionError('invalid-option', `runtime-deps 的 source "${id}" 至少要给 registry 或 cache 之一:${item.id}`);
190
+ }
191
+ sources.push({
192
+ id,
193
+ ...(registry === undefined ? {} : { registry }),
194
+ ...(cache === undefined ? {} : { cache }),
195
+ });
196
+ }
197
+ const attemptsRaw = raw['attempts'];
198
+ if (!Array.isArray(attemptsRaw) || attemptsRaw.length === 0) {
199
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.attempts 必须是非空数组:${item.id}`);
200
+ }
201
+ const attempts = [];
202
+ for (const [index, candidate] of attemptsRaw.entries()) {
203
+ if (!isRecord(candidate))
204
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.attempts[${String(index)}] 必须是对象:${item.id}`);
205
+ const source = text(candidate['source']);
206
+ if (source === undefined || !sourceIds.has(source)) {
207
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.attempts[${String(index)}].source 必须是已声明的 source id:${String(source)}(${item.id})`);
208
+ }
209
+ const manager = candidate['manager'];
210
+ if (manager !== undefined && manager !== 'npm' && manager !== 'pnpm') {
211
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.attempts[${String(index)}].manager 只能是 npm 或 pnpm:${String(manager)}(${item.id})`);
212
+ }
213
+ const env = candidate['env'];
214
+ if (env !== undefined) {
215
+ if (!isRecord(env) || Object.values(env).some(value => typeof value !== 'string')) {
216
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.attempts[${String(index)}].env 必须是 string→string:${item.id}`);
217
+ }
218
+ }
219
+ const allowScripts = candidate['allowScripts'];
220
+ if (allowScripts !== undefined && typeof allowScripts !== 'boolean') {
221
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.attempts[${String(index)}].allowScripts 必须是布尔值:${item.id}`);
222
+ }
223
+ attempts.push({
224
+ source,
225
+ ...(manager === undefined ? {} : { manager }),
226
+ ...(env === undefined ? {} : { env: env }),
227
+ ...(allowScripts === undefined ? {} : { allowScripts }),
228
+ });
229
+ }
230
+ const verifyRaw = raw['verify'];
231
+ if (!Array.isArray(verifyRaw) || verifyRaw.some(entry => typeof entry !== 'string' || entry.trim() === '')) {
232
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.verify 必须是字符串数组:${item.id}`);
233
+ }
234
+ // An empty `verify` would let a manager that exits 0 without installing anything still pass
235
+ // `verifyTree` (package.json/node_modules exist). A root must resolve at least one declared entry.
236
+ if (verifyRaw.length === 0) {
237
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.verify 不能为空(至少一个可解析项,通常取 spec.packages 的键):${item.id}`);
238
+ }
239
+ const peersRaw = raw['peers'];
240
+ const peers = {};
241
+ if (peersRaw !== undefined) {
242
+ if (!isRecord(peersRaw))
243
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.peers 必须是对象:${item.id}`);
244
+ for (const [name, range] of Object.entries(peersRaw)) {
245
+ assertPackageName(name, 'peer 名');
246
+ if (typeof range !== 'string' || range.trim() === '') {
247
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.peers["${name}"] 必须是非空区间:${item.id}`);
248
+ }
249
+ // A range outside the supported semver subset must not be "assumed satisfied": it is a
250
+ // declaration error and it would decide which host singleton a shared root may carry.
251
+ try {
252
+ assertRange(range);
253
+ }
254
+ catch (error) {
255
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.peers["${name}"] 不是受支持的版本区间:${JSON.stringify(range)}(${reasonOf(error).message})`);
256
+ }
257
+ peers[name] = range;
258
+ }
259
+ }
260
+ const lockfile = raw['lockfile'];
261
+ if (lockfile !== undefined && (typeof lockfile !== 'string' || lockfile.trim() === '')) {
262
+ throw new ProvisionError('invalid-option', `runtime-deps 的 spec.lockfile 必须是路径字符串:${item.id}`);
263
+ }
264
+ return {
265
+ packages,
266
+ sources,
267
+ attempts,
268
+ verify: verifyRaw,
269
+ ...(peersRaw === undefined ? {} : { peers }),
270
+ ...(lockfile === undefined ? {} : { lockfile }),
271
+ };
272
+ }
273
+ /**
274
+ * The resource identity: a digest of the declared set, pure and never throwing (declare must not
275
+ * break).
276
+ *
277
+ * `item.target.root` is part of the digest: the SAME spec under a different root is a different
278
+ * physical store, so two such items must not collapse into one status row (and one handle). The
279
+ * content key stays spec-only — it names the DECLARED set, not the store.
280
+ */
281
+ function identityName(item) {
282
+ // `target` is required by the item schema; the optional chain keeps `identify()` total for a
283
+ // malformed item (declare() must never throw).
284
+ const root = item.target?.root;
285
+ let digest;
286
+ try {
287
+ digest = sha256Hex(canonicalJson({ root: root ?? null, spec: item.spec ?? null }));
288
+ }
289
+ catch {
290
+ digest = sha256Hex(`${String(root)}\u0000${String(item.spec)}`);
291
+ }
292
+ return `deps-${digest.slice(0, 16)}`;
293
+ }
294
+ /** The root directory name for one key, using the SAME segment derivation the core applies in
295
+ * `publish()` (`versionSegment`): a very long HOME would otherwise bypass the core's path budget,
296
+ * and probe/plan/install would look at a path `publish()` never creates. */
297
+ function rootNameOf(home, item, key) {
298
+ return versionSegment(home, item.target.root, identityName(item), key);
299
+ }
300
+ /** A source by id (already validated by {@link parseSpec}). */
301
+ function sourceOf(spec, id) {
302
+ const source = spec.sources.find(candidate => candidate.id === id);
303
+ if (source === undefined)
304
+ throw new ProvisionError('invalid-option', `runtime-deps 引用了未声明的 source:${id}`);
305
+ return source;
306
+ }
307
+ /** The manager arguments for one attempt. No package name is ever composed here. */
308
+ function managerArgs(manager, source, attempt) {
309
+ const args = manager === 'npm'
310
+ ? ['install', '--no-audit', '--no-fund', '--loglevel=error', '--legacy-peer-deps']
311
+ : [
312
+ 'install',
313
+ '--reporter=silent',
314
+ '--config.auto-install-peers=false',
315
+ '--config.strict-peer-dependencies=false',
316
+ '--config.confirmModulesPurge=false',
317
+ // The generated manifest is ours alone and the package.json has already been checked by the
318
+ // provider: pnpm's "which dependencies may run build scripts" gate would otherwise block the
319
+ // very postinstall an `allowScripts: true` attempt asked for. Script permission is decided by
320
+ // `--ignore-scripts` below, not by the manager's interactive approval state.
321
+ '--config.strict-dep-builds=false',
322
+ '--ignore-workspace',
323
+ ];
324
+ if (source.registry !== undefined)
325
+ args.push(`--registry=${source.registry}`);
326
+ if (source.cache !== undefined) {
327
+ args.push(manager === 'npm' ? `--cache=${source.cache}` : `--store-dir=${source.cache}`);
328
+ }
329
+ // A cache-only source is a LOCAL store: it must not silently reach the network.
330
+ if (source.registry === undefined && source.cache !== undefined)
331
+ args.push('--offline');
332
+ // Lifecycle scripts are OFF unless the caller's attempt explicitly asks for them.
333
+ if (attempt.allowScripts !== true)
334
+ args.push('--ignore-scripts');
335
+ return args;
336
+ }
337
+ /**
338
+ * npm/pnpm config keys that describe the TRANSPORT (a proxy, a CA bundle), not a package SOURCE.
339
+ * They survive the `npm_config_*` scrub below: an operator's proxy is not an undeclared registry.
340
+ */
341
+ const TRANSPORT_NPM_CONFIG = [
342
+ 'proxy',
343
+ 'http-proxy',
344
+ 'https-proxy',
345
+ 'noproxy',
346
+ 'no-proxy',
347
+ 'cafile',
348
+ 'ca',
349
+ ];
350
+ /** The prefix npm and pnpm read their config from. */
351
+ const NPM_CONFIG_PREFIX = 'npm_config_';
352
+ /**
353
+ * The environment a manager process gets: this process's environment plus the attempt's overrides.
354
+ *
355
+ * When the attempt names a registry, inherited `npm_config_*` (npm and pnpm both read the environment
356
+ * as config) could point the manager at a source the spec never declared — `npm_config_registry`, or a
357
+ * scoped `npm_config_@scope:registry` that even outranks `--registry`. The attempt list is the only
358
+ * place a source may be named, so those keys are dropped for such an attempt. The attempt's OWN
359
+ * `env` is kept verbatim: it is a declaration, and it is part of the content key.
360
+ */
361
+ function attemptEnv(attempt, source) {
362
+ const env = { ...process.env };
363
+ if (source.registry !== undefined) {
364
+ for (const name of Object.keys(env)) {
365
+ if (!name.toLowerCase().startsWith(NPM_CONFIG_PREFIX))
366
+ continue;
367
+ if (TRANSPORT_NPM_CONFIG.includes(name.toLowerCase().slice(NPM_CONFIG_PREFIX.length)))
368
+ continue;
369
+ delete env[name];
370
+ }
371
+ }
372
+ return { ...env, ...(attempt.env ?? {}) };
373
+ }
374
+ /** The absolute lockfile path the spec names. */
375
+ function lockfilePath(lockfile) {
376
+ return isAbsolute(lockfile) ? lockfile : resolvePath(lockfile);
377
+ }
378
+ /** Read the caller's lockfile once; its bytes and digest are part of the key. */
379
+ async function readLockfile(fs, lockfile) {
380
+ if (lockfile === undefined)
381
+ return { bytes: undefined, hash: '', name: undefined };
382
+ const path = lockfilePath(lockfile);
383
+ let bytes;
384
+ try {
385
+ bytes = await fs.readFile(path);
386
+ }
387
+ catch (error) {
388
+ throw new ProvisionError('invalid-option', `runtime-deps 读不到 lockfile:${path}(${reasonOf(error).message})`);
389
+ }
390
+ return { bytes, hash: sha256Hex(bytes), name: basename(path) };
391
+ }
392
+ /** The generated `package.json` for a root: dependencies + optional host peers. */
393
+ function packageJsonOf(spec) {
394
+ const peerNames = Object.keys(spec.peers ?? {}).sort();
395
+ return {
396
+ name: 'dsh-runtime-deps',
397
+ private: true,
398
+ version: '0.0.0',
399
+ description: 'Runtime dependencies managed by @avantf/dsh-plugin-base (spec-addressed, install-once)',
400
+ dependencies: sortedRecord(spec.packages),
401
+ ...(peerNames.length === 0
402
+ ? {}
403
+ : {
404
+ peerDependencies: sortedRecord(spec.peers ?? {}),
405
+ // Host peers are LINKED after the manager run; telling the manager they are optional keeps
406
+ // npm from fetching them from the registry in the first place.
407
+ peerDependenciesMeta: Object.fromEntries(peerNames.map(name => [name, { optional: true }])),
408
+ }),
409
+ };
410
+ }
411
+ /** Remove a leftover install tree (between attempts) without touching the caller's lockfile. */
412
+ async function cleanInstallTree(fs, staging, providedLockName) {
413
+ await fs.rm(join(staging, 'node_modules'), { recursive: true }).catch(() => undefined);
414
+ for (const name of MANAGER_LOCKFILES) {
415
+ if (name === providedLockName)
416
+ continue;
417
+ await fs.rm(join(staging, name)).catch(() => undefined);
418
+ }
419
+ }
420
+ /** Write the generated package.json and (when given) the caller's lockfile into staging. */
421
+ async function seedStaging(fs, staging, spec, lock) {
422
+ await fs.writeFile(join(staging, 'package.json'), encodeJson(packageJsonOf(spec)));
423
+ if (lock.bytes !== undefined && lock.name !== undefined && MANAGER_LOCKFILES.includes(lock.name)) {
424
+ await fs.writeFile(join(staging, lock.name), lock.bytes);
425
+ }
426
+ }
427
+ /** win32 long-path prefix (`\\?\`) that `realpath`/junctions can report; neither the key nor a
428
+ * comparison may see it. */
429
+ function withoutLongPathPrefix(path) {
430
+ return process.platform === 'win32' ? path.replace(/^\\\\\?\\/u, '') : path;
431
+ }
432
+ /**
433
+ * The directory a peer is linked FROM: absolute and, when the IO seam can answer, symlink-free.
434
+ *
435
+ * The key, the symlink TARGET and the probe's `readlink` comparison all use this one string, so the
436
+ * same physical host copy names one root however the caller spelled it (`…/node_modules/zod`,
437
+ * `…/node_modules/../node_modules/zod`, or a symlink to that directory). The key commits to the
438
+ * physical copy, not to a spelling: otherwise one singleton gets linked twice, and a root is built
439
+ * on a path that merely happens to point at the same directory today.
440
+ *
441
+ * An fs without `realpath` (an injected seam) keeps path-level identity. That bound is stated in
442
+ * `docs/RUNTIME_DEPS.md` rather than papered over here.
443
+ */
444
+ async function canonicalPeerDir(ctx, dir) {
445
+ const absolute = withoutLongPathPrefix(resolvePath(dir));
446
+ if (ctx.fs.realpath === undefined)
447
+ return absolute;
448
+ try {
449
+ return withoutLongPathPrefix(await ctx.fs.realpath(absolute));
450
+ }
451
+ catch {
452
+ // Missing or unreadable: keep the spelled (absolute) path. The link then cannot be made and the
453
+ // item fails as `peer/unsatisfied` — the caller's own diagnosis — instead of a realpath error.
454
+ return absolute;
455
+ }
456
+ }
457
+ /** Resolve every declared host peer; a declared peer that cannot be resolved is `peer/unsatisfied`. */
458
+ async function resolvePeers(spec, ctx, peerDirs) {
459
+ const links = [];
460
+ for (const [name, range] of Object.entries(spec.peers ?? {}).sort(([left], [right]) => (left < right ? -1 : 1))) {
461
+ const declared = peerDirs?.[name] ?? resolvePeerDir(name, ctx);
462
+ if (declared === undefined) {
463
+ throw new ProvisionError('peer/unsatisfied', `runtime-deps 的 peer ${name}@${range} 不可满足(调用方未提供目录,也无法从加载 base 的树解析到)`);
464
+ }
465
+ // One string for all three uses — key input, symlink target, probe comparison.
466
+ const dir = await canonicalPeerDir(ctx, declared);
467
+ const installed = await packageVersionOf(ctx, dir);
468
+ // `parseSpec` already rejected an out-of-subset range, so this call cannot throw: a false here
469
+ // means the HOST copy genuinely does not satisfy the declared range.
470
+ if (installed !== undefined && !satisfiesRange(installed, range)) {
471
+ throw new ProvisionError('peer/unsatisfied', `runtime-deps 的 peer ${name} 需要 ${range},调用方解析到的是 ${installed}`);
472
+ }
473
+ links.push({ name, dir, range, version: installed });
474
+ }
475
+ return links;
476
+ }
477
+ /** Link each host peer into the staging tree; a peer already fetched by a manager is replaced.
478
+ * Returns how each peer was placed, because a copy fallback weakens the root's peer identity. */
479
+ async function linkPeers(ctx, staging, peers) {
480
+ const modes = [];
481
+ for (const peer of peers) {
482
+ const linkPath = join(staging, 'node_modules', ...peer.name.split('/'));
483
+ await ctx.fs.rm(linkPath, { recursive: true }).catch(() => undefined);
484
+ const mode = await linkOrCopy(ctx.fs, peer.dir, linkPath, {
485
+ onFallback: () => ctx.logger.warn(`runtime-deps 的 peer ${peer.name} 退化为复制:根对这个 peer 的身份只是版本级(没有 symlink 目标可比),记为 copiedPeers`),
486
+ });
487
+ modes.push({ name: peer.name, mode });
488
+ if (mode === 'linked')
489
+ ctx.logger.debug(`runtime-deps: linked peer ${peer.name} → ${peer.dir}`);
490
+ // linkOrCopy creates the parent directory; the package.json check is the verification half.
491
+ if (!(await exists(ctx.fs, join(linkPath, 'package.json')))) {
492
+ throw new ProvisionError('peer/unsatisfied', `runtime-deps 的 peer ${peer.name} 链接后缺少 package.json:${linkPath}`);
493
+ }
494
+ }
495
+ return modes;
496
+ }
497
+ /** The `version` of the package installed at `dir`, or `undefined` when there is no readable one. */
498
+ async function packageVersionAt(fs, dir) {
499
+ try {
500
+ const raw = decodeJson(await fs.readFile(join(dir, 'package.json')));
501
+ return isRecord(raw) ? text(raw['version']) : undefined;
502
+ }
503
+ catch {
504
+ return undefined;
505
+ }
506
+ }
507
+ /**
508
+ * A path as it should be compared with a peer link target.
509
+ *
510
+ * The target is read the way the OS reads it — relative to the link itself — and, on win32, compared
511
+ * case-insensitively and without the `\\?\` prefix a junction can report: a mismatch here would mean
512
+ * "not this key's root", i.e. a rebuild on EVERY probe, so the comparison must not fail on spelling.
513
+ */
514
+ function comparablePath(path, linkPath) {
515
+ const absolute = withoutLongPathPrefix(resolvePath(dirname(linkPath), path));
516
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
517
+ }
518
+ /** Verify a staged/published root: the caller's `verify` entries plus the peer links. */
519
+ async function verifyTree(fs, root, spec, peers) {
520
+ const packageJson = join(root, 'package.json');
521
+ if (!(await exists(fs, packageJson))) {
522
+ throw new ProvisionError('verify/failed', `runtime-deps 的根缺少 package.json:${root}`);
523
+ }
524
+ if (!(await exists(fs, join(root, 'node_modules')))) {
525
+ throw new ProvisionError('verify/failed', `runtime-deps 的根缺少 node_modules:${root}`);
526
+ }
527
+ const req = createRequire(packageJson);
528
+ for (const entry of spec.verify) {
529
+ let resolved;
530
+ try {
531
+ resolved = req.resolve(entry);
532
+ }
533
+ catch (error) {
534
+ throw new ProvisionError('verify/failed', `runtime-deps 的 verify 项无法解析:${entry}(${reasonOf(error).message})`);
535
+ }
536
+ // `require.resolve` caches its result per process; a file deleted after the first resolve would
537
+ // otherwise still "resolve". The resolved path existing is the half that cannot be cached.
538
+ if (!(await exists(fs, resolved))) {
539
+ throw new ProvisionError('verify/failed', `runtime-deps 的 verify 项解析到不存在的文件:${entry} → ${resolved}`);
540
+ }
541
+ }
542
+ for (const peer of peers) {
543
+ const peerDir = join(root, 'node_modules', ...peer.name.split('/'));
544
+ if (!(await exists(fs, join(peerDir, 'package.json')))) {
545
+ throw new ProvisionError('verify/failed', `runtime-deps 的 peer ${peer.name} 在根里不可解析:${root}`);
546
+ }
547
+ // The key commits to the resolved host COPY, not just to a version, so the link target is what
548
+ // proves a root is still the tree this spec resolved: another directory holding the same version
549
+ // is a different physical instance, and handing it out would give a plugin someone else's singleton.
550
+ // A COPY fallback has no target to compare — its identity is version-level, and `.runtime.json`
551
+ // records that fact in `copiedPeers` (`install`/`probe` explain it); the checks below still apply.
552
+ const linkTarget = await fs.readlink(peerDir);
553
+ if (linkTarget !== undefined && comparablePath(linkTarget, peerDir) !== comparablePath(peer.dir, peerDir)) {
554
+ throw new ProvisionError('verify/failed', `runtime-deps 的 peer ${peer.name} 在根里链接到 ${linkTarget},与本 key 解析到的宿主副本 ${peer.dir} 不一致:${root}`);
555
+ }
556
+ // The LINKED copy is what a plugin actually loads, so its VERSION — not just its existence — has
557
+ // to satisfy the range declared here. Otherwise a root linked against a host singleton of another
558
+ // version would be handed out as if it were this spec's.
559
+ const linked = await packageVersionAt(fs, peerDir);
560
+ if (linked === undefined) {
561
+ throw new ProvisionError('verify/failed', `runtime-deps 的 peer ${peer.name} 在根里没有可读的版本:${peerDir}`);
562
+ }
563
+ const range = spec.peers?.[peer.name] ?? peer.range;
564
+ if (!satisfiesRange(linked, range)) {
565
+ throw new ProvisionError('verify/failed', `runtime-deps 的 peer ${peer.name} 在根里是 ${linked},不满足 ${range}:${root}`);
566
+ }
567
+ }
568
+ }
569
+ /** Read the completion record of a root; anything inconsistent is "not ready", never a throw. */
570
+ async function readReadyRoot(fs, root, key) {
571
+ if (!(await exists(fs, join(root, READY_FILE))))
572
+ return undefined;
573
+ let raw;
574
+ try {
575
+ raw = decodeJson(await fs.readFile(join(root, RECORD_FILE)));
576
+ }
577
+ catch {
578
+ return undefined;
579
+ }
580
+ if (!isRecord(raw))
581
+ return undefined;
582
+ if (raw['schemaVersion'] !== RECORD_SCHEMA_VERSION || raw['kind'] !== RUNTIME_DEPS_KIND || raw['state'] !== 'ready')
583
+ return undefined;
584
+ if (raw['key'] !== key)
585
+ return undefined;
586
+ // The record is a handle a plugin reads; a malformed one is "not ready", never a half-truth.
587
+ if (!isRecord(raw['packages']) || !Array.isArray(raw['attempts']) || !Array.isArray(raw['peers']))
588
+ return undefined;
589
+ if (typeof raw['integrity'] !== 'string' || typeof raw['installedAt'] !== 'string')
590
+ return undefined;
591
+ if (!(await exists(fs, join(root, 'package.json'))))
592
+ return undefined;
593
+ if (!(await exists(fs, join(root, 'node_modules'))))
594
+ return undefined;
595
+ return raw;
596
+ }
597
+ /** How long a detected manager version is trusted. */
598
+ const MANAGER_VERSION_TTL_MS = 60_000;
599
+ /**
600
+ * Spawn a package manager. On win32 the manager is a `.cmd` shim, which Node refuses to spawn
601
+ * without a shell (CVE-2024-27980); `cmd.exe /d /s /c` is the same shape `scripts/lib/win-spawn.mjs`
602
+ * pins for the build scripts, kept local because `src/` must not import the repo's scripts.
603
+ */
604
+ function spawnManager(manager, args, options) {
605
+ if (process.platform !== 'win32')
606
+ return spawn(manager, [...args], { ...options, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
607
+ const comspec = process.env['ComSpec'] ?? 'cmd.exe';
608
+ // The arguments are fixed flags plus a caller-configured registry/cache URL; quoting each one keeps
609
+ // `&`/`|` inside it from becoming a command separator.
610
+ const line = [manager, ...args].map(quoteWindows).join(' ');
611
+ return spawn(comspec, ['/d', '/s', '/c', line], { ...options, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
612
+ }
613
+ function quoteWindows(value) {
614
+ return /[\s"&|<>^]/u.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
615
+ }
616
+ /** Default runner: spawn the manager in `cwd`; capture capped output; honour timeout/abort. */
617
+ function defaultRun(input) {
618
+ return new Promise(resolvePromise => {
619
+ let stdout = '';
620
+ let stderr = '';
621
+ let settled = false;
622
+ let timer;
623
+ const finish = (result) => {
624
+ if (settled)
625
+ return;
626
+ settled = true;
627
+ if (timer !== undefined)
628
+ clearTimeout(timer);
629
+ input.signal?.removeEventListener('abort', onAbort);
630
+ resolvePromise(result);
631
+ };
632
+ const child = spawnManager(input.manager, input.args, {
633
+ cwd: input.cwd,
634
+ env: input.env,
635
+ });
636
+ const onAbort = () => {
637
+ child.kill('SIGKILL');
638
+ };
639
+ if (input.timeoutMs !== undefined && input.timeoutMs > 0) {
640
+ timer = setTimeout(() => child.kill('SIGKILL'), input.timeoutMs);
641
+ }
642
+ if (input.signal?.aborted === true)
643
+ onAbort();
644
+ else
645
+ input.signal?.addEventListener('abort', onAbort, { once: true });
646
+ child.stdout?.on('data', (chunk) => {
647
+ if (stdout.length < MAX_OUTPUT_CHARS)
648
+ stdout += chunk.toString('utf8').slice(0, MAX_OUTPUT_CHARS - stdout.length);
649
+ });
650
+ child.stderr?.on('data', (chunk) => {
651
+ if (stderr.length < MAX_OUTPUT_CHARS)
652
+ stderr += chunk.toString('utf8').slice(0, MAX_OUTPUT_CHARS - stderr.length);
653
+ });
654
+ child.on('error', error => {
655
+ finish({ code: -1, stdout, stderr: `${stderr}${error.message}` });
656
+ });
657
+ child.on('close', code => {
658
+ finish({ code: code ?? -1, stdout, stderr });
659
+ });
660
+ });
661
+ }
662
+ // ─────────────────────────────────────────────────────────────────────────────
663
+ // The provider
664
+ // ─────────────────────────────────────────────────────────────────────────────
665
+ /** Build the built-in shared runtime-dependency provider.
666
+ * @stable */
667
+ export function runtimeDepsProvider(options = {}) {
668
+ const defaultManager = options.manager ?? 'pnpm';
669
+ const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
670
+ const staleLockMs = options.staleLockMs ?? DEFAULT_STALE_LOCK_MS;
671
+ const clock = options.clock ?? Date.now;
672
+ const platform = options.target?.platform ?? process.platform;
673
+ const arch = options.target?.arch ?? process.arch;
674
+ const nodeAbi = options.target?.abi ?? process.versions.modules ?? 'unknown';
675
+ const run = options.run ?? defaultRun;
676
+ const managerVersions = new Map();
677
+ const detectManagerVersion = (manager) => {
678
+ const injected = options.managerVersion?.(manager);
679
+ if (injected !== undefined)
680
+ return Promise.resolve(injected);
681
+ const cached = managerVersions.get(manager);
682
+ if (cached !== undefined && clock() - cached.at < MANAGER_VERSION_TTL_MS)
683
+ return Promise.resolve(cached.value);
684
+ return new Promise(resolvePromise => {
685
+ const child = spawnManager(manager, ['--version'], {});
686
+ let stdout = '';
687
+ const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
688
+ child.stdout?.on('data', (chunk) => {
689
+ if (stdout.length < 256)
690
+ stdout += chunk.toString('utf8');
691
+ });
692
+ // Drain stderr: `spawnManager` pipes it, and an unread pipe could block a chatty manager.
693
+ child.stderr?.resume();
694
+ child.on('error', () => {
695
+ clearTimeout(timer);
696
+ resolvePromise('missing');
697
+ });
698
+ child.on('close', code => {
699
+ clearTimeout(timer);
700
+ const first = stdout.trim().split(/\r?\n/u)[0];
701
+ const value = code === 0 && first !== undefined && first !== '' ? first : code === 0 ? 'unknown' : 'missing';
702
+ managerVersions.set(manager, { value, at: clock() });
703
+ resolvePromise(value);
704
+ });
705
+ });
706
+ };
707
+ /** The content key and the lockfile/integrity facts that belong with it. */
708
+ const keyOf = async (spec, fs, peersOfKey) => {
709
+ const lock = await readLockfile(fs, spec.lockfile);
710
+ const attempts = [];
711
+ for (const attempt of spec.attempts) {
712
+ const source = sourceOf(spec, attempt.source);
713
+ const manager = attempt.manager ?? defaultManager;
714
+ attempts.push({
715
+ source: {
716
+ id: source.id,
717
+ registry: source.registry ?? null,
718
+ cache: source.cache ?? null,
719
+ },
720
+ env: Object.fromEntries(Object.entries(attempt.env ?? {}).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))),
721
+ allowScripts: attempt.allowScripts ?? false,
722
+ manager: { name: manager, version: await detectManagerVersion(manager) },
723
+ });
724
+ }
725
+ const packages = sortedRecord(spec.packages);
726
+ // Peers are identified by EVERYTHING a root commits to: the name, the declared range, and the host
727
+ // copy that will actually be linked (directory + version). A root physically links that directory,
728
+ // so a different copy of the same singleton version is a different root too — otherwise the first
729
+ // installer would hand every other plugin ITS host instance.
730
+ const peers = [...peersOfKey]
731
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
732
+ .map(peer => ({ name: peer.name, range: peer.range, dir: peer.dir, version: peer.version ?? null }));
733
+ // `verify` is part of the key like `packages` is: it decides whether a root satisfies the spec,
734
+ // so a spec with a different (or additional) verify entry must get its own root instead of
735
+ // failing — and then deleting — another spec's root.
736
+ const verify = [...spec.verify].sort();
737
+ const key = sha256Hex(canonicalJson({
738
+ schemaVersion: RECORD_SCHEMA_VERSION,
739
+ packages,
740
+ verify,
741
+ lockHash: lock.hash,
742
+ platform,
743
+ arch,
744
+ nodeAbi,
745
+ attempts,
746
+ peers,
747
+ }));
748
+ const integrity = sriOfSha256(sha256Hex(canonicalJson({ key, packages, verify, lockHash: lock.hash, peers })));
749
+ return { key, lockHash: lock.hash, lockBytes: lock.bytes, lockName: lock.name, integrity };
750
+ };
751
+ const storeOf = (home, item) => runtimeDepsStore(home, item.target.root);
752
+ /** The published root of one key: `targetDir()` and the core's `publish()` must agree on this. */
753
+ const rootOf = (home, item, key) => join(storeOf(home, item), rootNameOf(home, item, key));
754
+ const attemptRecord = (spec, attempt, result, started, code, detail) => {
755
+ const source = sourceOf(spec, attempt.source);
756
+ return {
757
+ source: source.id,
758
+ // The registry is caller-configured and may be `https://user:pass@host/` or carry a
759
+ // `_authToken`: this record lands in `.runtime.json`, inside a store every plugin can read.
760
+ // Redacting keeps the endpoint (the audit value) and drops the credential.
761
+ ...(source.registry === undefined ? {} : { registry: redact(source.registry) }),
762
+ ...(source.cache === undefined ? {} : { cache: source.cache }),
763
+ manager: attempt.manager ?? defaultManager,
764
+ envKeys: Object.keys(attempt.env ?? {}).sort(),
765
+ allowScripts: attempt.allowScripts ?? false,
766
+ result,
767
+ ...(code === undefined ? {} : { code }),
768
+ // The detail is persisted (`.runtime.json`, `status.json.last_error`) and logged: manager output
769
+ // can echo a registry userinfo or a token, so it is scrubbed on the way in.
770
+ ...(detail === undefined ? {} : { detail: tail(redact(detail)) }),
771
+ ms: Math.max(0, clock() - started),
772
+ };
773
+ };
774
+ const writeRecord = async (fs, staging, keyInfo, spec, attempts, peers, linkModes) => {
775
+ const record = {
776
+ schemaVersion: RECORD_SCHEMA_VERSION,
777
+ kind: RUNTIME_DEPS_KIND,
778
+ state: 'ready',
779
+ key: keyInfo.key,
780
+ packages: sortedRecord(spec.packages),
781
+ lockHash: keyInfo.lockHash,
782
+ platform,
783
+ arch,
784
+ nodeAbi,
785
+ attempts,
786
+ peers: peers.map(peer => peer.name).sort(),
787
+ copiedPeers: linkModes.filter(link => link.mode === 'copied').map(link => link.name).sort(),
788
+ integrity: keyInfo.integrity,
789
+ installedAt: new Date(clock()).toISOString(),
790
+ };
791
+ // Order matters: the record is readable before the completion marker appears.
792
+ await fs.writeFile(join(staging, RECORD_FILE), encodeJson(record));
793
+ await fs.writeFile(join(staging, READY_FILE), encodeJson({ key: keyInfo.key, installedAt: record.installedAt }));
794
+ return record;
795
+ };
796
+ const resolvedOf = (item, keyInfo, root, source) => ({
797
+ name: identityName(item),
798
+ version: keyInfo.key,
799
+ dir: root,
800
+ entryDir: root,
801
+ integrity: keyInfo.integrity,
802
+ source,
803
+ });
804
+ return {
805
+ id: options.id ?? '@avantf/dsh-plugin-base/runtime-deps',
806
+ kinds: [RUNTIME_DEPS_KIND],
807
+ identify(item) {
808
+ // Never throws: `declare()` is on the plugin's mount path, and an invalid spec must become a
809
+ // FAILED item (reported by `ensure`) rather than a thrown declaration.
810
+ return { name: identityName(item) };
811
+ },
812
+ targetDir(_item, ref) {
813
+ // The published unit is the core's version SEGMENT, not the raw version: `publish()` falls back
814
+ // to a short hash when the absolute path would exceed the path budget, and `rootOf()` derives
815
+ // the same segment, so probe/plan/install look at exactly this directory.
816
+ return ref.segment;
817
+ },
818
+ async probe(item, ctx) {
819
+ const spec = parseSpec(item);
820
+ // Peers are resolved BEFORE the key: which host copy is linked is part of the root's identity.
821
+ // A host peer that cannot be resolved (or whose version no longer satisfies the declared range)
822
+ // throws here — a declaration error no reinstall could fix.
823
+ const peers = await resolvePeers(spec, ctx, options.peerDirs);
824
+ const keyInfo = await keyOf(spec, ctx.fs, peers);
825
+ const root = rootOf(ctx.home, item, keyInfo.key);
826
+ const record = await readReadyRoot(ctx.fs, root, keyInfo.key);
827
+ if (record === undefined)
828
+ return { found: false };
829
+ // `.ready` promises "verified at publish time"; a probe re-checks the cheap half so a root
830
+ // whose host links moved (or whose tree was damaged) is rebuilt instead of handed to a plugin
831
+ // that would fail on import.
832
+ try {
833
+ await verifyTree(ctx.fs, root, spec, peers);
834
+ }
835
+ catch (error) {
836
+ ctx.logger.warn(`runtime-deps: root ${root} 不再通过校验,将重建(${reasonOf(error).message})`);
837
+ return { found: false };
838
+ }
839
+ // A copied peer has no link target to compare, so this root's peer identity is version-level.
840
+ // Say it where the reuse is decided, not only where the copy was made.
841
+ if (record.copiedPeers !== undefined && record.copiedPeers.length > 0) {
842
+ ctx.logger.debug(`runtime-deps: ${root} 的 peer ${record.copiedPeers.join(', ')} 以复制方式落盘,identity 为版本级(记录在 .runtime.json 的 copiedPeers)`);
843
+ }
844
+ return { found: true, version: keyInfo.key, dir: root, source: 'managed' };
845
+ },
846
+ async plan(item, ctx) {
847
+ const spec = parseSpec(item);
848
+ // The plan must name the SAME root the install would: peer identity is key material, so a peer
849
+ // that cannot be resolved leaves the item "cannot plan" rather than a different action.
850
+ const peers = await resolvePeers(spec, ctx, options.peerDirs);
851
+ const keyInfo = await keyOf(spec, ctx.fs, peers);
852
+ const root = rootOf(ctx.home, item, keyInfo.key);
853
+ const ready = await readReadyRoot(ctx.fs, root, keyInfo.key);
854
+ return ready === undefined ? { action: 'install' } : { action: 'present', version: keyInfo.key };
855
+ },
856
+ async install(item, ctx) {
857
+ const spec = parseSpec(item);
858
+ // Peers are resolved BEFORE any reuse/install (and before the lock): a declared host singleton
859
+ // that cannot be linked — or whose host version no longer matches — is not something a
860
+ // different registry, or a rebuilt root, could fix, and the resolved identity IS part of the key.
861
+ const peers = await resolvePeers(spec, ctx, options.peerDirs);
862
+ const keyInfo = await keyOf(spec, ctx.fs, peers);
863
+ const store = storeOf(ctx.home, item);
864
+ const root = rootOf(ctx.home, item, keyInfo.key);
865
+ // Defense in depth: both names are hex digests, but the core joins them into a path.
866
+ assertSafeRelativePath(keyInfo.key);
867
+ const lock = ctx.lock ?? defaultLock();
868
+ const handle = await lock.acquire(runtimeDepsLockPath(store, keyInfo.key), { timeoutMs: lockTimeoutMs, staleMs: staleLockMs });
869
+ try {
870
+ // Another process may have published the same key while this one waited for the lock.
871
+ if ((await readReadyRoot(ctx.fs, root, keyInfo.key)) !== undefined) {
872
+ try {
873
+ await verifyTree(ctx.fs, root, spec, peers);
874
+ ctx.logger.info(`runtime-deps: reusing ${root}(key ${keyInfo.key.slice(0, 12)}…)`);
875
+ // Nothing was placed by THIS call: the core reports the item as `present`.
876
+ return { ...resolvedOf(item, keyInfo, root, 'managed'), reused: true };
877
+ }
878
+ catch (error) {
879
+ ctx.logger.warn(`runtime-deps: 已有的根 ${root} 不再通过校验,重建(${reasonOf(error).message})`);
880
+ // Destroying a ready root is only safe because the key covers what a root must satisfy —
881
+ // packages, verify entries and peer name/range/resolved-copy identity. A spec that
882
+ // declares anything else has a different key and a different root, so this can only ever
883
+ // replace OUR OWN tree.
884
+ await ctx.fs.rm(root, { recursive: true });
885
+ }
886
+ }
887
+ // A root without `.ready` is a failed/interrupted attempt: it is not a version to keep.
888
+ if (await exists(ctx.fs, root)) {
889
+ ctx.logger.warn(`runtime-deps: 移除未完成的根 ${root}(没有 ${READY_FILE})`);
890
+ await ctx.fs.rm(root, { recursive: true });
891
+ }
892
+ // Staging lives INSIDE the store (same filesystem as the key root, so the rename is atomic).
893
+ // It is never published directly: `.ready` + the core's install.json are written in it first.
894
+ stagingSequence += 1;
895
+ const staging = join(store, `${STAGING_PREFIX}${keyInfo.key}-${String(process.pid)}-${String(stagingSequence)}-${Math.random().toString(36).slice(2, 8)}`);
896
+ await ctx.fs.mkdir(staging);
897
+ const records = [];
898
+ try {
899
+ for (const attempt of spec.attempts) {
900
+ const source = sourceOf(spec, attempt.source);
901
+ const manager = attempt.manager ?? defaultManager;
902
+ const started = clock();
903
+ try {
904
+ // Every attempt starts from the same generated manifest: a manager that failed (or a
905
+ // publish that did not reach the rename) must not leave a half-written tree behind.
906
+ await cleanInstallTree(ctx.fs, staging, keyInfo.lockName);
907
+ await seedStaging(ctx.fs, staging, spec, { bytes: keyInfo.lockBytes, name: keyInfo.lockName });
908
+ const result = await run({
909
+ manager,
910
+ args: managerArgs(manager, source, attempt),
911
+ cwd: staging,
912
+ env: attemptEnv(attempt, source),
913
+ ...(ctx.policy.timeoutMs !== undefined && ctx.policy.timeoutMs > 0 ? { timeoutMs: ctx.policy.timeoutMs } : {}),
914
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
915
+ });
916
+ if (result.code !== 0) {
917
+ throw new ProvisionError('runtime-deps/install-failed', `runtime-deps 的 ${manager} install 失败(source ${source.id},exit ${String(result.code)}):${tail(redact(result.stderr || result.stdout)) || '无输出'}`);
918
+ }
919
+ const linkModes = await linkPeers(ctx, staging, peers);
920
+ // Verify BEFORE `.ready`: the marker is a promise, not a hope.
921
+ await verifyTree(ctx.fs, staging, spec, peers);
922
+ const record = await writeRecord(ctx.fs, staging, keyInfo, spec, [
923
+ ...records,
924
+ attemptRecord(spec, attempt, 'ok', started),
925
+ ], peers, linkModes);
926
+ ctx.logger.info(`runtime-deps: installed ${String(Object.keys(spec.packages).length)} package(s) into ${root} ` +
927
+ `(${manager},source ${source.id},integrity ${record.integrity.slice(0, 23)}…)`);
928
+ // The core writes its own install.json into staging and atomically renames the tree to
929
+ // `<home>/<target.root>/<segment>`. That rename is the publication point.
930
+ const resolved = await ctx.publish(staging, {
931
+ name: identityName(item),
932
+ version: keyInfo.key,
933
+ integrity: record.integrity,
934
+ source: 'installed',
935
+ });
936
+ return resolved;
937
+ }
938
+ catch (error) {
939
+ const { code, message } = reasonOf(error);
940
+ records.push(attemptRecord(spec, attempt, 'failed', started, code, message));
941
+ ctx.logger.warn(`runtime-deps: attempt ${source.id}/${manager} 失败:${message}`);
942
+ }
943
+ }
944
+ const summary = records
945
+ .map(record => `${record.source}/${record.manager}: ${record.code ?? 'failed'}(${record.detail ?? ''})`)
946
+ .join('; ');
947
+ throw new ProvisionError('runtime-deps/install-failed', `runtime-deps 的所有 attempts 都失败(key ${keyInfo.key.slice(0, 12)}…):${summary}`, JSON.stringify(records));
948
+ }
949
+ finally {
950
+ // Remove whatever is left under the staging NAME. On the normal path the core's rename
951
+ // already consumed it (rm on a missing path is a no-op); on a failed attempt the tree is
952
+ // garbage; and when `publish()` decides an equivalent root is already in place it returns
953
+ // WITHOUT renaming — the staging tree is then this call's own leftover, not a root.
954
+ await ctx.fs.rm(staging, { recursive: true }).catch(() => undefined);
955
+ }
956
+ }
957
+ finally {
958
+ handle.dispose();
959
+ }
960
+ },
961
+ async verify(item, resolved, ctx) {
962
+ const spec = parseSpec(item);
963
+ const key = resolved.version ?? basename(resolved.dir);
964
+ const record = await readReadyRoot(ctx.fs, resolved.dir, key);
965
+ if (record === undefined) {
966
+ throw new ProvisionError('verify/failed', `runtime-deps 的根没有可用的 ${READY_FILE}/${RECORD_FILE}:${resolved.dir}`);
967
+ }
968
+ const peers = await resolvePeers(spec, ctx, options.peerDirs);
969
+ await verifyTree(ctx.fs, resolved.dir, spec, peers);
970
+ },
971
+ };
972
+ }
973
+ /**
974
+ * Status rows are the family's recorded plugin→resource references.
975
+ *
976
+ * `reliable` is false when the file exists but cannot be understood (a newer schema, corrupt JSON,
977
+ * STRUCTURAL damage — `rows` not an array, a row without `key`/`version`, an IO error): GC must then
978
+ * treat EVERY root as possibly referenced and remove nothing. Treating an unreadable record as "no
979
+ * references" would turn one bad file into deleting every shared root — and a partially understood
980
+ * one (the old lenient reader dropped the rows it could not parse) is the same failure with a smaller
981
+ * blast radius, which is why `readStatus` reports it as `unreadable` instead.
982
+ *
983
+ * `absent` is the same "cannot tell" for a DIFFERENT reason: there is no status plane at all. A
984
+ * home whose plugin has not recorded anything yet, or whose `status.json` was removed, must not have
985
+ * its whole shared store deleted because a missing file reads as "no references".
986
+ */
987
+ async function referencedRuntimeDepsKeys(fs, home) {
988
+ const keys = new Set();
989
+ try {
990
+ const read = await readStatus(fs, home);
991
+ if (read.kind === 'none')
992
+ return { keys, reliable: true, absent: true };
993
+ if (read.kind === 'unreadable' || read.kind === 'too-new')
994
+ return { keys, reliable: false, absent: false };
995
+ for (const row of read.rows) {
996
+ if (row.version === '')
997
+ continue;
998
+ if (!row.key.startsWith(`${RUNTIME_DEPS_KIND}+`))
999
+ continue;
1000
+ keys.add(row.version);
1001
+ }
1002
+ }
1003
+ catch {
1004
+ return { keys, reliable: false, absent: false };
1005
+ }
1006
+ return { keys, reliable: true, absent: false };
1007
+ }
1008
+ /** Is `value` a content key (the 64-hex digest this provider uses as a root's version)? */
1009
+ function isContentKey(value) {
1010
+ return /^[0-9a-f]{64}$/u.test(value);
1011
+ }
1012
+ /**
1013
+ * The content key a root directory holds.
1014
+ *
1015
+ * Normally the DIRECTORY NAME is the key. It is not when the core shortened the path
1016
+ * (`versionSegment`): then only the provider's own record names the key. A root whose key cannot be
1017
+ * determined is never removed — "cannot tell" must not delete a live root.
1018
+ */
1019
+ async function rootContentKey(fs, path, entry) {
1020
+ try {
1021
+ const raw = decodeJson(await fs.readFile(join(path, RECORD_FILE)));
1022
+ if (isRecord(raw)) {
1023
+ const key = raw['key'];
1024
+ if (typeof key === 'string' && key !== '')
1025
+ return key;
1026
+ }
1027
+ }
1028
+ catch {
1029
+ // A root without a readable record falls through to the name.
1030
+ }
1031
+ return isContentKey(entry) ? entry : undefined;
1032
+ }
1033
+ /**
1034
+ * Parse `<key>-<pid>-<seq>-<rand>` out of a staging name this build wrote. `undefined` means the
1035
+ * name did not come from this provider (a manual/older tree), so only mtime can judge it.
1036
+ */
1037
+ function parseStagingName(entry) {
1038
+ const match = /^\.staging-([0-9a-f]{64})-(\d+)/u.exec(entry);
1039
+ const key = match?.[1];
1040
+ const pidText = match?.[2];
1041
+ if (key === undefined || pidText === undefined)
1042
+ return undefined;
1043
+ const pid = Number(pidText);
1044
+ if (!Number.isSafeInteger(pid) || pid <= 0)
1045
+ return undefined;
1046
+ return { key, pid };
1047
+ }
1048
+ /**
1049
+ * Remove roots no recorded plugin references. EXPLICIT: this is an operator action, never a startup
1050
+ * step, because a running plugin may hold a key that has not reached `status.json` yet.
1051
+ *
1052
+ * A root is kept when its key is in `keep` or in a `runtime-deps` status row's `version` (the
1053
+ * content key this provider reports). Roots in use by a live per-key lock are skipped, never stolen.
1054
+ */
1055
+ export async function gcRuntimeDeps(options) {
1056
+ const fs = options.fs ?? defaultFs();
1057
+ const clock = options.clock ?? Date.now;
1058
+ const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
1059
+ const stagingGraceMs = options.stagingGraceMs ?? 60 * 60 * 1000;
1060
+ const logger = options.logger;
1061
+ const store = runtimeDepsStore(options.home, options.root ?? RUNTIME_DEPS_DIR);
1062
+ if (!(await exists(fs, store)))
1063
+ return { removed: [], kept: [], skipped: [], cleaned: [] };
1064
+ const { keys: referenced, reliable, absent } = await referencedRuntimeDepsKeys(fs, options.home);
1065
+ const keep = new Set(options.keep ?? []);
1066
+ const lock = options.lock ?? defaultLock();
1067
+ const handle = await lock.acquire(join(store, '.gc.lock'), { timeoutMs: 10_000, staleMs });
1068
+ const removed = [];
1069
+ const kept = [];
1070
+ const skipped = [];
1071
+ const cleaned = [];
1072
+ let warnedAbsent = false;
1073
+ try {
1074
+ let entries;
1075
+ try {
1076
+ entries = await fs.readdir(store);
1077
+ }
1078
+ catch {
1079
+ return { removed, kept, skipped, cleaned };
1080
+ }
1081
+ for (const entry of [...entries].sort()) {
1082
+ if (entry.startsWith('.'))
1083
+ continue;
1084
+ const path = join(store, entry);
1085
+ const info = await fs.stat(path);
1086
+ if (info?.isDirectory !== true)
1087
+ continue;
1088
+ const key = await rootContentKey(fs, path, entry);
1089
+ if (key === undefined) {
1090
+ // The root's key cannot be determined: it might be the very root a plugin is using.
1091
+ skipped.push(entry);
1092
+ logger?.warn(`runtime-deps gc: 无法确定根 ${path} 的内容 key(记录不可读),保留`);
1093
+ continue;
1094
+ }
1095
+ if (referenced.has(key) || keep.has(key)) {
1096
+ kept.push(entry);
1097
+ continue;
1098
+ }
1099
+ if (!reliable) {
1100
+ // The recorded references cannot be read: every root is "possibly referenced".
1101
+ skipped.push(entry);
1102
+ continue;
1103
+ }
1104
+ if (absent) {
1105
+ // There is no recorded reference at all: a missing plane is "cannot tell", not "unused".
1106
+ if (!warnedAbsent) {
1107
+ warnedAbsent = true;
1108
+ logger?.warn(`runtime-deps gc: ${options.home} 没有 status.json,无法判断根是否仍被引用;本次不删除任何根`);
1109
+ }
1110
+ skipped.push(entry);
1111
+ continue;
1112
+ }
1113
+ // A live per-key lock means an install is writing this key right now: do not remove it.
1114
+ if (await lockHeldByLiveProcess(fs, runtimeDepsLockPath(store, key))) {
1115
+ skipped.push(entry);
1116
+ continue;
1117
+ }
1118
+ if (options.dryRun === true) {
1119
+ skipped.push(entry);
1120
+ continue;
1121
+ }
1122
+ try {
1123
+ await fs.rm(path, { recursive: true });
1124
+ removed.push(entry);
1125
+ logger?.info(`runtime-deps gc: removed ${path}`);
1126
+ }
1127
+ catch (error) {
1128
+ skipped.push(entry);
1129
+ logger?.warn(`runtime-deps gc: 无法移除 ${path}(${reasonOf(error).message})`);
1130
+ }
1131
+ }
1132
+ // Interrupted staging trees: their writer is gone (or old enough) and they can never be published.
1133
+ for (const entry of await fs.readdir(store).catch(() => [])) {
1134
+ if (!entry.startsWith(STAGING_PREFIX))
1135
+ continue;
1136
+ const path = join(store, entry);
1137
+ const info = await fs.stat(path);
1138
+ if (info === undefined)
1139
+ continue;
1140
+ // A staging name this build wrote names its key and its writer. A SLOW install is not a stale
1141
+ // one: past the grace period an mtime says nothing about whether the writer is still alive, so
1142
+ // a live pid (or a live per-key lock, i.e. a waiter about to become the writer) protects it.
1143
+ const staged = parseStagingName(entry);
1144
+ if (staged !== undefined) {
1145
+ if (pidIsAlive(staged.pid)) {
1146
+ skipped.push(entry);
1147
+ continue;
1148
+ }
1149
+ if (await lockHeldByLiveProcess(fs, runtimeDepsLockPath(store, staged.key))) {
1150
+ skipped.push(entry);
1151
+ continue;
1152
+ }
1153
+ }
1154
+ if (clock() - info.mtimeMs < stagingGraceMs)
1155
+ continue;
1156
+ if (options.dryRun === true) {
1157
+ skipped.push(entry);
1158
+ continue;
1159
+ }
1160
+ try {
1161
+ await fs.rm(path, { recursive: true });
1162
+ cleaned.push(entry);
1163
+ }
1164
+ catch {
1165
+ skipped.push(entry);
1166
+ }
1167
+ }
1168
+ // Dead lock files (a live holder is never reclaimed). The next `acquire` would do this too; GC
1169
+ // makes the store tidy instead of leaving one stale file per crashed process.
1170
+ const locksRoot = join(store, LOCKS_DIR);
1171
+ for (const entry of await fs.readdir(locksRoot).catch(() => [])) {
1172
+ if (!entry.endsWith('.lock'))
1173
+ continue;
1174
+ const path = join(locksRoot, entry);
1175
+ const info = await fs.stat(path);
1176
+ if (info === undefined)
1177
+ continue;
1178
+ if (await lockHeldByLiveProcess(fs, path))
1179
+ continue;
1180
+ if (clock() - info.mtimeMs <= staleMs)
1181
+ continue;
1182
+ if (options.dryRun === true) {
1183
+ skipped.push(join(LOCKS_DIR, entry));
1184
+ continue;
1185
+ }
1186
+ try {
1187
+ await fs.rm(path);
1188
+ cleaned.push(join(LOCKS_DIR, entry));
1189
+ }
1190
+ catch {
1191
+ skipped.push(join(LOCKS_DIR, entry));
1192
+ }
1193
+ }
1194
+ }
1195
+ finally {
1196
+ handle.dispose();
1197
+ }
1198
+ return { removed, kept, skipped, cleaned };
1199
+ }
1200
+ /** Is the control-plane lock at `path` held by a process that still exists? */
1201
+ async function lockHeldByLiveProcess(fs, path) {
1202
+ let raw;
1203
+ try {
1204
+ raw = decodeJson(await fs.readFile(path));
1205
+ }
1206
+ catch {
1207
+ return false;
1208
+ }
1209
+ if (!isRecord(raw))
1210
+ return false;
1211
+ const pid = raw['pid'];
1212
+ return typeof pid === 'number' && pidIsAlive(pid);
1213
+ }
1214
+ //# sourceMappingURL=runtime_deps.js.map