@deepseek-ai/dsh-plugin-manager 0.1.7-alpha.2 → 0.1.7-rc.2

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/lib/index.js CHANGED
@@ -7,11 +7,206 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
7
7
  import z from "@deepseek-ai/schemastery";
8
8
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
9
9
  import { pluginEntryId, readPluginInventory } from "@deepseek-ai/dsh-host-plugin-inventory";
10
- import { OPTIONAL_BUNDLES, bundlePatchPaths, composeEntries, loadOptionalPatches, loadOverlayPatches, readPluginMeta, readProfileManifest, readProfilePatches, reconcileProfilePatches, resolveBundleDir, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
10
+ import { OPTIONAL_BUNDLES, PROFILE_COMPATIBILITY_FILENAME, bundlePatchPaths, composeEntries, evaluatePluginCompatibility, loadOptionalPatches, loadOverlayPatches, pluginCompatibilityWarning, readPluginMeta, readProfileCompatibility, readProfileManifest, readProfilePatches, readProfileVersionExemptions, reconcileProfilePatches, resolveBundleDir, resolveProfileDir, setProfileVersionExemption } from "@deepseek-ai/dsh-app-boot";
11
+ import { once } from "node:events";
11
12
  import { execa } from "execa";
12
13
  import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
13
14
  import { isAlias, isMap, isNode, isScalar, isSeq, parseDocument, visit } from "yaml";
15
+ //#region lib/types/install-spec.js
16
+ /**
17
+ * Reading an install spec before pnpm sees it: which of pnpm's spec forms it
18
+ * takes, and for a registry name whether it is one the registry can accept.
19
+ * @module @deepseek-ai/dsh-plugin-manager/install-spec
20
+ */
21
+ /** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */
22
+ const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
23
+ const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
24
+ const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i;
25
+ /** The hosts pnpm's shorthands stand for. */
26
+ const GIT_SHORTHAND_HOSTS = {
27
+ github: "github.com",
28
+ gitlab: "gitlab.com",
29
+ bitbucket: "bitbucket.org",
30
+ gist: "gist.github.com"
31
+ };
32
+ /** A tarball, on disk or over HTTP. */
33
+ const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
34
+ /** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
35
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
36
+ const PACKAGE_NAME_MAX_LENGTH = 214;
37
+ /** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
38
+ var InvalidInstallSpecError = class extends Error {
39
+ spec;
40
+ reason;
41
+ /**
42
+ * @param spec - the spec as typed, trimmed.
43
+ * @param reason - why it is refused, as one sentence.
44
+ */
45
+ constructor(spec, reason) {
46
+ super(`plugin-manager: ${reason}: ${spec}`);
47
+ this.spec = spec;
48
+ this.reason = reason;
49
+ this.name = "InvalidInstallSpecError";
50
+ }
51
+ };
52
+ function invalid(spec, reason) {
53
+ return new InvalidInstallSpecError(spec, reason);
54
+ }
55
+ /** The host a git spec is cloned from: the shorthand's host, the scp-like user@host, or the URL's host with its port. */
56
+ function gitHost(spec) {
57
+ const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase();
58
+ if (shorthand !== void 0 && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand)) return GIT_SHORTHAND_HOSTS[shorthand];
59
+ const scp = /^git@([^:]+):/i.exec(spec)?.[1];
60
+ if (scp !== void 0) return scp.toLowerCase();
61
+ return new URL(spec.replace(/^git\+/i, "")).host;
62
+ }
63
+ /**
64
+ * Read a spec into its form. A path must be absolute: the Host's working
65
+ * directory means nothing to the person typing into a browser, and a
66
+ * relative path resolved against the profile would point inside it.
67
+ * @param raw - the spec as typed.
68
+ * @returns the parsed spec.
69
+ * @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
70
+ * registry would refuse, or a URL that is neither a git host nor a tarball.
71
+ */
72
+ function parseInstallSpec(raw) {
73
+ const spec = raw.trim();
74
+ if (spec === "") throw invalid(spec, "the package spec must not be empty");
75
+ const path = spec.replace(/^(?:file|link):/, "");
76
+ if (path !== spec || isAbsolute(path)) {
77
+ if (!isAbsolute(path)) throw invalid(spec, "a local path must be absolute");
78
+ return TARBALL_SPEC.test(path) ? {
79
+ kind: "tarball",
80
+ spec,
81
+ path
82
+ } : {
83
+ kind: "path",
84
+ spec,
85
+ path
86
+ };
87
+ }
88
+ if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, "a local path must be absolute");
89
+ if ((GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)) && !TARBALL_SPEC.test(spec)) return {
90
+ kind: "git",
91
+ spec,
92
+ host: gitHost(spec)
93
+ };
94
+ if (/^https?:\/\//i.test(spec)) {
95
+ if (TARBALL_SPEC.test(spec)) return {
96
+ kind: "tarball",
97
+ spec,
98
+ host: new URL(spec).host
99
+ };
100
+ throw invalid(spec, "a URL must point at a git repository or a tarball");
101
+ }
102
+ const at = spec.indexOf("@", 1);
103
+ const name = at === -1 ? spec : spec.slice(0, at);
104
+ const range = at === -1 ? void 0 : spec.slice(at + 1);
105
+ if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) throw invalid(spec, "not a package name the registry accepts");
106
+ if (range === "") throw invalid(spec, "a version after @ must not be empty");
107
+ return range === void 0 ? {
108
+ kind: "registry",
109
+ spec,
110
+ name
111
+ } : {
112
+ kind: "registry",
113
+ spec,
114
+ name,
115
+ range
116
+ };
117
+ }
118
+ //#endregion
119
+ //#region lib/types/run-tree.js
120
+ /** Waiting for one package run's process tree to disappear before its caller touches the profile. */
121
+ /** How long a terminated tree may take to disappear before the caller stops waiting. */
122
+ const TREE_WAIT_MS = 5e3;
123
+ /** Poll cadence while waiting for a terminated tree to disappear. */
124
+ const TREE_POLL_MS = 15;
125
+ /**
126
+ * Whether one run leads its own process group, which is the target a POSIX
127
+ * liveness probe addresses for the whole tree. A run that captures output is
128
+ * spawned as its own group leader, because its tree is terminated as a unit; a
129
+ * run that inherits the caller's descriptors keeps the caller's group, so an
130
+ * interrupt still reaches it.
131
+ * @param execution Whether the run captures output or inherits the caller's descriptors.
132
+ * @param platform Host platform deciding how a tree is addressed.
133
+ * @returns True when the run leads its own process group.
134
+ */
135
+ function leadsOwnGroup(execution, platform = process.platform) {
136
+ return execution === "service" && platform !== "win32";
137
+ }
138
+ /** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */
139
+ function targetOf(pid, grouped, platform) {
140
+ return platform !== "win32" && grouped ? -pid : pid;
141
+ }
142
+ /**
143
+ * Whether any member of a run's tree is still alive.
144
+ * @param tree The run's process id and whether it leads its own group.
145
+ * @param internals Injectable process operations.
146
+ * @returns True while the probe finds the process or a group member.
147
+ */
148
+ function treeAlive(tree, internals = {}) {
149
+ const pid = tree.pid;
150
+ if (pid === void 0) return false;
151
+ const platform = internals.platform ?? process.platform;
152
+ const alive = internals.alive ?? ((target) => {
153
+ process.kill(target, 0);
154
+ return true;
155
+ });
156
+ try {
157
+ return alive(targetOf(pid, tree.grouped, platform));
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+ /**
163
+ * Wait until a terminated run's tree is gone, so the caller restores and
164
+ * unlocks the profile only after the scripts it started stopped writing.
165
+ * @param tree The run's process id and whether it leads its own group.
166
+ * @param internals Injectable process operations.
167
+ * @returns Fulfillment once no member remains, or the wait bound elapsed.
168
+ */
169
+ async function awaitTreeGone(tree, internals = {}) {
170
+ const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
171
+ while (treeAlive(tree, internals)) {
172
+ if (Date.now() >= deadline) return;
173
+ await new Promise((resolve) => setTimeout(resolve, TREE_POLL_MS));
174
+ }
175
+ }
176
+ //#endregion
177
+ //#region lib/types/failure.js
178
+ /** Expected management rejection; presentation belongs to the caller's locale. */
179
+ var ManagementFailure = class extends Error {
180
+ /** Code rendered by the caller's locale dictionary. */
181
+ code;
182
+ /** The packages an `incompatible-version` rejection names. */
183
+ incompatible;
184
+ /**
185
+ * @param code Localizable management rejection.
186
+ * @param incompatible Packages the running DSH version rejects, for `incompatible-version`.
187
+ */
188
+ constructor(code, incompatible) {
189
+ super(code);
190
+ this.code = code;
191
+ this.incompatible = incompatible;
192
+ }
193
+ };
194
+ /**
195
+ * Drop the exemption status from an unexempted compatibility result.
196
+ * @param issue Result whose exemption is not active.
197
+ * @returns The package, runtime, and rejected peer ranges.
198
+ */
199
+ function incompatiblePlugin(issue) {
200
+ return {
201
+ name: issue.name,
202
+ version: issue.version,
203
+ runtimeVersion: issue.runtimeVersion,
204
+ peers: issue.peers
205
+ };
206
+ }
207
+ //#endregion
14
208
  //#region lib/types/operations.js
209
+ /** Shared profile package operations used by dsh plugin and the running manager. */
15
210
  /** Resolve relative package specs against the caller's directory.
16
211
  * @param argument One pnpm argument.
17
212
  * @param cwd Invocation directory, never the profile directory.
@@ -69,15 +264,180 @@ async function reconcile(before, dir, anchor, options) {
69
264
  };
70
265
  await saveManifest(dir, after);
71
266
  }
267
+ /**
268
+ * How long the pipes keep draining after their process exited, as a fixed part of
269
+ * finishing a run rather than a deployment knob: a descendant that inherited them
270
+ * holds them open, and the tail a failure classification reads is written by then.
271
+ */
272
+ const DRAIN_AFTER_EXIT_MS = 2e3;
273
+ /** Whether every collector finished within `ms`.
274
+ * @param collectors The pipe readers racing the bound.
275
+ * @param ms The longest wait, in milliseconds.
276
+ * @returns True when all collectors settled in time.
277
+ */
278
+ async function drainWithin(collectors, ms) {
279
+ if (collectors.length === 0) return true;
280
+ let timer;
281
+ try {
282
+ return await Promise.race([Promise.allSettled(collectors).then(() => true), new Promise((resolve) => {
283
+ timer = setTimeout(() => {
284
+ resolve(false);
285
+ }, ms);
286
+ })]);
287
+ } finally {
288
+ clearTimeout(timer);
289
+ }
290
+ }
291
+ /** Install commands that take the packages to install as positionals. */
292
+ const INSTALL_COMMANDS = new Set([
293
+ "add",
294
+ "install",
295
+ "i"
296
+ ]);
297
+ /** Bound on a pre-install registry lookup when the caller names none. */
298
+ const LOOKUP_TIMEOUT_MS = 2e4;
299
+ /** Package specs an install command names explicitly, in order. */
300
+ function namedSpecs(args) {
301
+ const index = args.findIndex((argument) => !argument.startsWith("-"));
302
+ const command = index < 0 ? void 0 : args[index];
303
+ if (command === void 0 || !INSTALL_COMMANDS.has(command)) return [];
304
+ return args.slice(index + 1).filter((argument) => !argument.startsWith("-"));
305
+ }
306
+ /** The manifest a named spec would install, read without installing it.
307
+ * A path spec is read from disk. A registry spec asks pnpm's own configuration for the version the
308
+ * range selects and its peer requirements. A git or tarball spec needs the fetch itself, so the
309
+ * check after installation is what judges it.
310
+ * @param dir Profile directory the lookup runs in.
311
+ * @param spec Anchored install spec.
312
+ * @param options Pnpm executable, prefix arguments, the caller's bound and signal.
313
+ * @param environment Environment of the caller's pnpm invocations.
314
+ * @param flags Flags of the run itself, so the lookup asks the registry that run will use.
315
+ * @returns The package manifest, or undefined when reading it would need the installation itself.
316
+ */
317
+ async function namedSpecManifest(dir, spec, options, environment, flags) {
318
+ const parsed = parseInstallSpec(spec);
319
+ if (parsed.kind === "path") {
320
+ const filename = join(parsed.path, "package.json");
321
+ return existsSync(filename) ? JSON.parse(readFileSync(filename, "utf8")) : void 0;
322
+ }
323
+ if (parsed.kind !== "registry") return void 0;
324
+ const viewed = await execa(options.command ?? "pnpm", [
325
+ ...options.args ?? [],
326
+ "view",
327
+ parsed.spec,
328
+ "name",
329
+ "version",
330
+ "peerDependencies",
331
+ "--json",
332
+ ...flags,
333
+ "--config.fetch-retries=0"
334
+ ], {
335
+ cwd: dir,
336
+ env: environment,
337
+ extendEnv: false,
338
+ reject: false,
339
+ stdin: "ignore",
340
+ ...options.signal === void 0 ? {} : { cancelSignal: options.signal },
341
+ timeout: options.lookupTimeoutMs ?? LOOKUP_TIMEOUT_MS
342
+ });
343
+ if (viewed.exitCode !== 0) return void 0;
344
+ const value = JSON.parse(viewed.stdout);
345
+ return Array.isArray(value) ? value.at(-1) : value;
346
+ }
347
+ /** Missing installed packages are repairable; their absence is part of the before/after comparison. */
348
+ function optionalFile(path) {
349
+ try {
350
+ return readFileSync(path, "utf8");
351
+ } catch (error) {
352
+ if (error.code === "ENOENT") return void 0;
353
+ throw error;
354
+ }
355
+ }
356
+ /** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */
357
+ function runRecordPath(dir) {
358
+ return join(dir, ".plugin-manager", "run.json");
359
+ }
360
+ /** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */
361
+ async function recordRun(dir, tree) {
362
+ if (tree.pid === void 0) return;
363
+ await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, {
364
+ mode: 384,
365
+ dirMode: 448
366
+ });
367
+ }
368
+ /**
369
+ * Why this operation must not run: a record left by an operation whose process
370
+ * ended mid-run names a run that is still writing the profile. The profile
371
+ * lock is taken over once its holder exits, but its pnpm tree can outlive it.
372
+ * A recorded run that stopped, within a bounded wait, has its record removed.
373
+ * @param dir Profile directory.
374
+ * @returns The diagnostic, or undefined when no recorded run is active.
375
+ */
376
+ async function activeRecordedRun(dir) {
377
+ const path = runRecordPath(dir);
378
+ const text = optionalFile(path);
379
+ if (text === void 0) return void 0;
380
+ let tree;
381
+ try {
382
+ const value = JSON.parse(text);
383
+ const { pid, grouped } = typeof value === "object" && value !== null ? value : {};
384
+ if (Number.isSafeInteger(pid) && pid > 0 && typeof grouped === "boolean") tree = {
385
+ pid,
386
+ grouped
387
+ };
388
+ } catch (error) {}
389
+ if (tree === void 0) return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`;
390
+ await awaitTreeGone(tree);
391
+ if (treeAlive(tree)) return `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n`;
392
+ await rm(path, { force: true });
393
+ }
394
+ /** pnpm can install plugins through any direct-dependency field. */
395
+ function directDependencies(manifest) {
396
+ const extra = manifest;
397
+ return {
398
+ ...extra.devDependencies,
399
+ ...manifest.dependencies,
400
+ ...extra.optionalDependencies
401
+ };
402
+ }
403
+ /** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */
404
+ function bundleComponentManifests(manifest, dir, anchor) {
405
+ const bundle = manifest.dsh?.bundle;
406
+ if (bundle === void 0) return [];
407
+ const patches = bundlePatchPaths(dir, bundle).flatMap((file) => loadOverlayPatches("dsh", file));
408
+ const names = /* @__PURE__ */ new Set();
409
+ const visit = (rows) => {
410
+ for (const row of rows) {
411
+ if (row.group && Array.isArray(row.config)) visit(row.config);
412
+ if (typeof row.name !== "string" || row.name.startsWith(".") || row.name.startsWith("/") || row.name.includes(":")) continue;
413
+ const parts = row.name.split("/");
414
+ names.add(parts.slice(0, row.name.startsWith("@") ? 2 : 1).join("/"));
415
+ }
416
+ };
417
+ visit(composeEntries([patches.filter((patch) => patch.insert !== void 0)]));
418
+ return [...names].flatMap((name) => {
419
+ let packageDir;
420
+ try {
421
+ packageDir = resolveBundleDir("dsh", name, anchor, dir);
422
+ } catch (error) {
423
+ return [];
424
+ }
425
+ return [readProfileManifest("dsh", packageDir)];
426
+ });
427
+ }
72
428
  /** Execute pnpm inside a profile whose caller already holds the profile write lock.
429
+ * Newly installed or updated direct dependencies are checked even when activation is disabled;
430
+ * an untouched dependency never blocks an unrelated operation and stays denied at startup.
431
+ * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk.
73
432
  * @param context Launcher-owned profile and resolution locations.
74
433
  * @param args Pnpm arguments, before relative path anchoring.
75
434
  * @param options Output, activation and cancellation policy.
76
- * @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
435
+ * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
436
+ * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
77
437
  */
78
438
  async function runProfilePnpm(context, args, options) {
79
439
  const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
80
- const before = readProfileManifest("dsh", dir);
440
+ const active = await activeRecordedRun(dir);
81
441
  const logRoot = join(dir, ".plugin-manager", "logs");
82
442
  await mkdir(logRoot, {
83
443
  recursive: true,
@@ -85,70 +445,242 @@ async function runProfilePnpm(context, args, options) {
85
445
  });
86
446
  const logPath = join(await mkdtemp(join(logRoot, "operation-")), "pnpm.log");
87
447
  const log = await open(logPath, "wx", 384);
448
+ if (active !== void 0) {
449
+ await log.write(active);
450
+ await log.close();
451
+ options.onOutput?.(active, "stderr");
452
+ const bytes = Buffer.from(active);
453
+ return {
454
+ exitCode: 1,
455
+ output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString("utf8"),
456
+ truncated: bytes.length > options.outputBytes,
457
+ logPath
458
+ };
459
+ }
460
+ const before = readProfileManifest("dsh", dir);
461
+ const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({
462
+ path: join(dir, name),
463
+ text: optionalFile(join(dir, name))
464
+ }));
465
+ const beforeDependencies = directDependencies(before);
466
+ const installedBefore = new Map(Object.keys(beforeDependencies).map((name) => [name, optionalFile(join(dir, "node_modules", name, "package.json"))]));
88
467
  let output = Buffer.alloc(0);
89
468
  let truncated = false;
469
+ const append = (bytes) => {
470
+ output = Buffer.concat([output, bytes]);
471
+ if (output.length > options.outputBytes) {
472
+ truncated = true;
473
+ output = output.subarray(output.length - options.outputBytes);
474
+ }
475
+ };
476
+ const environment = {
477
+ ...options.execution === "cli" ? process.env : scrubbedParentEnv(),
478
+ ...options.env
479
+ };
480
+ const restore = async () => {
481
+ for (const file of savedFiles) if (file.text === void 0) await rm(file.path, { force: true });
482
+ else await writeFileAtomic(file.path, file.text, { mode: 384 });
483
+ };
484
+ /** Packages a compatibility check refused; callers render them for their own surface. */
485
+ const incompatible = [];
486
+ const rejected = async (warnings, restoration) => {
487
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
488
+ await log.write(diagnostic);
489
+ options.onOutput?.(diagnostic, "stderr");
490
+ append(Buffer.from(diagnostic));
491
+ await log.close();
492
+ return {
493
+ exitCode: 1,
494
+ output: output.toString("utf8"),
495
+ truncated,
496
+ logPath,
497
+ incompatible
498
+ };
499
+ };
500
+ const preflight = [];
501
+ const exemptions = readProfileVersionExemptions(dir);
502
+ const registryFlags = args.filter((argument) => argument.startsWith("--registry="));
503
+ for (const raw of namedSpecs(args)) try {
504
+ const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags);
505
+ if (manifest === void 0) continue;
506
+ const issue = evaluatePluginCompatibility(manifest, exemptions);
507
+ if (issue !== void 0 && !issue.exempted) {
508
+ preflight.push(pluginCompatibilityWarning(issue));
509
+ incompatible.push(incompatiblePlugin(issue));
510
+ }
511
+ } catch (error) {
512
+ continue;
513
+ }
514
+ if (preflight.length > 0) return rejected(preflight, "nothing was installed");
90
515
  const cancellation = new AbortController();
516
+ const grouped = leadsOwnGroup(options.execution);
91
517
  const child = execa(options.command ?? "pnpm", [...options.args ?? [], ...args.map((arg) => anchorPathSpec(arg, context.cwd))], {
92
518
  cwd: dir,
93
- env: {
94
- ...options.execution === "cli" ? process.env : scrubbedParentEnv(),
95
- ...options.env
96
- },
519
+ env: environment,
97
520
  extendEnv: false,
98
521
  reject: false,
99
522
  stdout: options.execution === "cli" ? "inherit" : "pipe",
100
523
  stderr: options.execution === "cli" ? "inherit" : "pipe",
524
+ killDescendants: options.execution === "service",
101
525
  buffer: false,
102
526
  stdin: options.execution === "cli" ? "inherit" : "ignore",
103
527
  cancelSignal: options.signal === void 0 ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal])
104
528
  });
529
+ const exited = once(child.nodeChildProcess, "exit").catch(() => void 0);
105
530
  let writes = Promise.resolve();
531
+ /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
532
+ const control = {
533
+ settled: false,
534
+ stalled: false
535
+ };
536
+ /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
537
+ let cut = false;
538
+ /** The first failure a reading hit before that cut, which the run still reports. */
539
+ let failure;
540
+ let idleTimer;
541
+ /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
542
+ const armIdle = () => {
543
+ if (control.settled || options.idleTimeoutMs === void 0) return;
544
+ clearTimeout(idleTimer);
545
+ idleTimer = setTimeout(() => {
546
+ control.stalled = true;
547
+ child.kill();
548
+ }, options.idleTimeoutMs);
549
+ };
106
550
  const collect = async (stream, kind) => {
107
551
  try {
108
552
  for await (const chunk of stream) {
553
+ armIdle();
109
554
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
110
555
  writes = writes.then(async () => {
111
556
  await log.write(bytes);
112
557
  });
113
558
  await writes;
114
559
  options.onOutput?.(bytes.toString("utf8"), kind);
115
- output = Buffer.concat([output, bytes]);
116
- if (output.length > options.outputBytes) {
117
- truncated = true;
118
- output = output.subarray(output.length - options.outputBytes);
119
- }
560
+ append(bytes);
120
561
  }
121
562
  } catch (error) {
122
- cancellation.abort();
563
+ if (!cut) {
564
+ failure ??= error instanceof Error ? error : new Error(String(error));
565
+ cancellation.abort();
566
+ }
123
567
  throw error;
124
568
  }
125
569
  };
570
+ const collectors = [...child.stdout === null ? [] : [collect(child.stdout, "stdout")], ...child.stderr === null ? [] : [collect(child.stderr, "stderr")]];
571
+ for (const collector of collectors) collector.catch(() => {});
572
+ if (collectors.length > 0) armIdle();
126
573
  let exitCode;
127
574
  try {
128
- const [completion, ...streams] = await Promise.allSettled([
129
- child,
130
- ...child.stdout === null ? [] : [collect(child.stdout, "stdout")],
131
- ...child.stderr === null ? [] : [collect(child.stderr, "stderr")]
132
- ]);
133
- for (const stream of streams) if (stream.status === "rejected") throw stream.reason;
575
+ await recordRun(dir, {
576
+ pid: child.pid,
577
+ grouped
578
+ });
579
+ const settled = Promise.allSettled([child]);
580
+ await Promise.race([exited, settled]);
581
+ control.settled = true;
582
+ clearTimeout(idleTimer);
583
+ if (control.stalled) await awaitTreeGone({
584
+ pid: child.pid,
585
+ grouped
586
+ });
587
+ if (await drainWithin(collectors, DRAIN_AFTER_EXIT_MS)) {
588
+ for (const stream of await Promise.allSettled(collectors)) if (stream.status === "rejected") throw stream.reason;
589
+ } else {
590
+ cut = true;
591
+ child.stdout?.destroy();
592
+ child.stderr?.destroy();
593
+ const notice = "dsh: pnpm output was cut short after its process exited\n";
594
+ await log.write(notice);
595
+ if (failure !== void 0) throw failure;
596
+ options.onOutput?.(notice, "stderr");
597
+ append(Buffer.from(notice));
598
+ }
599
+ const [completion] = await settled;
134
600
  if (completion.status === "rejected") throw completion.reason;
135
601
  const result = completion.value;
136
602
  exitCode = result.exitCode ?? (result.code === "ENOENT" ? 127 : 1);
603
+ if (control.stalled) {
604
+ const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`;
605
+ await log.write(notice);
606
+ options.onOutput?.(notice, "stderr");
607
+ append(Buffer.from(notice));
608
+ }
137
609
  if (result.failed && output.length === 0) {
138
610
  const diagnostic = result.shortMessage ?? "pnpm failed";
139
611
  await log.write(diagnostic);
140
612
  truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
141
613
  output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
142
614
  }
143
- if (exitCode === 0 && options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options);
615
+ if (exitCode === 0 && !control.stalled) {
616
+ const after = readProfileManifest("dsh", dir);
617
+ const warnings = [];
618
+ for (const [name, spec] of Object.entries(directDependencies(after))) {
619
+ const packageDir = join(dir, "node_modules", name);
620
+ const installed = optionalFile(join(packageDir, "package.json"));
621
+ if (installed === void 0) continue;
622
+ const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed;
623
+ const found = [];
624
+ const issues = [];
625
+ try {
626
+ const manifest = readProfileManifest("dsh", packageDir);
627
+ for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
628
+ const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir));
629
+ if (issue !== void 0 && !issue.exempted) {
630
+ found.push(pluginCompatibilityWarning(issue));
631
+ issues.push(incompatiblePlugin(issue));
632
+ }
633
+ }
634
+ } catch (error) {
635
+ found.push(`Cannot validate installed package ${name}: ${String(error)}`);
636
+ }
637
+ if (found.length === 0) continue;
638
+ if (!untouched) {
639
+ warnings.push(...found);
640
+ incompatible.push(...issues);
641
+ } else {
642
+ const notice = `\ndsh: warning: ${found.join("\n")}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n`;
643
+ await log.write(notice);
644
+ options.onOutput?.(notice, "stderr");
645
+ }
646
+ }
647
+ if (warnings.length > 0) {
648
+ await restore();
649
+ const repair = ["install", savedFiles.some((file) => file.path.endsWith("pnpm-lock.yaml") && file.text !== void 0) ? "--frozen-lockfile" : "--config.lockfile=false"];
650
+ const repairing = execa(options.command ?? "pnpm", [...options.args ?? [], ...repair], {
651
+ cwd: dir,
652
+ env: environment,
653
+ extendEnv: false,
654
+ reject: false,
655
+ stdin: "ignore",
656
+ ...options.idleTimeoutMs === void 0 ? {} : { timeout: options.idleTimeoutMs }
657
+ });
658
+ await recordRun(dir, {
659
+ pid: repairing.pid,
660
+ grouped: false
661
+ });
662
+ const repaired = await repairing;
663
+ exitCode = 1;
664
+ const restoration = repaired.exitCode === 0 ? "restored package.json, pnpm-lock.yaml, and node_modules" : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
665
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
666
+ await log.write(diagnostic);
667
+ options.onOutput?.(diagnostic, "stderr");
668
+ append(Buffer.from(diagnostic));
669
+ } else if (options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options);
670
+ }
144
671
  } finally {
672
+ control.settled = true;
673
+ clearTimeout(idleTimer);
674
+ await rm(runRecordPath(dir), { force: true });
145
675
  await log.close();
146
676
  }
147
677
  return {
148
678
  exitCode,
149
679
  output: output.toString("utf8"),
150
680
  truncated,
151
- logPath
681
+ logPath,
682
+ ...control.stalled ? { timedOut: true } : {},
683
+ ...incompatible.length > 0 ? { incompatible } : {}
152
684
  };
153
685
  }
154
686
  /**
@@ -246,6 +778,7 @@ const LOG_KINDS = [
246
778
  ["disk-full", /\bENOSPC\b|no space left on device/i],
247
779
  ["permission", /\bEACCES\b|\bEPERM\b|permission denied/i],
248
780
  ["integrity", /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
781
+ ["unknown", /The requested URL returned error: 40[134]\b/],
249
782
  ["network", /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/]
250
783
  ];
251
784
  /**
@@ -259,110 +792,6 @@ function classifyInstallFailure(facts) {
259
792
  for (const [kind, pattern] of LOG_KINDS) if (pattern.test(facts.log)) return kind;
260
793
  return "unknown";
261
794
  }
262
- //#endregion
263
- //#region lib/types/install-spec.js
264
- /**
265
- * Reading an install spec before pnpm sees it: which of pnpm's spec forms it
266
- * takes, and for a registry name whether it is one the registry can accept.
267
- * @module @deepseek-ai/dsh-plugin-manager/install-spec
268
- */
269
- /** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */
270
- const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
271
- const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
272
- const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i;
273
- /** The hosts pnpm's shorthands stand for. */
274
- const GIT_SHORTHAND_HOSTS = {
275
- github: "github.com",
276
- gitlab: "gitlab.com",
277
- bitbucket: "bitbucket.org",
278
- gist: "gist.github.com"
279
- };
280
- /** A tarball, on disk or over HTTP. */
281
- const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
282
- /** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
283
- const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
284
- const PACKAGE_NAME_MAX_LENGTH = 214;
285
- /** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
286
- var InvalidInstallSpecError = class extends Error {
287
- spec;
288
- reason;
289
- /**
290
- * @param spec - the spec as typed, trimmed.
291
- * @param reason - why it is refused, as one sentence.
292
- */
293
- constructor(spec, reason) {
294
- super(`plugin-manager: ${reason}: ${spec}`);
295
- this.spec = spec;
296
- this.reason = reason;
297
- this.name = "InvalidInstallSpecError";
298
- }
299
- };
300
- function invalid(spec, reason) {
301
- return new InvalidInstallSpecError(spec, reason);
302
- }
303
- /** The host a git spec is cloned from: the shorthand's host, the scp-like user@host, or the URL's host with its port. */
304
- function gitHost(spec) {
305
- const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase();
306
- if (shorthand !== void 0 && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand)) return GIT_SHORTHAND_HOSTS[shorthand];
307
- const scp = /^git@([^:]+):/i.exec(spec)?.[1];
308
- if (scp !== void 0) return scp.toLowerCase();
309
- return new URL(spec.replace(/^git\+/i, "")).host;
310
- }
311
- /**
312
- * Read a spec into its form. A path must be absolute: the Host's working
313
- * directory means nothing to the person typing into a browser, and a
314
- * relative path resolved against the profile would point inside it.
315
- * @param raw - the spec as typed.
316
- * @returns the parsed spec.
317
- * @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
318
- * registry would refuse, or a URL that is neither a git host nor a tarball.
319
- */
320
- function parseInstallSpec(raw) {
321
- const spec = raw.trim();
322
- if (spec === "") throw invalid(spec, "the package spec must not be empty");
323
- const path = spec.replace(/^(?:file|link):/, "");
324
- if (path !== spec || isAbsolute(path)) {
325
- if (!isAbsolute(path)) throw invalid(spec, "a local path must be absolute");
326
- return TARBALL_SPEC.test(path) ? {
327
- kind: "tarball",
328
- spec,
329
- path
330
- } : {
331
- kind: "path",
332
- spec,
333
- path
334
- };
335
- }
336
- if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, "a local path must be absolute");
337
- if ((GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)) && !TARBALL_SPEC.test(spec)) return {
338
- kind: "git",
339
- spec,
340
- host: gitHost(spec)
341
- };
342
- if (/^https?:\/\//i.test(spec)) {
343
- if (TARBALL_SPEC.test(spec)) return {
344
- kind: "tarball",
345
- spec,
346
- host: new URL(spec).host
347
- };
348
- throw invalid(spec, "a URL must point at a git repository or a tarball");
349
- }
350
- const at = spec.indexOf("@", 1);
351
- const name = at === -1 ? spec : spec.slice(0, at);
352
- const range = at === -1 ? void 0 : spec.slice(at + 1);
353
- if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) throw invalid(spec, "not a package name the registry accepts");
354
- if (range === "") throw invalid(spec, "a version after @ must not be empty");
355
- return range === void 0 ? {
356
- kind: "registry",
357
- spec,
358
- name
359
- } : {
360
- kind: "registry",
361
- spec,
362
- name,
363
- range
364
- };
365
- }
366
795
  /** Public npmmirror URL shared by the fallback configuration and public-registry comparison. */
367
796
  const NPMMIRROR_REGISTRY = "https://registry.npmmirror.com/";
368
797
  /**
@@ -482,18 +911,6 @@ async function writePluginEnabled(filename, id, name, enabled) {
482
911
  return true;
483
912
  }
484
913
  //#endregion
485
- //#region lib/types/failure.js
486
- /** Expected management rejection; presentation belongs to the caller's locale. */
487
- var ManagementFailure = class extends Error {
488
- /** Code rendered by the caller's locale dictionary. */
489
- code;
490
- /** @param code Localizable management rejection. */
491
- constructor(code) {
492
- super(code);
493
- this.code = code;
494
- }
495
- };
496
- //#endregion
497
914
  //#region lib/types/build-approval.js
498
915
  /** Approve pnpm's pending dependency scripts in the current profile's workspace settings. */
499
916
  async function readPolicy(dir) {
@@ -537,6 +954,84 @@ async function approveBuilds(dir, names) {
537
954
  await writeFileAtomic(join(dir, "pnpm-workspace.yaml"), String(document), { mode: 384 });
538
955
  }
539
956
  //#endregion
957
+ //#region lib/types/github-connection.js
958
+ /** Bounded GitHub repository checks using the installer's Git configuration and environment. */
959
+ /**
960
+ * Check a GitHub repository before pnpm starts, without downloading or building its package.
961
+ * Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting.
962
+ * Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback.
963
+ * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked.
964
+ * @param dir The profile directory where installation runs.
965
+ * @param options The connection deadline, output bound and operation cancellation.
966
+ * @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec.
967
+ */
968
+ async function checkGithubConnection(spec, dir, options) {
969
+ if (spec.kind !== "git") return void 0;
970
+ const host = spec.host.toLowerCase().replace(/:.*$/, "");
971
+ if (host !== "github.com" && !host.endsWith(".github.com")) return void 0;
972
+ const repository = spec.spec.replace(/#.*$/s, "").replace(/^github:/i, "https://github.com/").replace(/^gist:/i, "https://gist.github.com/").replace(/^git\+/i, "");
973
+ const logRoot = join(dir, ".plugin-manager", "logs");
974
+ await mkdir(logRoot, {
975
+ recursive: true,
976
+ mode: 448
977
+ });
978
+ const logPath = join(await mkdtemp(join(logRoot, "github-connection-")), "git.log");
979
+ const log = await open(logPath, "ax+", 384);
980
+ try {
981
+ const result = await execa("git", [
982
+ "-c",
983
+ "credential.helper=",
984
+ "ls-remote",
985
+ "--",
986
+ repository,
987
+ "HEAD"
988
+ ], {
989
+ cwd: dir,
990
+ env: {
991
+ ...scrubbedParentEnv(),
992
+ ...options.env,
993
+ LC_ALL: "C",
994
+ GIT_TERMINAL_PROMPT: "0",
995
+ GIT_ASKPASS: "",
996
+ SSH_ASKPASS: "",
997
+ SSH_ASKPASS_REQUIRE: "never"
998
+ },
999
+ extendEnv: false,
1000
+ stdin: "ignore",
1001
+ stdout: "ignore",
1002
+ stderr: {
1003
+ file: logPath,
1004
+ append: true
1005
+ },
1006
+ buffer: false,
1007
+ reject: false,
1008
+ timeout: options.timeoutMs,
1009
+ cancelSignal: options.signal,
1010
+ killDescendants: true,
1011
+ killSignal: "SIGKILL"
1012
+ });
1013
+ if (!result.failed) return void 0;
1014
+ if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`);
1015
+ if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? "GitHub connection check failed");
1016
+ const { size } = await log.stat();
1017
+ const bytes = Buffer.alloc(Math.min(size, options.outputBytes));
1018
+ await log.read(bytes, 0, bytes.length, size - bytes.length);
1019
+ const output = bytes.toString("utf8");
1020
+ return {
1021
+ exitCode: result.exitCode ?? (result.code === "ENOENT" ? 127 : 1),
1022
+ output,
1023
+ truncated: size > options.outputBytes,
1024
+ logPath,
1025
+ kind: classifyInstallFailure({
1026
+ log: output,
1027
+ timedOut: result.timedOut
1028
+ })
1029
+ };
1030
+ } finally {
1031
+ await log.close();
1032
+ }
1033
+ }
1034
+ //#endregion
540
1035
  //#region lib/types/index.js
541
1036
  /** Current-profile plugin and bundle management over shared dsh plugin operations. */
542
1037
  var __runInitializers = function(thisArg, initializers, value) {
@@ -611,10 +1106,14 @@ function messageOf(error) {
611
1106
  }
612
1107
  /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
613
1108
  function managementError(error) {
614
- return error instanceof ManagementFailure ? { code: error.code } : {
1109
+ if (!(error instanceof ManagementFailure)) return {
615
1110
  code: "operation-error",
616
1111
  diagnostic: messageOf(error)
617
1112
  };
1113
+ return {
1114
+ code: error.code,
1115
+ ...error.incompatible === void 0 ? {} : { incompatible: error.incompatible }
1116
+ };
618
1117
  }
619
1118
  /** The caller stopped an installation; its files are restored before this is thrown. */
620
1119
  var InstallCancelledError = class extends Error {
@@ -684,6 +1183,8 @@ function parsedForRegistry(spec) {
684
1183
  let PluginManager = (() => {
685
1184
  let _classSuper = TypertRemoteService;
686
1185
  let _instanceExtraInitializers = [];
1186
+ let _listVersionExemptions_decorators;
1187
+ let _setVersionExemption_decorators;
687
1188
  let _listPlugins_decorators;
688
1189
  let _listBundles_decorators;
689
1190
  let _registries_decorators;
@@ -697,6 +1198,8 @@ let PluginManager = (() => {
697
1198
  return class PluginManager extends _classSuper {
698
1199
  static {
699
1200
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
1201
+ _listVersionExemptions_decorators = [Remote];
1202
+ _setVersionExemption_decorators = [Remote];
700
1203
  _listPlugins_decorators = [Remote];
701
1204
  _listBundles_decorators = [Remote];
702
1205
  _registries_decorators = [Remote];
@@ -707,6 +1210,28 @@ let PluginManager = (() => {
707
1210
  _waitForInstall_decorators = [Remote];
708
1211
  _cancelInstall_decorators = [Remote];
709
1212
  _removeBundle_decorators = [Remote];
1213
+ __esDecorate(this, null, _listVersionExemptions_decorators, {
1214
+ kind: "method",
1215
+ name: "listVersionExemptions",
1216
+ static: false,
1217
+ private: false,
1218
+ access: {
1219
+ has: (obj) => "listVersionExemptions" in obj,
1220
+ get: (obj) => obj.listVersionExemptions
1221
+ },
1222
+ metadata: _metadata
1223
+ }, null, _instanceExtraInitializers);
1224
+ __esDecorate(this, null, _setVersionExemption_decorators, {
1225
+ kind: "method",
1226
+ name: "setVersionExemption",
1227
+ static: false,
1228
+ private: false,
1229
+ access: {
1230
+ has: (obj) => "setVersionExemption" in obj,
1231
+ get: (obj) => obj.setVersionExemption
1232
+ },
1233
+ metadata: _metadata
1234
+ }, null, _instanceExtraInitializers);
710
1235
  __esDecorate(this, null, _listPlugins_decorators, {
711
1236
  kind: "method",
712
1237
  name: "listPlugins",
@@ -830,6 +1355,8 @@ let PluginManager = (() => {
830
1355
  outputBytes: z.number().step(1).min(1).default(16384),
831
1356
  lockWaitMs: z.number().step(1).min(0).default(12e4),
832
1357
  inspectTimeoutMs: z.number().step(1).min(1e3).default(2e4),
1358
+ githubConnectionTimeoutMs: z.number().step(1).min(1e3).default(5e3),
1359
+ idleTimeoutMs: z.number().step(1).min(1e3).default(6e5),
833
1360
  registry: z.string().pattern(REGISTRY_URL),
834
1361
  fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY])
835
1362
  });
@@ -841,6 +1368,8 @@ let PluginManager = (() => {
841
1368
  outputBytes;
842
1369
  lockWaitMs;
843
1370
  inspectTimeoutMs;
1371
+ githubConnectionTimeoutMs;
1372
+ idleTimeoutMs;
844
1373
  pnpmCommand;
845
1374
  configuredRegistries;
846
1375
  ownerContext;
@@ -856,6 +1385,8 @@ let PluginManager = (() => {
856
1385
  this.outputBytes = config.outputBytes;
857
1386
  this.lockWaitMs = config.lockWaitMs;
858
1387
  this.inspectTimeoutMs = config.inspectTimeoutMs;
1388
+ this.githubConnectionTimeoutMs = config.githubConnectionTimeoutMs;
1389
+ this.idleTimeoutMs = config.idleTimeoutMs;
859
1390
  this.pnpmCommand = config.pnpmCommand;
860
1391
  this.configuredRegistries = {
861
1392
  registry: config.registry === void 0 ? null : normalizeRegistry(config.registry),
@@ -866,6 +1397,34 @@ let PluginManager = (() => {
866
1397
  await Promise.allSettled([...this.packageOperations]);
867
1398
  }, "plugin-manager: package cancellation");
868
1399
  }
1400
+ /** Read exact plugin-version exemptions saved in this profile.
1401
+ * @returns Accepted package-name@version keys with the runtime versions they may run on, and any
1402
+ * record or file problem the reader rejected, which the caller reports instead of failing.
1403
+ */
1404
+ listVersionExemptions() {
1405
+ const { exemptions, warnings } = readProfileCompatibility(this.profile.dir);
1406
+ return {
1407
+ exemptions,
1408
+ warnings
1409
+ };
1410
+ }
1411
+ /** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
1412
+ * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
1413
+ * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
1414
+ * @param enabled Whether to grant rather than revoke the exemption.
1415
+ * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
1416
+ * @returns Saved and runtime outcomes. Startup-only profiles require restart.
1417
+ */
1418
+ setVersionExemption(packageVersion, runtimeVersion, enabled, acceptRisk) {
1419
+ return this.change((result) => this.configure(async () => {
1420
+ await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true);
1421
+ result.warnings = await this.reload();
1422
+ }), {
1423
+ stage: "enable",
1424
+ target: packageVersion,
1425
+ enabled
1426
+ }, "bundle");
1427
+ }
869
1428
  /** Read current plugins, including why a row cannot be changed through the profile patch.
870
1429
  * @returns Current runtime entries with persistent patch targets.
871
1430
  */
@@ -896,6 +1455,7 @@ let PluginManager = (() => {
896
1455
  */
897
1456
  listBundles() {
898
1457
  const manifest = readProfileManifest("dsh", this.profile.dir);
1458
+ const exemptions = readProfileVersionExemptions(this.profile.dir);
899
1459
  const selected = manifest.dsh?.profile?.bundles ?? [];
900
1460
  const dependencies = Object.keys(manifest.dependencies ?? {});
901
1461
  const installation = JSON.parse(readFileSync(this.profile.installAnchor, "utf8"));
@@ -927,6 +1487,8 @@ let PluginManager = (() => {
927
1487
  });
928
1488
  continue;
929
1489
  }
1490
+ const compatibility = evaluatePluginCompatibility(info, exemptions);
1491
+ if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
930
1492
  const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
931
1493
  const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, "package.json")).href);
932
1494
  bundles.push({
@@ -1116,7 +1678,9 @@ let PluginManager = (() => {
1116
1678
  }, "bundle");
1117
1679
  }
1118
1680
  /**
1119
- * Install a package using the same pnpm implementation as dsh plugin. A run
1681
+ * Install a package using the same pnpm implementation as dsh plugin. GitHub
1682
+ * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
1683
+ * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
1120
1684
  * that fails, is cancelled, or adds a package without a bundle patch restores
1121
1685
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
1122
1686
  * @param spec One package spec, including local paths relative to the invocation directory.
@@ -1131,7 +1695,7 @@ let PluginManager = (() => {
1131
1695
  phase: "installing",
1132
1696
  result: Promise.resolve(null)
1133
1697
  };
1134
- const stopped = () => control.abort.signal.aborted;
1698
+ const stopped = () => control.abort.signal.aborted || this.abort.signal.aborted;
1135
1699
  if (requestId !== void 0) this.installs.set(requestId, control);
1136
1700
  const announce = (phase, attempt) => {
1137
1701
  if (requestId !== void 0) this.ownerContext.emit("plugin-manager/install-state", {
@@ -1149,11 +1713,30 @@ let PluginManager = (() => {
1149
1713
  }
1150
1714
  const files = await this.readRestoredFiles();
1151
1715
  const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {};
1152
- const plan = registryPlan(options?.registry, await this.registries());
1153
1716
  let name;
1154
1717
  try {
1155
- let run;
1156
1718
  result.registries = [];
1719
+ const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
1720
+ timeoutMs: this.githubConnectionTimeoutMs,
1721
+ outputBytes: this.outputBytes,
1722
+ signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
1723
+ ...this.profile.packageManager?.env === void 0 ? {} : { env: this.profile.packageManager.env }
1724
+ });
1725
+ this.packageOperations.add(connection);
1726
+ let connectionFailure;
1727
+ try {
1728
+ connectionFailure = await connection;
1729
+ } finally {
1730
+ this.packageOperations.delete(connection);
1731
+ }
1732
+ if (stopped()) throw new InstallCancelledError();
1733
+ if (connectionFailure?.kind === "network" || connectionFailure?.kind === "timeout") {
1734
+ result.packageResult = connectionFailure;
1735
+ result.failedAt = "spec-host";
1736
+ throw new Error(connectionFailure.output);
1737
+ }
1738
+ const plan = registryPlan(options?.registry, await this.registries());
1739
+ let run;
1157
1740
  for (const [index, registry] of plan.entries()) {
1158
1741
  if (index > 0) await this.restoreFiles(files);
1159
1742
  if (stopped()) throw new InstallCancelledError();
@@ -1170,17 +1753,21 @@ let PluginManager = (() => {
1170
1753
  ], control.abort.signal, requestId);
1171
1754
  result.packageResult = run;
1172
1755
  if (stopped()) throw new InstallCancelledError();
1173
- /* v8 ignore next 2 -- runPnpm classifies every failed run, so kind is never absent here */
1174
- if (run.exitCode === 0 || run.kind === void 0) break;
1175
- const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
1756
+ if (run.incompatible !== void 0) throw new ManagementFailure("incompatible-version", run.incompatible);
1757
+ if (run.exitCode === 0 && run.timedOut !== true) break;
1758
+ /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */
1759
+ if (run.kind === void 0) break;
1176
1760
  delete result.failedAt;
1761
+ if (run.timedOut === true) break;
1762
+ const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
1177
1763
  if (failedAt !== "other") result.failedAt = failedAt;
1178
1764
  if (failedAt !== "registry" || index === plan.length - 1) break;
1179
1765
  }
1180
1766
  /* v8 ignore next -- the plan is never empty, so a run always settled */
1181
1767
  if (run === void 0) throw new Error("no registry was asked");
1182
- if (run.exitCode === 0) delete result.failedAt;
1183
- if (run.exitCode !== 0) {
1768
+ const succeeded = run.exitCode === 0 && run.timedOut !== true;
1769
+ if (succeeded) delete result.failedAt;
1770
+ if (!succeeded) {
1184
1771
  try {
1185
1772
  result.pendingBuilds = await readPendingBuilds(this.profile.dir);
1186
1773
  } catch (error) {
@@ -1197,6 +1784,8 @@ let PluginManager = (() => {
1197
1784
  const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
1198
1785
  const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
1199
1786
  if (manifest?.dsh?.bundle === void 0) throw new ManagementFailure("not-bundle");
1787
+ const compatibility = evaluatePluginCompatibility(manifest, readProfileVersionExemptions(this.profile.dir));
1788
+ if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
1200
1789
  for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches("dsh", file);
1201
1790
  } catch (error) {
1202
1791
  await this.restoreFiles(files);
@@ -1232,7 +1821,7 @@ let PluginManager = (() => {
1232
1821
  }
1233
1822
  /** Stop an installation this manager owns and wait until its files are back.
1234
1823
  * @param requestId The id the installation was started with.
1235
- * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
1824
+ * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
1236
1825
  * applied, `not-running` for any other id.
1237
1826
  */
1238
1827
  async cancelInstall(requestId) {
@@ -1266,7 +1855,7 @@ let PluginManager = (() => {
1266
1855
  if ([...this.ctx.loader.entries()].some((entry) => entry.fiber?.uid != null && contributions.some((row) => row.id === entry.options.id && row.name === entry.options.name))) throw new ManagementFailure("bundle-in-use");
1267
1856
  });
1268
1857
  result.packageResult = await this.runPnpm(["remove", name]);
1269
- if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output);
1858
+ if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) throw new Error(result.packageResult.output);
1270
1859
  }, {
1271
1860
  stage: "remove",
1272
1861
  target: name
@@ -1325,6 +1914,8 @@ let PluginManager = (() => {
1325
1914
  signal: signal === void 0 ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
1326
1915
  outputBytes: this.outputBytes,
1327
1916
  activateNewBundles: false,
1917
+ idleTimeoutMs: this.idleTimeoutMs,
1918
+ lookupTimeoutMs: this.inspectTimeoutMs,
1328
1919
  onOutput: (text, stream) => {
1329
1920
  this.ownerContext.emit("plugin-manager/install-log", {
1330
1921
  ...identity,
@@ -1348,9 +1939,13 @@ let PluginManager = (() => {
1348
1939
  text: "",
1349
1940
  exitCode: signal?.aborted === true ? null : result.exitCode
1350
1941
  });
1351
- return result.exitCode === 0 ? result : {
1942
+ if (result.exitCode === 0 && result.timedOut !== true) return result;
1943
+ return {
1352
1944
  ...result,
1353
- kind: classifyInstallFailure({ log: result.output })
1945
+ kind: classifyInstallFailure({
1946
+ log: result.output,
1947
+ ...result.timedOut === true ? { timedOut: true } : {}
1948
+ })
1354
1949
  };
1355
1950
  } catch (error) {
1356
1951
  this.ownerContext.emit("plugin-manager/install-log", {
@@ -1384,8 +1979,15 @@ let PluginManager = (() => {
1384
1979
  async selectBundle(name, enabled) {
1385
1980
  const manifest = readProfileManifest("dsh", this.profile.dir);
1386
1981
  const previous = manifest.dsh?.profile?.bundles ?? [];
1387
- if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === void 0) throw new ManagementFailure("not-bundle");
1388
- if (enabled) this.bundleRows(name);
1982
+ if (enabled || !previous.includes(name)) {
1983
+ const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
1984
+ if (metadata === void 0) throw new ManagementFailure("not-bundle");
1985
+ if (enabled) {
1986
+ const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir));
1987
+ if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
1988
+ this.bundleRows(name);
1989
+ }
1990
+ }
1389
1991
  if (!enabled && previous.includes(name)) {
1390
1992
  if (this.protectsManager(name)) throw new ManagementFailure("management-required");
1391
1993
  }
@@ -1457,7 +2059,8 @@ let PluginManager = (() => {
1457
2059
  return [
1458
2060
  "package.json",
1459
2061
  "cordis.patch.yml",
1460
- "pnpm-workspace.yaml"
2062
+ "pnpm-workspace.yaml",
2063
+ PROFILE_COMPATIBILITY_FILENAME
1461
2064
  ].map((file) => {
1462
2065
  try {
1463
2066
  return readFileSync(join(this.profile.dir, file), "utf8");