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

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,200 @@ 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
+ * Wait until a terminated run's tree is gone, so the caller restores and
144
+ * unlocks the profile only after the scripts it started stopped writing.
145
+ * @param tree The run's process id and whether it leads its own group.
146
+ * @param internals Injectable process operations.
147
+ * @returns Fulfillment once no member remains, or the wait bound elapsed.
148
+ */
149
+ async function awaitTreeGone(tree, internals = {}) {
150
+ const pid = tree.pid;
151
+ if (pid === void 0) return;
152
+ const platform = internals.platform ?? process.platform;
153
+ const alive = internals.alive ?? ((target) => {
154
+ process.kill(target, 0);
155
+ return true;
156
+ });
157
+ const remaining = () => {
158
+ try {
159
+ return alive(targetOf(pid, tree.grouped, platform));
160
+ } catch {
161
+ return false;
162
+ }
163
+ };
164
+ const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
165
+ while (remaining()) {
166
+ if (Date.now() >= deadline) return;
167
+ await new Promise((resolve) => setTimeout(resolve, TREE_POLL_MS));
168
+ }
169
+ }
170
+ //#endregion
171
+ //#region lib/types/failure.js
172
+ /** Expected management rejection; presentation belongs to the caller's locale. */
173
+ var ManagementFailure = class extends Error {
174
+ /** Code rendered by the caller's locale dictionary. */
175
+ code;
176
+ /** The packages an `incompatible-version` rejection names. */
177
+ incompatible;
178
+ /**
179
+ * @param code Localizable management rejection.
180
+ * @param incompatible Packages the running DSH version rejects, for `incompatible-version`.
181
+ */
182
+ constructor(code, incompatible) {
183
+ super(code);
184
+ this.code = code;
185
+ this.incompatible = incompatible;
186
+ }
187
+ };
188
+ /**
189
+ * Drop the exemption status from an unexempted compatibility result.
190
+ * @param issue Result whose exemption is not active.
191
+ * @returns The package, runtime, and rejected peer ranges.
192
+ */
193
+ function incompatiblePlugin(issue) {
194
+ return {
195
+ name: issue.name,
196
+ version: issue.version,
197
+ runtimeVersion: issue.runtimeVersion,
198
+ peers: issue.peers
199
+ };
200
+ }
201
+ //#endregion
14
202
  //#region lib/types/operations.js
203
+ /** Shared profile package operations used by dsh plugin and the running manager. */
15
204
  /** Resolve relative package specs against the caller's directory.
16
205
  * @param argument One pnpm argument.
17
206
  * @param cwd Invocation directory, never the profile directory.
@@ -69,15 +258,148 @@ async function reconcile(before, dir, anchor, options) {
69
258
  };
70
259
  await saveManifest(dir, after);
71
260
  }
261
+ /**
262
+ * How long the pipes keep draining after their process exited, as a fixed part of
263
+ * finishing a run rather than a deployment knob: a descendant that inherited them
264
+ * holds them open, and the tail a failure classification reads is written by then.
265
+ */
266
+ const DRAIN_AFTER_EXIT_MS = 2e3;
267
+ /** Whether every collector finished within `ms`.
268
+ * @param collectors The pipe readers racing the bound.
269
+ * @param ms The longest wait, in milliseconds.
270
+ * @returns True when all collectors settled in time.
271
+ */
272
+ async function drainWithin(collectors, ms) {
273
+ if (collectors.length === 0) return true;
274
+ let timer;
275
+ try {
276
+ return await Promise.race([Promise.allSettled(collectors).then(() => true), new Promise((resolve) => {
277
+ timer = setTimeout(() => {
278
+ resolve(false);
279
+ }, ms);
280
+ })]);
281
+ } finally {
282
+ clearTimeout(timer);
283
+ }
284
+ }
285
+ /** Install commands that take the packages to install as positionals. */
286
+ const INSTALL_COMMANDS = new Set([
287
+ "add",
288
+ "install",
289
+ "i"
290
+ ]);
291
+ /** Bound on a pre-install registry lookup when the caller names none. */
292
+ const LOOKUP_TIMEOUT_MS = 2e4;
293
+ /** Package specs an install command names explicitly, in order. */
294
+ function namedSpecs(args) {
295
+ const index = args.findIndex((argument) => !argument.startsWith("-"));
296
+ const command = index < 0 ? void 0 : args[index];
297
+ if (command === void 0 || !INSTALL_COMMANDS.has(command)) return [];
298
+ return args.slice(index + 1).filter((argument) => !argument.startsWith("-"));
299
+ }
300
+ /** The manifest a named spec would install, read without installing it.
301
+ * A path spec is read from disk. A registry spec asks pnpm's own configuration for the version the
302
+ * range selects and its peer requirements. A git or tarball spec needs the fetch itself, so the
303
+ * check after installation is what judges it.
304
+ * @param dir Profile directory the lookup runs in.
305
+ * @param spec Anchored install spec.
306
+ * @param options Pnpm executable, prefix arguments, the caller's bound and signal.
307
+ * @param environment Environment of the caller's pnpm invocations.
308
+ * @param flags Flags of the run itself, so the lookup asks the registry that run will use.
309
+ * @returns The package manifest, or undefined when reading it would need the installation itself.
310
+ */
311
+ async function namedSpecManifest(dir, spec, options, environment, flags) {
312
+ const parsed = parseInstallSpec(spec);
313
+ if (parsed.kind === "path") {
314
+ const filename = join(parsed.path, "package.json");
315
+ return existsSync(filename) ? JSON.parse(readFileSync(filename, "utf8")) : void 0;
316
+ }
317
+ if (parsed.kind !== "registry") return void 0;
318
+ const viewed = await execa(options.command ?? "pnpm", [
319
+ ...options.args ?? [],
320
+ "view",
321
+ parsed.spec,
322
+ "name",
323
+ "version",
324
+ "peerDependencies",
325
+ "--json",
326
+ ...flags,
327
+ "--config.fetch-retries=0"
328
+ ], {
329
+ cwd: dir,
330
+ env: environment,
331
+ extendEnv: false,
332
+ reject: false,
333
+ stdin: "ignore",
334
+ ...options.signal === void 0 ? {} : { cancelSignal: options.signal },
335
+ timeout: options.lookupTimeoutMs ?? LOOKUP_TIMEOUT_MS
336
+ });
337
+ if (viewed.exitCode !== 0) return void 0;
338
+ const value = JSON.parse(viewed.stdout);
339
+ return Array.isArray(value) ? value.at(-1) : value;
340
+ }
341
+ /** Missing installed packages are repairable; their absence is part of the before/after comparison. */
342
+ function optionalFile(path) {
343
+ try {
344
+ return readFileSync(path, "utf8");
345
+ } catch (error) {
346
+ if (error.code === "ENOENT") return void 0;
347
+ throw error;
348
+ }
349
+ }
350
+ /** pnpm can install plugins through any direct-dependency field. */
351
+ function directDependencies(manifest) {
352
+ const extra = manifest;
353
+ return {
354
+ ...extra.devDependencies,
355
+ ...manifest.dependencies,
356
+ ...extra.optionalDependencies
357
+ };
358
+ }
359
+ /** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */
360
+ function bundleComponentManifests(manifest, dir, anchor) {
361
+ const bundle = manifest.dsh?.bundle;
362
+ if (bundle === void 0) return [];
363
+ const patches = bundlePatchPaths(dir, bundle).flatMap((file) => loadOverlayPatches("dsh", file));
364
+ const names = /* @__PURE__ */ new Set();
365
+ const visit = (rows) => {
366
+ for (const row of rows) {
367
+ if (row.group && Array.isArray(row.config)) visit(row.config);
368
+ if (typeof row.name !== "string" || row.name.startsWith(".") || row.name.startsWith("/") || row.name.includes(":")) continue;
369
+ const parts = row.name.split("/");
370
+ names.add(parts.slice(0, row.name.startsWith("@") ? 2 : 1).join("/"));
371
+ }
372
+ };
373
+ visit(composeEntries([patches.filter((patch) => patch.insert !== void 0)]));
374
+ return [...names].flatMap((name) => {
375
+ let packageDir;
376
+ try {
377
+ packageDir = resolveBundleDir("dsh", name, anchor, dir);
378
+ } catch (error) {
379
+ return [];
380
+ }
381
+ return [readProfileManifest("dsh", packageDir)];
382
+ });
383
+ }
72
384
  /** Execute pnpm inside a profile whose caller already holds the profile write lock.
385
+ * Newly installed or updated direct dependencies are checked even when activation is disabled;
386
+ * an untouched dependency never blocks an unrelated operation and stays denied at startup.
387
+ * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk.
73
388
  * @param context Launcher-owned profile and resolution locations.
74
389
  * @param args Pnpm arguments, before relative path anchoring.
75
390
  * @param options Output, activation and cancellation policy.
76
- * @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
391
+ * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
392
+ * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
77
393
  */
78
394
  async function runProfilePnpm(context, args, options) {
79
395
  const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
80
396
  const before = readProfileManifest("dsh", dir);
397
+ const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({
398
+ path: join(dir, name),
399
+ text: optionalFile(join(dir, name))
400
+ }));
401
+ const beforeDependencies = directDependencies(before);
402
+ const installedBefore = new Map(Object.keys(beforeDependencies).map((name) => [name, optionalFile(join(dir, "node_modules", name, "package.json"))]));
81
403
  const logRoot = join(dir, ".plugin-manager", "logs");
82
404
  await mkdir(logRoot, {
83
405
  recursive: true,
@@ -87,68 +409,210 @@ async function runProfilePnpm(context, args, options) {
87
409
  const log = await open(logPath, "wx", 384);
88
410
  let output = Buffer.alloc(0);
89
411
  let truncated = false;
412
+ const append = (bytes) => {
413
+ output = Buffer.concat([output, bytes]);
414
+ if (output.length > options.outputBytes) {
415
+ truncated = true;
416
+ output = output.subarray(output.length - options.outputBytes);
417
+ }
418
+ };
419
+ const environment = {
420
+ ...options.execution === "cli" ? process.env : scrubbedParentEnv(),
421
+ ...options.env
422
+ };
423
+ const restore = async () => {
424
+ for (const file of savedFiles) if (file.text === void 0) await rm(file.path, { force: true });
425
+ else await writeFileAtomic(file.path, file.text, { mode: 384 });
426
+ };
427
+ /** Packages a compatibility check refused; callers render them for their own surface. */
428
+ const incompatible = [];
429
+ const rejected = async (warnings, restoration) => {
430
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
431
+ await log.write(diagnostic);
432
+ options.onOutput?.(diagnostic, "stderr");
433
+ append(Buffer.from(diagnostic));
434
+ await log.close();
435
+ return {
436
+ exitCode: 1,
437
+ output: output.toString("utf8"),
438
+ truncated,
439
+ logPath,
440
+ incompatible
441
+ };
442
+ };
443
+ const preflight = [];
444
+ const exemptions = readProfileVersionExemptions(dir);
445
+ const registryFlags = args.filter((argument) => argument.startsWith("--registry="));
446
+ for (const raw of namedSpecs(args)) try {
447
+ const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags);
448
+ if (manifest === void 0) continue;
449
+ const issue = evaluatePluginCompatibility(manifest, exemptions);
450
+ if (issue !== void 0 && !issue.exempted) {
451
+ preflight.push(pluginCompatibilityWarning(issue));
452
+ incompatible.push(incompatiblePlugin(issue));
453
+ }
454
+ } catch (error) {
455
+ continue;
456
+ }
457
+ if (preflight.length > 0) return rejected(preflight, "nothing was installed");
90
458
  const cancellation = new AbortController();
459
+ const grouped = leadsOwnGroup(options.execution);
91
460
  const child = execa(options.command ?? "pnpm", [...options.args ?? [], ...args.map((arg) => anchorPathSpec(arg, context.cwd))], {
92
461
  cwd: dir,
93
- env: {
94
- ...options.execution === "cli" ? process.env : scrubbedParentEnv(),
95
- ...options.env
96
- },
462
+ env: environment,
97
463
  extendEnv: false,
98
464
  reject: false,
99
465
  stdout: options.execution === "cli" ? "inherit" : "pipe",
100
466
  stderr: options.execution === "cli" ? "inherit" : "pipe",
467
+ killDescendants: options.execution === "service",
101
468
  buffer: false,
102
469
  stdin: options.execution === "cli" ? "inherit" : "ignore",
103
470
  cancelSignal: options.signal === void 0 ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal])
104
471
  });
105
472
  let writes = Promise.resolve();
473
+ /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
474
+ const control = {
475
+ settled: false,
476
+ stalled: false
477
+ };
478
+ /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
479
+ let cut = false;
480
+ /** The first failure a reading hit before that cut, which the run still reports. */
481
+ let failure;
482
+ let idleTimer;
483
+ /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
484
+ const armIdle = () => {
485
+ if (control.settled || options.idleTimeoutMs === void 0) return;
486
+ clearTimeout(idleTimer);
487
+ idleTimer = setTimeout(() => {
488
+ control.stalled = true;
489
+ child.kill();
490
+ }, options.idleTimeoutMs);
491
+ };
106
492
  const collect = async (stream, kind) => {
107
493
  try {
108
494
  for await (const chunk of stream) {
495
+ armIdle();
109
496
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
110
497
  writes = writes.then(async () => {
111
498
  await log.write(bytes);
112
499
  });
113
500
  await writes;
114
501
  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
- }
502
+ append(bytes);
120
503
  }
121
504
  } catch (error) {
122
- cancellation.abort();
505
+ if (!cut) {
506
+ failure ??= error instanceof Error ? error : new Error(String(error));
507
+ cancellation.abort();
508
+ }
123
509
  throw error;
124
510
  }
125
511
  };
512
+ const collectors = [...child.stdout === null ? [] : [collect(child.stdout, "stdout")], ...child.stderr === null ? [] : [collect(child.stderr, "stderr")]];
513
+ for (const collector of collectors) collector.catch(() => {});
514
+ if (collectors.length > 0) armIdle();
126
515
  let exitCode;
127
516
  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;
517
+ const settled = Promise.allSettled([child]);
518
+ await Promise.race([once(child.nodeChildProcess, "exit").catch(() => void 0), settled]);
519
+ control.settled = true;
520
+ clearTimeout(idleTimer);
521
+ if (control.stalled) await awaitTreeGone({
522
+ pid: child.pid,
523
+ grouped
524
+ });
525
+ if (await drainWithin(collectors, DRAIN_AFTER_EXIT_MS)) {
526
+ for (const stream of await Promise.allSettled(collectors)) if (stream.status === "rejected") throw stream.reason;
527
+ } else {
528
+ cut = true;
529
+ child.stdout?.destroy();
530
+ child.stderr?.destroy();
531
+ const notice = "dsh: pnpm output was cut short after its process exited\n";
532
+ await log.write(notice);
533
+ if (failure !== void 0) throw failure;
534
+ options.onOutput?.(notice, "stderr");
535
+ append(Buffer.from(notice));
536
+ }
537
+ const [completion] = await settled;
134
538
  if (completion.status === "rejected") throw completion.reason;
135
539
  const result = completion.value;
136
540
  exitCode = result.exitCode ?? (result.code === "ENOENT" ? 127 : 1);
541
+ if (control.stalled) {
542
+ const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`;
543
+ await log.write(notice);
544
+ options.onOutput?.(notice, "stderr");
545
+ append(Buffer.from(notice));
546
+ }
137
547
  if (result.failed && output.length === 0) {
138
548
  const diagnostic = result.shortMessage ?? "pnpm failed";
139
549
  await log.write(diagnostic);
140
550
  truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
141
551
  output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
142
552
  }
143
- if (exitCode === 0 && options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options);
553
+ if (exitCode === 0 && !control.stalled) {
554
+ const after = readProfileManifest("dsh", dir);
555
+ const warnings = [];
556
+ for (const [name, spec] of Object.entries(directDependencies(after))) {
557
+ const packageDir = join(dir, "node_modules", name);
558
+ const installed = optionalFile(join(packageDir, "package.json"));
559
+ if (installed === void 0) continue;
560
+ const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed;
561
+ const found = [];
562
+ const issues = [];
563
+ try {
564
+ const manifest = readProfileManifest("dsh", packageDir);
565
+ for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
566
+ const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir));
567
+ if (issue !== void 0 && !issue.exempted) {
568
+ found.push(pluginCompatibilityWarning(issue));
569
+ issues.push(incompatiblePlugin(issue));
570
+ }
571
+ }
572
+ } catch (error) {
573
+ found.push(`Cannot validate installed package ${name}: ${String(error)}`);
574
+ }
575
+ if (found.length === 0) continue;
576
+ if (!untouched) {
577
+ warnings.push(...found);
578
+ incompatible.push(...issues);
579
+ } else {
580
+ 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`;
581
+ await log.write(notice);
582
+ options.onOutput?.(notice, "stderr");
583
+ }
584
+ }
585
+ if (warnings.length > 0) {
586
+ await restore();
587
+ const repair = ["install", savedFiles.some((file) => file.path.endsWith("pnpm-lock.yaml") && file.text !== void 0) ? "--frozen-lockfile" : "--config.lockfile=false"];
588
+ const repaired = await execa(options.command ?? "pnpm", [...options.args ?? [], ...repair], {
589
+ cwd: dir,
590
+ env: environment,
591
+ extendEnv: false,
592
+ reject: false,
593
+ stdin: "ignore",
594
+ ...options.idleTimeoutMs === void 0 ? {} : { timeout: options.idleTimeoutMs }
595
+ });
596
+ exitCode = 1;
597
+ 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'";
598
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
599
+ await log.write(diagnostic);
600
+ options.onOutput?.(diagnostic, "stderr");
601
+ append(Buffer.from(diagnostic));
602
+ } else if (options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options);
603
+ }
144
604
  } finally {
605
+ control.settled = true;
606
+ clearTimeout(idleTimer);
145
607
  await log.close();
146
608
  }
147
609
  return {
148
610
  exitCode,
149
611
  output: output.toString("utf8"),
150
612
  truncated,
151
- logPath
613
+ logPath,
614
+ ...control.stalled ? { timedOut: true } : {},
615
+ ...incompatible.length > 0 ? { incompatible } : {}
152
616
  };
153
617
  }
154
618
  /**
@@ -246,6 +710,7 @@ const LOG_KINDS = [
246
710
  ["disk-full", /\bENOSPC\b|no space left on device/i],
247
711
  ["permission", /\bEACCES\b|\bEPERM\b|permission denied/i],
248
712
  ["integrity", /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
713
+ ["unknown", /The requested URL returned error: 40[134]\b/],
249
714
  ["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
715
  ];
251
716
  /**
@@ -259,110 +724,6 @@ function classifyInstallFailure(facts) {
259
724
  for (const [kind, pattern] of LOG_KINDS) if (pattern.test(facts.log)) return kind;
260
725
  return "unknown";
261
726
  }
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
727
  /** Public npmmirror URL shared by the fallback configuration and public-registry comparison. */
367
728
  const NPMMIRROR_REGISTRY = "https://registry.npmmirror.com/";
368
729
  /**
@@ -482,18 +843,6 @@ async function writePluginEnabled(filename, id, name, enabled) {
482
843
  return true;
483
844
  }
484
845
  //#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
846
  //#region lib/types/build-approval.js
498
847
  /** Approve pnpm's pending dependency scripts in the current profile's workspace settings. */
499
848
  async function readPolicy(dir) {
@@ -537,6 +886,84 @@ async function approveBuilds(dir, names) {
537
886
  await writeFileAtomic(join(dir, "pnpm-workspace.yaml"), String(document), { mode: 384 });
538
887
  }
539
888
  //#endregion
889
+ //#region lib/types/github-connection.js
890
+ /** Bounded GitHub repository checks using the installer's Git configuration and environment. */
891
+ /**
892
+ * Check a GitHub repository before pnpm starts, without downloading or building its package.
893
+ * Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting.
894
+ * Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback.
895
+ * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked.
896
+ * @param dir The profile directory where installation runs.
897
+ * @param options The connection deadline, output bound and operation cancellation.
898
+ * @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec.
899
+ */
900
+ async function checkGithubConnection(spec, dir, options) {
901
+ if (spec.kind !== "git") return void 0;
902
+ const host = spec.host.toLowerCase().replace(/:.*$/, "");
903
+ if (host !== "github.com" && !host.endsWith(".github.com")) return void 0;
904
+ const repository = spec.spec.replace(/#.*$/s, "").replace(/^github:/i, "https://github.com/").replace(/^gist:/i, "https://gist.github.com/").replace(/^git\+/i, "");
905
+ const logRoot = join(dir, ".plugin-manager", "logs");
906
+ await mkdir(logRoot, {
907
+ recursive: true,
908
+ mode: 448
909
+ });
910
+ const logPath = join(await mkdtemp(join(logRoot, "github-connection-")), "git.log");
911
+ const log = await open(logPath, "ax+", 384);
912
+ try {
913
+ const result = await execa("git", [
914
+ "-c",
915
+ "credential.helper=",
916
+ "ls-remote",
917
+ "--",
918
+ repository,
919
+ "HEAD"
920
+ ], {
921
+ cwd: dir,
922
+ env: {
923
+ ...scrubbedParentEnv(),
924
+ ...options.env,
925
+ LC_ALL: "C",
926
+ GIT_TERMINAL_PROMPT: "0",
927
+ GIT_ASKPASS: "",
928
+ SSH_ASKPASS: "",
929
+ SSH_ASKPASS_REQUIRE: "never"
930
+ },
931
+ extendEnv: false,
932
+ stdin: "ignore",
933
+ stdout: "ignore",
934
+ stderr: {
935
+ file: logPath,
936
+ append: true
937
+ },
938
+ buffer: false,
939
+ reject: false,
940
+ timeout: options.timeoutMs,
941
+ cancelSignal: options.signal,
942
+ killDescendants: true,
943
+ killSignal: "SIGKILL"
944
+ });
945
+ if (!result.failed) return void 0;
946
+ if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`);
947
+ if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? "GitHub connection check failed");
948
+ const { size } = await log.stat();
949
+ const bytes = Buffer.alloc(Math.min(size, options.outputBytes));
950
+ await log.read(bytes, 0, bytes.length, size - bytes.length);
951
+ const output = bytes.toString("utf8");
952
+ return {
953
+ exitCode: result.exitCode ?? (result.code === "ENOENT" ? 127 : 1),
954
+ output,
955
+ truncated: size > options.outputBytes,
956
+ logPath,
957
+ kind: classifyInstallFailure({
958
+ log: output,
959
+ timedOut: result.timedOut
960
+ })
961
+ };
962
+ } finally {
963
+ await log.close();
964
+ }
965
+ }
966
+ //#endregion
540
967
  //#region lib/types/index.js
541
968
  /** Current-profile plugin and bundle management over shared dsh plugin operations. */
542
969
  var __runInitializers = function(thisArg, initializers, value) {
@@ -611,10 +1038,14 @@ function messageOf(error) {
611
1038
  }
612
1039
  /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
613
1040
  function managementError(error) {
614
- return error instanceof ManagementFailure ? { code: error.code } : {
1041
+ if (!(error instanceof ManagementFailure)) return {
615
1042
  code: "operation-error",
616
1043
  diagnostic: messageOf(error)
617
1044
  };
1045
+ return {
1046
+ code: error.code,
1047
+ ...error.incompatible === void 0 ? {} : { incompatible: error.incompatible }
1048
+ };
618
1049
  }
619
1050
  /** The caller stopped an installation; its files are restored before this is thrown. */
620
1051
  var InstallCancelledError = class extends Error {
@@ -684,6 +1115,8 @@ function parsedForRegistry(spec) {
684
1115
  let PluginManager = (() => {
685
1116
  let _classSuper = TypertRemoteService;
686
1117
  let _instanceExtraInitializers = [];
1118
+ let _listVersionExemptions_decorators;
1119
+ let _setVersionExemption_decorators;
687
1120
  let _listPlugins_decorators;
688
1121
  let _listBundles_decorators;
689
1122
  let _registries_decorators;
@@ -697,6 +1130,8 @@ let PluginManager = (() => {
697
1130
  return class PluginManager extends _classSuper {
698
1131
  static {
699
1132
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
1133
+ _listVersionExemptions_decorators = [Remote];
1134
+ _setVersionExemption_decorators = [Remote];
700
1135
  _listPlugins_decorators = [Remote];
701
1136
  _listBundles_decorators = [Remote];
702
1137
  _registries_decorators = [Remote];
@@ -707,6 +1142,28 @@ let PluginManager = (() => {
707
1142
  _waitForInstall_decorators = [Remote];
708
1143
  _cancelInstall_decorators = [Remote];
709
1144
  _removeBundle_decorators = [Remote];
1145
+ __esDecorate(this, null, _listVersionExemptions_decorators, {
1146
+ kind: "method",
1147
+ name: "listVersionExemptions",
1148
+ static: false,
1149
+ private: false,
1150
+ access: {
1151
+ has: (obj) => "listVersionExemptions" in obj,
1152
+ get: (obj) => obj.listVersionExemptions
1153
+ },
1154
+ metadata: _metadata
1155
+ }, null, _instanceExtraInitializers);
1156
+ __esDecorate(this, null, _setVersionExemption_decorators, {
1157
+ kind: "method",
1158
+ name: "setVersionExemption",
1159
+ static: false,
1160
+ private: false,
1161
+ access: {
1162
+ has: (obj) => "setVersionExemption" in obj,
1163
+ get: (obj) => obj.setVersionExemption
1164
+ },
1165
+ metadata: _metadata
1166
+ }, null, _instanceExtraInitializers);
710
1167
  __esDecorate(this, null, _listPlugins_decorators, {
711
1168
  kind: "method",
712
1169
  name: "listPlugins",
@@ -830,6 +1287,8 @@ let PluginManager = (() => {
830
1287
  outputBytes: z.number().step(1).min(1).default(16384),
831
1288
  lockWaitMs: z.number().step(1).min(0).default(12e4),
832
1289
  inspectTimeoutMs: z.number().step(1).min(1e3).default(2e4),
1290
+ githubConnectionTimeoutMs: z.number().step(1).min(1e3).default(5e3),
1291
+ idleTimeoutMs: z.number().step(1).min(1e3).default(6e5),
833
1292
  registry: z.string().pattern(REGISTRY_URL),
834
1293
  fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY])
835
1294
  });
@@ -841,6 +1300,8 @@ let PluginManager = (() => {
841
1300
  outputBytes;
842
1301
  lockWaitMs;
843
1302
  inspectTimeoutMs;
1303
+ githubConnectionTimeoutMs;
1304
+ idleTimeoutMs;
844
1305
  pnpmCommand;
845
1306
  configuredRegistries;
846
1307
  ownerContext;
@@ -856,6 +1317,8 @@ let PluginManager = (() => {
856
1317
  this.outputBytes = config.outputBytes;
857
1318
  this.lockWaitMs = config.lockWaitMs;
858
1319
  this.inspectTimeoutMs = config.inspectTimeoutMs;
1320
+ this.githubConnectionTimeoutMs = config.githubConnectionTimeoutMs;
1321
+ this.idleTimeoutMs = config.idleTimeoutMs;
859
1322
  this.pnpmCommand = config.pnpmCommand;
860
1323
  this.configuredRegistries = {
861
1324
  registry: config.registry === void 0 ? null : normalizeRegistry(config.registry),
@@ -866,6 +1329,34 @@ let PluginManager = (() => {
866
1329
  await Promise.allSettled([...this.packageOperations]);
867
1330
  }, "plugin-manager: package cancellation");
868
1331
  }
1332
+ /** Read exact plugin-version exemptions saved in this profile.
1333
+ * @returns Accepted package-name@version keys with the runtime versions they may run on, and any
1334
+ * record or file problem the reader rejected, which the caller reports instead of failing.
1335
+ */
1336
+ listVersionExemptions() {
1337
+ const { exemptions, warnings } = readProfileCompatibility(this.profile.dir);
1338
+ return {
1339
+ exemptions,
1340
+ warnings
1341
+ };
1342
+ }
1343
+ /** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
1344
+ * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
1345
+ * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
1346
+ * @param enabled Whether to grant rather than revoke the exemption.
1347
+ * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
1348
+ * @returns Saved and runtime outcomes. Startup-only profiles require restart.
1349
+ */
1350
+ setVersionExemption(packageVersion, runtimeVersion, enabled, acceptRisk) {
1351
+ return this.change((result) => this.configure(async () => {
1352
+ await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true);
1353
+ result.warnings = await this.reload();
1354
+ }), {
1355
+ stage: "enable",
1356
+ target: packageVersion,
1357
+ enabled
1358
+ }, "bundle");
1359
+ }
869
1360
  /** Read current plugins, including why a row cannot be changed through the profile patch.
870
1361
  * @returns Current runtime entries with persistent patch targets.
871
1362
  */
@@ -896,6 +1387,7 @@ let PluginManager = (() => {
896
1387
  */
897
1388
  listBundles() {
898
1389
  const manifest = readProfileManifest("dsh", this.profile.dir);
1390
+ const exemptions = readProfileVersionExemptions(this.profile.dir);
899
1391
  const selected = manifest.dsh?.profile?.bundles ?? [];
900
1392
  const dependencies = Object.keys(manifest.dependencies ?? {});
901
1393
  const installation = JSON.parse(readFileSync(this.profile.installAnchor, "utf8"));
@@ -927,6 +1419,8 @@ let PluginManager = (() => {
927
1419
  });
928
1420
  continue;
929
1421
  }
1422
+ const compatibility = evaluatePluginCompatibility(info, exemptions);
1423
+ if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
930
1424
  const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
931
1425
  const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, "package.json")).href);
932
1426
  bundles.push({
@@ -1116,7 +1610,9 @@ let PluginManager = (() => {
1116
1610
  }, "bundle");
1117
1611
  }
1118
1612
  /**
1119
- * Install a package using the same pnpm implementation as dsh plugin. A run
1613
+ * Install a package using the same pnpm implementation as dsh plugin. GitHub
1614
+ * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
1615
+ * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
1120
1616
  * that fails, is cancelled, or adds a package without a bundle patch restores
1121
1617
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
1122
1618
  * @param spec One package spec, including local paths relative to the invocation directory.
@@ -1131,7 +1627,7 @@ let PluginManager = (() => {
1131
1627
  phase: "installing",
1132
1628
  result: Promise.resolve(null)
1133
1629
  };
1134
- const stopped = () => control.abort.signal.aborted;
1630
+ const stopped = () => control.abort.signal.aborted || this.abort.signal.aborted;
1135
1631
  if (requestId !== void 0) this.installs.set(requestId, control);
1136
1632
  const announce = (phase, attempt) => {
1137
1633
  if (requestId !== void 0) this.ownerContext.emit("plugin-manager/install-state", {
@@ -1149,11 +1645,30 @@ let PluginManager = (() => {
1149
1645
  }
1150
1646
  const files = await this.readRestoredFiles();
1151
1647
  const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {};
1152
- const plan = registryPlan(options?.registry, await this.registries());
1153
1648
  let name;
1154
1649
  try {
1155
- let run;
1156
1650
  result.registries = [];
1651
+ const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
1652
+ timeoutMs: this.githubConnectionTimeoutMs,
1653
+ outputBytes: this.outputBytes,
1654
+ signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
1655
+ ...this.profile.packageManager?.env === void 0 ? {} : { env: this.profile.packageManager.env }
1656
+ });
1657
+ this.packageOperations.add(connection);
1658
+ let connectionFailure;
1659
+ try {
1660
+ connectionFailure = await connection;
1661
+ } finally {
1662
+ this.packageOperations.delete(connection);
1663
+ }
1664
+ if (stopped()) throw new InstallCancelledError();
1665
+ if (connectionFailure?.kind === "network" || connectionFailure?.kind === "timeout") {
1666
+ result.packageResult = connectionFailure;
1667
+ result.failedAt = "spec-host";
1668
+ throw new Error(connectionFailure.output);
1669
+ }
1670
+ const plan = registryPlan(options?.registry, await this.registries());
1671
+ let run;
1157
1672
  for (const [index, registry] of plan.entries()) {
1158
1673
  if (index > 0) await this.restoreFiles(files);
1159
1674
  if (stopped()) throw new InstallCancelledError();
@@ -1170,17 +1685,21 @@ let PluginManager = (() => {
1170
1685
  ], control.abort.signal, requestId);
1171
1686
  result.packageResult = run;
1172
1687
  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));
1688
+ if (run.incompatible !== void 0) throw new ManagementFailure("incompatible-version", run.incompatible);
1689
+ if (run.exitCode === 0 && run.timedOut !== true) break;
1690
+ /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */
1691
+ if (run.kind === void 0) break;
1176
1692
  delete result.failedAt;
1693
+ if (run.timedOut === true) break;
1694
+ const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
1177
1695
  if (failedAt !== "other") result.failedAt = failedAt;
1178
1696
  if (failedAt !== "registry" || index === plan.length - 1) break;
1179
1697
  }
1180
1698
  /* v8 ignore next -- the plan is never empty, so a run always settled */
1181
1699
  if (run === void 0) throw new Error("no registry was asked");
1182
- if (run.exitCode === 0) delete result.failedAt;
1183
- if (run.exitCode !== 0) {
1700
+ const succeeded = run.exitCode === 0 && run.timedOut !== true;
1701
+ if (succeeded) delete result.failedAt;
1702
+ if (!succeeded) {
1184
1703
  try {
1185
1704
  result.pendingBuilds = await readPendingBuilds(this.profile.dir);
1186
1705
  } catch (error) {
@@ -1197,6 +1716,8 @@ let PluginManager = (() => {
1197
1716
  const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
1198
1717
  const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
1199
1718
  if (manifest?.dsh?.bundle === void 0) throw new ManagementFailure("not-bundle");
1719
+ const compatibility = evaluatePluginCompatibility(manifest, readProfileVersionExemptions(this.profile.dir));
1720
+ if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
1200
1721
  for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches("dsh", file);
1201
1722
  } catch (error) {
1202
1723
  await this.restoreFiles(files);
@@ -1232,7 +1753,7 @@ let PluginManager = (() => {
1232
1753
  }
1233
1754
  /** Stop an installation this manager owns and wait until its files are back.
1234
1755
  * @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
1756
+ * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
1236
1757
  * applied, `not-running` for any other id.
1237
1758
  */
1238
1759
  async cancelInstall(requestId) {
@@ -1266,7 +1787,7 @@ let PluginManager = (() => {
1266
1787
  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
1788
  });
1268
1789
  result.packageResult = await this.runPnpm(["remove", name]);
1269
- if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output);
1790
+ if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) throw new Error(result.packageResult.output);
1270
1791
  }, {
1271
1792
  stage: "remove",
1272
1793
  target: name
@@ -1325,6 +1846,8 @@ let PluginManager = (() => {
1325
1846
  signal: signal === void 0 ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
1326
1847
  outputBytes: this.outputBytes,
1327
1848
  activateNewBundles: false,
1849
+ idleTimeoutMs: this.idleTimeoutMs,
1850
+ lookupTimeoutMs: this.inspectTimeoutMs,
1328
1851
  onOutput: (text, stream) => {
1329
1852
  this.ownerContext.emit("plugin-manager/install-log", {
1330
1853
  ...identity,
@@ -1348,9 +1871,13 @@ let PluginManager = (() => {
1348
1871
  text: "",
1349
1872
  exitCode: signal?.aborted === true ? null : result.exitCode
1350
1873
  });
1351
- return result.exitCode === 0 ? result : {
1874
+ if (result.exitCode === 0 && result.timedOut !== true) return result;
1875
+ return {
1352
1876
  ...result,
1353
- kind: classifyInstallFailure({ log: result.output })
1877
+ kind: classifyInstallFailure({
1878
+ log: result.output,
1879
+ ...result.timedOut === true ? { timedOut: true } : {}
1880
+ })
1354
1881
  };
1355
1882
  } catch (error) {
1356
1883
  this.ownerContext.emit("plugin-manager/install-log", {
@@ -1384,8 +1911,15 @@ let PluginManager = (() => {
1384
1911
  async selectBundle(name, enabled) {
1385
1912
  const manifest = readProfileManifest("dsh", this.profile.dir);
1386
1913
  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);
1914
+ if (enabled || !previous.includes(name)) {
1915
+ const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
1916
+ if (metadata === void 0) throw new ManagementFailure("not-bundle");
1917
+ if (enabled) {
1918
+ const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir));
1919
+ if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
1920
+ this.bundleRows(name);
1921
+ }
1922
+ }
1389
1923
  if (!enabled && previous.includes(name)) {
1390
1924
  if (this.protectsManager(name)) throw new ManagementFailure("management-required");
1391
1925
  }
@@ -1457,7 +1991,8 @@ let PluginManager = (() => {
1457
1991
  return [
1458
1992
  "package.json",
1459
1993
  "cordis.patch.yml",
1460
- "pnpm-workspace.yaml"
1994
+ "pnpm-workspace.yaml",
1995
+ PROFILE_COMPATIBILITY_FILENAME
1461
1996
  ].map((file) => {
1462
1997
  try {
1463
1998
  return readFileSync(join(this.profile.dir, file), "utf8");