@deepseek-ai/dsh-plugin-manager 0.1.6-alpha.2 → 0.1.7-alpha.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
@@ -2,11 +2,12 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { mkdir, mkdtemp, open, readFile, rm } from "node:fs/promises";
4
4
  import { isAbsolute, join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
5
6
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
6
7
  import z from "@deepseek-ai/schemastery";
7
8
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
8
9
  import { pluginEntryId, readPluginInventory } from "@deepseek-ai/dsh-host-plugin-inventory";
9
- import { OPTIONAL_BUNDLES, composeEntries, loadOptionalPatches, loadOverlayPatches, readProfileManifest, readProfilePatches, reconcileProfilePatches, resolveBundleDir, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
10
+ import { OPTIONAL_BUNDLES, bundlePatchPaths, composeEntries, loadOptionalPatches, loadOverlayPatches, readPluginMeta, readProfileManifest, readProfilePatches, reconcileProfilePatches, resolveBundleDir, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
10
11
  import { execa } from "execa";
11
12
  import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
12
13
  import { isAlias, isMap, isNode, isScalar, isSeq, parseDocument, visit } from "yaml";
@@ -55,7 +56,7 @@ async function reconcile(before, dir, anchor, options) {
55
56
  options.onOutput?.(`dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, "stderr");
56
57
  continue;
57
58
  }
58
- loadOverlayPatches("dsh", join(resolveBundleDir("dsh", name, anchor, dir), metadata.dsh.bundle.patch));
59
+ for (const file of bundlePatchPaths(resolveBundleDir("dsh", name, anchor, dir), metadata.dsh.bundle)) loadOverlayPatches("dsh", file);
59
60
  if (!bundles.includes(name)) bundles.push(name);
60
61
  }
61
62
  if (JSON.stringify(previous) === JSON.stringify(bundles)) return;
@@ -151,11 +152,49 @@ async function runProfilePnpm(context, args, options) {
151
152
  };
152
153
  }
153
154
  /**
155
+ * Read the registry pnpm's own configuration names in the profile: its `.npmrc` chain and workspace settings,
156
+ * as `pnpm config get registry` resolves them.
157
+ * @param dir Profile directory.
158
+ * @param options The pnpm executable and the time bound.
159
+ * @returns The registry URL as pnpm printed it, or null when pnpm did not answer with one.
160
+ */
161
+ async function readProfileRegistry(dir, options) {
162
+ const result = await execa(options.command ?? "pnpm", [
163
+ ...options.args ?? [],
164
+ "config",
165
+ "get",
166
+ "registry"
167
+ ], {
168
+ cwd: dir,
169
+ env: {
170
+ ...scrubbedParentEnv(),
171
+ ...options.env
172
+ },
173
+ extendEnv: false,
174
+ reject: false,
175
+ stdin: "ignore",
176
+ timeout: options.timeoutMs
177
+ });
178
+ const answer = result.exitCode === 0 ? result.stdout.trim().replace(/^[\s\S]*\n/, "").trim() : "";
179
+ return /^https?:\/\/\S+$/.test(answer) ? answer : null;
180
+ }
181
+ /**
182
+ * The argument that sends one pnpm command to a registry.
183
+ * @param registry - the registry, or null for the one pnpm's own configuration names.
184
+ * @returns `--registry=<url>` for a URL; nothing for null.
185
+ */
186
+ function registryArguments(registry) {
187
+ return registry === null ? [] : [`--registry=${registry}`];
188
+ }
189
+ /**
154
190
  * Ask the registry what a spec names through `pnpm view`, run in the profile
155
- * directory so the registry, proxy, and authentication settings of an install apply.
191
+ * directory so the registry, proxy, and authentication settings of an install
192
+ * apply. The lookup makes one request without pnpm's own retries: a registry
193
+ * that does not answer is reported within `timeoutMs`, and the registries
194
+ * configured after it are the retry.
156
195
  * @param dir Profile directory.
157
196
  * @param spec One registry spec: a package name with an optional range.
158
- * @param options Cancellation and the time bound.
197
+ * @param options The registry, cancellation, and the time bound.
159
198
  * @returns pnpm's exit, output, and how the lookup ended.
160
199
  */
161
200
  async function viewProfilePackage(dir, spec, options) {
@@ -167,7 +206,9 @@ async function viewProfilePackage(dir, spec, options) {
167
206
  "version",
168
207
  "description",
169
208
  "dsh",
170
- "--json"
209
+ "--json",
210
+ ...registryArguments(options.registry ?? null),
211
+ "--config.fetch-retries=0"
171
212
  ], {
172
213
  cwd: dir,
173
214
  env: {
@@ -205,7 +246,7 @@ const LOG_KINDS = [
205
246
  ["disk-full", /\bENOSPC\b|no space left on device/i],
206
247
  ["permission", /\bEACCES\b|\bEPERM\b|permission denied/i],
207
248
  ["integrity", /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
208
- ["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|socket hang up|Could not resolve host|unable to access/]
249
+ ["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/]
209
250
  ];
210
251
  /**
211
252
  * Classify a failed run.
@@ -229,6 +270,13 @@ function classifyInstallFailure(facts) {
229
270
  const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
230
271
  const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
231
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
+ };
232
280
  /** A tarball, on disk or over HTTP. */
233
281
  const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
234
282
  /** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
@@ -252,6 +300,14 @@ var InvalidInstallSpecError = class extends Error {
252
300
  function invalid(spec, reason) {
253
301
  return new InvalidInstallSpecError(spec, reason);
254
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
+ }
255
311
  /**
256
312
  * Read a spec into its form. A path must be absolute: the Host's working
257
313
  * directory means nothing to the person typing into a browser, and a
@@ -280,12 +336,14 @@ function parseInstallSpec(raw) {
280
336
  if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, "a local path must be absolute");
281
337
  if ((GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)) && !TARBALL_SPEC.test(spec)) return {
282
338
  kind: "git",
283
- spec
339
+ spec,
340
+ host: gitHost(spec)
284
341
  };
285
342
  if (/^https?:\/\//i.test(spec)) {
286
343
  if (TARBALL_SPEC.test(spec)) return {
287
344
  kind: "tarball",
288
- spec
345
+ spec,
346
+ host: new URL(spec).host
289
347
  };
290
348
  throw invalid(spec, "a URL must point at a git repository or a tarball");
291
349
  }
@@ -305,6 +363,82 @@ function parseInstallSpec(raw) {
305
363
  range
306
364
  };
307
365
  }
366
+ /** Public npmmirror URL shared by the fallback configuration and public-registry comparison. */
367
+ const NPMMIRROR_REGISTRY = "https://registry.npmmirror.com/";
368
+ /**
369
+ * Parse a registry URL into the form pnpm compares registries in: lower-case host, trailing slash.
370
+ * @param url - the registry as configured or requested.
371
+ * @returns the normalized URL.
372
+ * @throws {Error} for anything but an http(s) URL.
373
+ */
374
+ function normalizeRegistry(url) {
375
+ let parsed;
376
+ try {
377
+ parsed = new URL(url);
378
+ } catch {}
379
+ if (parsed === void 0 || parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`a registry must be an http(s) URL: ${url}`);
380
+ if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";
381
+ return parsed.href;
382
+ }
383
+ /**
384
+ * The registries one operation asks, first to last.
385
+ *
386
+ * The configured set is the configured first registry and the fallbacks; a
387
+ * requested registry is asked first when it is one of them, alone otherwise,
388
+ * so a private registry never falls through to a public one. pnpm's own
389
+ * registry (`null`) belongs to the set only when what it names is known to
390
+ * be public: npm's own registry or one of the configured fallbacks. While it
391
+ * names anything else, or is unknown, it is asked alone, and a public
392
+ * registry asked instead never falls back into it. A registry pnpm's own
393
+ * configuration already names is asked once.
394
+ * @param requested - the caller's registry; undefined defers to the configured first one.
395
+ * @param configured - the configured first registry, the fallbacks after it, and what pnpm's own configuration names.
396
+ * @returns the registries to ask, in order; never empty.
397
+ */
398
+ function registryPlan(requested, configured) {
399
+ const own = configured.resolved === null ? null : normalizeRegistry(configured.resolved);
400
+ const fallbacks = configured.fallbackRegistries.map(normalizeRegistry);
401
+ const ownIsPublic = own !== null && (own === "https://registry.npmjs.org/" || fallbacks.includes(own));
402
+ const keyOf = (registry) => registry === null ? own : normalizeRegistry(registry);
403
+ const known = [];
404
+ const keys = [];
405
+ for (const registry of [configured.registry, ...configured.fallbackRegistries]) {
406
+ if (registry === null && !ownIsPublic) continue;
407
+ const key = keyOf(registry);
408
+ if (keys.includes(key)) continue;
409
+ known.push(registry === null ? null : normalizeRegistry(registry));
410
+ keys.push(key);
411
+ }
412
+ const first = requested === void 0 ? configured.registry : requested;
413
+ const firstKey = keyOf(first);
414
+ const normalizedFirst = first === null ? null : normalizeRegistry(first);
415
+ if ((first === null || firstKey === own) && !ownIsPublic) return [normalizedFirst];
416
+ if (!keys.includes(firstKey)) return [normalizedFirst];
417
+ return [normalizedFirst, ...known.filter((_registry, index) => keys[index] !== firstKey)];
418
+ }
419
+ /** The failures after which another registry can answer differently: this one was unreachable, or its copy may be stale. */
420
+ const NEXT_REGISTRY_KINDS = new Set([
421
+ "network",
422
+ "timeout",
423
+ "not-found",
424
+ "no-matching-version"
425
+ ]);
426
+ /** A line of pnpm or git output that reports a failure, as opposed to a warning or progress line. */
427
+ const ERROR_LINE = /ERR_|ERROR|\berror\b|fatal:|Could not resolve|unable to access|ssh:|\bE[A-Z]{4,}\b/;
428
+ /**
429
+ * What a failed attempt could not reach or get an answer from.
430
+ * @param kind - how the attempt failed.
431
+ * @param log - what the attempt printed.
432
+ * @param spec - the spec the attempt installed.
433
+ * @returns `registry` for a failure another registry can change; `spec-host` when an error line names the host a git
434
+ * or tarball spec is fetched from, which no registry stands in for; `other` for a failure neither explains.
435
+ */
436
+ function attributeFailure(kind, log, spec) {
437
+ if (!NEXT_REGISTRY_KINDS.has(kind)) return "other";
438
+ const host = spec.kind === "git" || spec.kind === "tarball" ? spec.host?.toLowerCase() : void 0;
439
+ if (host === void 0) return "registry";
440
+ return log.split("\n").some((line) => ERROR_LINE.test(line) && line.toLowerCase().includes(host)) ? "spec-host" : "registry";
441
+ }
308
442
  //#endregion
309
443
  //#region lib/types/patch.js
310
444
  /** Comment-preserving profile plugin enablement edits. */
@@ -443,6 +577,8 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
443
577
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
444
578
  done = true;
445
579
  };
580
+ /** An http(s) URL, as pnpm's `--registry` takes it. */
581
+ const REGISTRY_URL = /^https?:\/\/\S+$/;
446
582
  const protectedModules = new Set([
447
583
  "@deepseek-ai/dsh-plugin-manager",
448
584
  "@deepseek-ai/cordis-plugin-loader",
@@ -493,7 +629,7 @@ function stringField(manifest, field) {
493
629
  return typeof value === "string" ? value : void 0;
494
630
  }
495
631
  /** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */
496
- function inspectionOf(kind, manifest) {
632
+ function inspectionOf(kind, manifest, registry) {
497
633
  const dsh = manifest.dsh;
498
634
  const declared = typeof dsh === "object" && dsh !== null ? dsh : void 0;
499
635
  const bundle = declared !== void 0 && typeof declared.bundle === "object" && declared.bundle !== null;
@@ -504,6 +640,7 @@ function inspectionOf(kind, manifest) {
504
640
  status: "accepted",
505
641
  kind,
506
642
  bundle,
643
+ registry,
507
644
  ...name === void 0 ? {} : { name },
508
645
  ...version === void 0 ? {} : { version },
509
646
  ...description === void 0 || description === "" ? {} : { description }
@@ -516,16 +653,45 @@ function refused(problem, reason) {
516
653
  reason
517
654
  };
518
655
  }
656
+ /** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */
657
+ function printedError(printed) {
658
+ let parsed;
659
+ try {
660
+ parsed = JSON.parse(printed || "null");
661
+ } catch {
662
+ return "";
663
+ }
664
+ const error = typeof parsed === "object" && parsed !== null ? parsed.error : void 0;
665
+ if (typeof error !== "object" || error === null) return "";
666
+ const { code, message } = error;
667
+ return [code, message].filter((part) => typeof part === "string").join(" ");
668
+ }
669
+ /** The spec's form, for deciding whether a failed attempt was the registry's; a form the parser refuses has no host of its own. */
670
+ function parsedForRegistry(spec) {
671
+ try {
672
+ return parseInstallSpec(spec);
673
+ } catch (error) {
674
+ /* v8 ignore next -- parseInstallSpec throws nothing but its own refusal */
675
+ if (!(error instanceof InvalidInstallSpecError)) throw error;
676
+ return {
677
+ kind: "registry",
678
+ spec,
679
+ name: spec
680
+ };
681
+ }
682
+ }
519
683
  /** Manage profile files and apply their declared reload lifecycle. */
520
684
  let PluginManager = (() => {
521
685
  let _classSuper = TypertRemoteService;
522
686
  let _instanceExtraInitializers = [];
523
687
  let _listPlugins_decorators;
524
688
  let _listBundles_decorators;
689
+ let _registries_decorators;
525
690
  let _inspect_decorators;
526
691
  let _setPluginEnabled_decorators;
527
692
  let _setBundleEnabled_decorators;
528
693
  let _installBundle_decorators;
694
+ let _waitForInstall_decorators;
529
695
  let _cancelInstall_decorators;
530
696
  let _removeBundle_decorators;
531
697
  return class PluginManager extends _classSuper {
@@ -533,10 +699,12 @@ let PluginManager = (() => {
533
699
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
534
700
  _listPlugins_decorators = [Remote];
535
701
  _listBundles_decorators = [Remote];
702
+ _registries_decorators = [Remote];
536
703
  _inspect_decorators = [Remote];
537
704
  _setPluginEnabled_decorators = [Remote];
538
705
  _setBundleEnabled_decorators = [Remote];
539
706
  _installBundle_decorators = [Remote];
707
+ _waitForInstall_decorators = [Remote];
540
708
  _cancelInstall_decorators = [Remote];
541
709
  _removeBundle_decorators = [Remote];
542
710
  __esDecorate(this, null, _listPlugins_decorators, {
@@ -561,6 +729,17 @@ let PluginManager = (() => {
561
729
  },
562
730
  metadata: _metadata
563
731
  }, null, _instanceExtraInitializers);
732
+ __esDecorate(this, null, _registries_decorators, {
733
+ kind: "method",
734
+ name: "registries",
735
+ static: false,
736
+ private: false,
737
+ access: {
738
+ has: (obj) => "registries" in obj,
739
+ get: (obj) => obj.registries
740
+ },
741
+ metadata: _metadata
742
+ }, null, _instanceExtraInitializers);
564
743
  __esDecorate(this, null, _inspect_decorators, {
565
744
  kind: "method",
566
745
  name: "inspect",
@@ -605,6 +784,17 @@ let PluginManager = (() => {
605
784
  },
606
785
  metadata: _metadata
607
786
  }, null, _instanceExtraInitializers);
787
+ __esDecorate(this, null, _waitForInstall_decorators, {
788
+ kind: "method",
789
+ name: "waitForInstall",
790
+ static: false,
791
+ private: false,
792
+ access: {
793
+ has: (obj) => "waitForInstall" in obj,
794
+ get: (obj) => obj.waitForInstall
795
+ },
796
+ metadata: _metadata
797
+ }, null, _instanceExtraInitializers);
608
798
  __esDecorate(this, null, _cancelInstall_decorators, {
609
799
  kind: "method",
610
800
  name: "cancelInstall",
@@ -639,15 +829,20 @@ let PluginManager = (() => {
639
829
  pnpmCommand: z.string().default("pnpm"),
640
830
  outputBytes: z.number().step(1).min(1).default(16384),
641
831
  lockWaitMs: z.number().step(1).min(0).default(12e4),
642
- inspectTimeoutMs: z.number().step(1).min(1e3).default(2e4)
832
+ inspectTimeoutMs: z.number().step(1).min(1e3).default(2e4),
833
+ registry: z.string().pattern(REGISTRY_URL),
834
+ fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY])
643
835
  });
644
- ownerEntryId = __runInitializers(this, _instanceExtraInitializers);
836
+ /** Management bundles remain protected if their files become unreadable. */
837
+ managementBundles = (__runInitializers(this, _instanceExtraInitializers), /* @__PURE__ */ new Set());
838
+ ownerEntryId;
645
839
  packageOperations = /* @__PURE__ */ new Set();
646
840
  profile;
647
841
  outputBytes;
648
842
  lockWaitMs;
649
843
  inspectTimeoutMs;
650
844
  pnpmCommand;
845
+ configuredRegistries;
651
846
  ownerContext;
652
847
  abort = new AbortController();
653
848
  /** Installations by request id, from their call until it settles. */
@@ -657,10 +852,15 @@ let PluginManager = (() => {
657
852
  this.ownerEntryId = ctx.fiber.entry?.id;
658
853
  this.ownerContext = ctx;
659
854
  this.profile = ctx.profileContext;
855
+ for (const name of this.profile.startedBundles) this.protectsManager(name);
660
856
  this.outputBytes = config.outputBytes;
661
857
  this.lockWaitMs = config.lockWaitMs;
662
858
  this.inspectTimeoutMs = config.inspectTimeoutMs;
663
859
  this.pnpmCommand = config.pnpmCommand;
860
+ this.configuredRegistries = {
861
+ registry: config.registry === void 0 ? null : normalizeRegistry(config.registry),
862
+ fallbackRegistries: config.fallbackRegistries.map(normalizeRegistry)
863
+ };
664
864
  ctx.effect(() => async () => {
665
865
  this.abort.abort();
666
866
  await Promise.allSettled([...this.packageOperations]);
@@ -691,8 +891,8 @@ let PluginManager = (() => {
691
891
  }
692
892
  /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
693
893
  * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
694
- * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
695
- * bundle, and removal availability.
894
+ * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
895
+ * whether the installation offers the bundle, and removal availability.
696
896
  */
697
897
  listBundles() {
698
898
  const manifest = readProfileManifest("dsh", this.profile.dir);
@@ -710,6 +910,7 @@ let PluginManager = (() => {
710
910
  const optional = OPTIONAL_BUNDLES.includes(name);
711
911
  const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name);
712
912
  const enabled = selected.includes(name);
913
+ const readOnlyReason = this.protectsManager(name) ? "management-required" : void 0;
713
914
  try {
714
915
  const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
715
916
  if (info === void 0) {
@@ -718,18 +919,21 @@ let PluginManager = (() => {
718
919
  enabled,
719
920
  installed,
720
921
  optional,
721
- removable,
922
+ removable: removable && readOnlyReason === void 0,
923
+ ...readOnlyReason === void 0 ? {} : { readOnlyReason },
722
924
  error: { code: "not-bundle" },
723
925
  rows: [],
724
926
  overrides: []
725
927
  });
726
928
  continue;
727
929
  }
728
- const readOnlyReason = this.protectsManager(name) ? "management-required" : void 0;
930
+ const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
931
+ const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, "package.json")).href);
729
932
  bundles.push({
730
933
  name,
731
934
  ...info.version === void 0 ? {} : { version: info.version },
732
935
  ...info.description === void 0 || info.description === "" ? {} : { description: info.description },
936
+ ...meta === void 0 ? {} : { meta },
733
937
  enabled,
734
938
  installed,
735
939
  optional,
@@ -743,7 +947,8 @@ let PluginManager = (() => {
743
947
  enabled,
744
948
  installed,
745
949
  optional,
746
- removable,
950
+ removable: removable && readOnlyReason === void 0,
951
+ ...readOnlyReason === void 0 ? {} : { readOnlyReason },
747
952
  error: managementError(error),
748
953
  rows: [],
749
954
  overrides: []
@@ -752,12 +957,26 @@ let PluginManager = (() => {
752
957
  }
753
958
  return Promise.resolve(bundles);
754
959
  }
960
+ /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
961
+ * @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now.
962
+ */
963
+ async registries() {
964
+ return {
965
+ ...this.configuredRegistries,
966
+ fallbackRegistries: [...this.configuredRegistries.fallbackRegistries],
967
+ resolved: await readProfileRegistry(this.profile.dir, {
968
+ ...this.profile.packageManager ?? { command: this.pnpmCommand },
969
+ timeoutMs: this.inspectTimeoutMs
970
+ })
971
+ };
972
+ }
755
973
  /** Read what a spec names before installing it.
756
974
  * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
975
+ * @param options The registry asked first.
757
976
  * @param signal Ends a registry lookup early.
758
977
  * @returns The package the spec names, or why it is refused.
759
978
  */
760
- async inspect(spec, signal) {
979
+ async inspect(spec, options, signal) {
761
980
  let parsed;
762
981
  try {
763
982
  parsed = parseInstallSpec(spec);
@@ -773,18 +992,24 @@ let PluginManager = (() => {
773
992
  ...Object.keys(manifest.dependencies ?? {}),
774
993
  ...Object.keys(installation.dependencies ?? {})
775
994
  ]);
995
+ const plan = registryPlan(options?.registry, await this.registries());
996
+ const registry = plan[0];
776
997
  switch (parsed.kind) {
777
998
  case "git": return {
778
999
  status: "accepted",
779
1000
  kind: "git",
780
- bundle: null
1001
+ bundle: null,
1002
+ registry,
1003
+ host: parsed.host
781
1004
  };
782
1005
  case "tarball":
783
1006
  if (parsed.path !== void 0 && !existsSync(parsed.path)) return refused("not-a-package", "the tarball does not exist");
784
1007
  return {
785
1008
  status: "accepted",
786
1009
  kind: "tarball",
787
- bundle: null
1010
+ bundle: null,
1011
+ registry,
1012
+ ...parsed.host === void 0 ? {} : { host: parsed.host }
788
1013
  };
789
1014
  case "path": {
790
1015
  if (!existsSync(parsed.path)) return refused("not-a-package", "the path does not exist");
@@ -794,7 +1019,7 @@ let PluginManager = (() => {
794
1019
  } catch (error) {
795
1020
  return refused("not-a-package", `no readable package.json at the path: ${messageOf(error)}`);
796
1021
  }
797
- const inspection = inspectionOf("path", read);
1022
+ const inspection = inspectionOf("path", read, registry);
798
1023
  if (inspection.name === void 0) return refused("not-a-package", "the package.json names no package");
799
1024
  if (known.has(inspection.name)) return refused("already-installed", `${inspection.name} is already installed`);
800
1025
  if (!inspection.bundle) return refused("not-a-bundle", `${inspection.name} declares no dsh.bundle`);
@@ -802,38 +1027,57 @@ let PluginManager = (() => {
802
1027
  }
803
1028
  case "registry": {
804
1029
  if (known.has(parsed.name)) return refused("already-installed", `${parsed.name} is already installed`);
805
- const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
806
- ...this.profile.packageManager ?? { command: this.pnpmCommand },
807
- timeoutMs: this.inspectTimeoutMs,
808
- ...signal === void 0 ? {} : { signal }
1030
+ const registries = [];
1031
+ const refusedBy = (problem, reason) => ({
1032
+ status: "refused",
1033
+ problem,
1034
+ reason,
1035
+ registries
809
1036
  });
810
- const log = `${view.stderr}${view.cause === void 0 ? "" : `${messageOf(view.cause)}\n`}`.trim();
811
- if (view.exitCode !== 0 || view.cause !== void 0 || view.timedOut) {
812
- const kind = classifyInstallFailure({
813
- log,
814
- timedOut: view.timedOut,
815
- ...view.cause === void 0 ? {} : { cause: view.cause }
1037
+ for (const current of plan) {
1038
+ registries.push(current);
1039
+ const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
1040
+ ...this.profile.packageManager ?? { command: this.pnpmCommand },
1041
+ timeoutMs: this.inspectTimeoutMs,
1042
+ ...signal === void 0 ? {} : { signal },
1043
+ registry: current
816
1044
  });
817
- const reason = log || view.stdout.trim() || `pnpm view exited with ${String(view.exitCode)}`;
818
- if (kind === "not-found" || kind === "no-matching-version") return refused("not-found", reason);
819
- if (kind === "network") return refused("network", reason);
820
- return refused("unknown", view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : reason);
821
- }
822
- let answer;
823
- try {
824
- answer = JSON.parse(view.stdout.replace(ANSI_SEQUENCE, "").trim() || "null");
825
- } catch (error) {
826
- return refused("unknown", `unreadable pnpm view output: ${messageOf(error)}`);
1045
+ const printed = view.stdout.replace(ANSI_SEQUENCE, "").trim();
1046
+ if (view.exitCode !== 0 || view.cause !== void 0 || view.timedOut) {
1047
+ const log = [
1048
+ view.stderr.trim(),
1049
+ printedError(printed),
1050
+ view.cause === void 0 ? "" : messageOf(view.cause)
1051
+ ].filter(Boolean).join("\n");
1052
+ const kind = classifyInstallFailure({
1053
+ log,
1054
+ timedOut: view.timedOut,
1055
+ ...view.cause === void 0 ? {} : { cause: view.cause }
1056
+ });
1057
+ if (registries.length < plan.length && signal?.aborted !== true && attributeFailure(kind, log, parsed) === "registry") continue;
1058
+ const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}`;
1059
+ if (kind === "not-found" || kind === "no-matching-version") return refusedBy("not-found", reason);
1060
+ if (kind === "network" || kind === "timeout") return refusedBy("network", reason);
1061
+ return refusedBy("unknown", reason);
1062
+ }
1063
+ let answer;
1064
+ try {
1065
+ answer = JSON.parse(printed || "null");
1066
+ } catch (error) {
1067
+ return refusedBy("unknown", `unreadable pnpm view output: ${messageOf(error)}`);
1068
+ }
1069
+ const latest = Array.isArray(answer) ? answer.at(-1) : answer;
1070
+ if (typeof latest !== "object" || latest === null) return refusedBy("unknown", "pnpm view answered no package");
1071
+ const inspection = inspectionOf("registry", latest, current);
1072
+ const named = inspection.name === void 0 ? {
1073
+ ...inspection,
1074
+ name: parsed.name
1075
+ } : inspection;
1076
+ if (!named.bundle) return refusedBy("not-a-bundle", `${named.name} declares no dsh.bundle`);
1077
+ return named;
827
1078
  }
828
- const latest = Array.isArray(answer) ? answer.at(-1) : answer;
829
- if (typeof latest !== "object" || latest === null) return refused("unknown", "pnpm view answered no package");
830
- const inspection = inspectionOf("registry", latest);
831
- const named = inspection.name === void 0 ? {
832
- ...inspection,
833
- name: parsed.name
834
- } : inspection;
835
- if (!named.bundle) return refused("not-a-bundle", `${named.name} declares no dsh.bundle`);
836
- return named;
1079
+ /* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */
1080
+ throw new Error("no registry was asked");
837
1081
  }
838
1082
  }
839
1083
  }
@@ -876,23 +1120,24 @@ let PluginManager = (() => {
876
1120
  * that fails, is cancelled, or adds a package without a bundle patch restores
877
1121
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
878
1122
  * @param spec One package spec, including local paths relative to the invocation directory.
879
- * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
880
- * the pending build scripts to allow for this profile before pnpm runs.
881
- * @returns Package-manager diagnostics and observed activation outcome.
1123
+ * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names,
1124
+ * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.
1125
+ * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome.
882
1126
  */
883
1127
  installBundle(spec, options) {
884
1128
  const requestId = options?.requestId;
885
1129
  const control = {
886
1130
  abort: new AbortController(),
887
1131
  phase: "installing",
888
- settled: Promise.resolve()
1132
+ result: Promise.resolve(null)
889
1133
  };
890
1134
  const stopped = () => control.abort.signal.aborted;
891
1135
  if (requestId !== void 0) this.installs.set(requestId, control);
892
- const announce = (phase) => {
1136
+ const announce = (phase, attempt) => {
893
1137
  if (requestId !== void 0) this.ownerContext.emit("plugin-manager/install-state", {
894
1138
  requestId,
895
- phase
1139
+ phase,
1140
+ ...attempt === void 0 ? {} : { attempt }
896
1141
  });
897
1142
  };
898
1143
  const result = this.change(async (result) => {
@@ -904,18 +1149,44 @@ let PluginManager = (() => {
904
1149
  }
905
1150
  const files = await this.readRestoredFiles();
906
1151
  const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {};
907
- announce("installing");
1152
+ const plan = registryPlan(options?.registry, await this.registries());
908
1153
  let name;
909
1154
  try {
910
- result.packageResult = await this.runPnpm(["add", spec], control.abort.signal, requestId);
911
- if (stopped()) throw new InstallCancelledError();
912
- if (result.packageResult.exitCode !== 0) {
1155
+ let run;
1156
+ result.registries = [];
1157
+ for (const [index, registry] of plan.entries()) {
1158
+ if (index > 0) await this.restoreFiles(files);
1159
+ if (stopped()) throw new InstallCancelledError();
1160
+ result.registries.push(registry);
1161
+ announce("installing", {
1162
+ registry,
1163
+ index: index + 1,
1164
+ total: plan.length
1165
+ });
1166
+ run = await this.runPnpm([
1167
+ "add",
1168
+ spec,
1169
+ ...registryArguments(registry)
1170
+ ], control.abort.signal, requestId);
1171
+ result.packageResult = run;
1172
+ 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));
1176
+ delete result.failedAt;
1177
+ if (failedAt !== "other") result.failedAt = failedAt;
1178
+ if (failedAt !== "registry" || index === plan.length - 1) break;
1179
+ }
1180
+ /* v8 ignore next -- the plan is never empty, so a run always settled */
1181
+ if (run === void 0) throw new Error("no registry was asked");
1182
+ if (run.exitCode === 0) delete result.failedAt;
1183
+ if (run.exitCode !== 0) {
913
1184
  try {
914
1185
  result.pendingBuilds = await readPendingBuilds(this.profile.dir);
915
1186
  } catch (error) {
916
1187
  this.ownerContext.logger.warn("Could not read pending build approvals after pnpm failed", error);
917
1188
  }
918
- throw new Error(result.packageResult.output);
1189
+ throw new Error(run.output);
919
1190
  }
920
1191
  const after = readProfileManifest("dsh", this.profile.dir).dependencies ?? {};
921
1192
  const installed = Object.keys(after).filter((name) => before[name] !== after[name]);
@@ -925,8 +1196,8 @@ let PluginManager = (() => {
925
1196
  name = target;
926
1197
  const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
927
1198
  const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
928
- if (manifest?.dsh?.bundle?.patch === void 0) throw new ManagementFailure("not-bundle");
929
- loadOverlayPatches("dsh", join(dir, manifest.dsh.bundle.patch));
1199
+ if (manifest?.dsh?.bundle === void 0) throw new ManagementFailure("not-bundle");
1200
+ for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches("dsh", file);
930
1201
  } catch (error) {
931
1202
  await this.restoreFiles(files);
932
1203
  throw error;
@@ -946,12 +1217,19 @@ let PluginManager = (() => {
946
1217
  target: spec,
947
1218
  enabled: options?.enabled !== false
948
1219
  }, "install");
949
- /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
950
- control.settled = result.then(() => void 0, () => void 0);
1220
+ control.result = result;
951
1221
  return result.finally(() => {
952
1222
  if (requestId !== void 0) this.installs.delete(requestId);
953
1223
  });
954
1224
  }
1225
+ /** Recover the result of an active installation without cancelling it.
1226
+ * @param requestId The id supplied when installation started.
1227
+ * @returns The installation's outcome after it settles, or null if no active request has that id.
1228
+ * Completed results are not retained; null establishes neither success nor cancellation.
1229
+ */
1230
+ async waitForInstall(requestId) {
1231
+ return this.installs.get(requestId)?.result ?? null;
1232
+ }
955
1233
  /** Stop an installation this manager owns and wait until its files are back.
956
1234
  * @param requestId The id the installation was started with.
957
1235
  * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
@@ -966,7 +1244,8 @@ let PluginManager = (() => {
966
1244
  phase: "cancelling"
967
1245
  });
968
1246
  control.abort.abort();
969
- await control.settled;
1247
+ /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
1248
+ await control.result.then(() => void 0, () => void 0);
970
1249
  return { status: "cancelled" };
971
1250
  }
972
1251
  /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
@@ -978,7 +1257,7 @@ let PluginManager = (() => {
978
1257
  await this.configure(async () => {
979
1258
  const bundle = (await this.listBundles()).find((item) => item.name === name);
980
1259
  if (bundle === void 0 || !bundle.removable) throw new ManagementFailure("not-removable");
981
- if (this.ownerContext.get("hmr") === void 0 && (this.profile.startedBundles.includes(name) || this.bundleRows(name).some((row) => [...this.ctx.loader.entries()].some((entry) => entry.options.id === row.id && entry.fiber !== void 0)))) throw new ManagementFailure("stop-profile");
1260
+ if (this.ownerContext.get("hmr") === void 0 && (this.profile.startedBundles.includes(name) || bundle.error === void 0 && this.bundleRows(name).some((row) => [...this.ctx.loader.entries()].some((entry) => entry.options.id === row.id && entry.fiber !== void 0)))) throw new ManagementFailure("stop-profile");
982
1261
  const contributions = bundle.error === void 0 ? this.bundleRows(name) : [];
983
1262
  if (bundle.enabled) {
984
1263
  await this.selectBundle(name, false);
@@ -995,25 +1274,34 @@ let PluginManager = (() => {
995
1274
  }
996
1275
  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
997
1276
  declaredRows(name, info) {
998
- const patch = info.dsh?.bundle?.patch;
1277
+ const bundle = info.dsh?.bundle;
999
1278
  /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
1000
- if (patch === void 0) return {
1279
+ if (bundle === void 0) return {
1001
1280
  rows: [],
1002
1281
  overrides: []
1003
1282
  };
1004
- const patches = loadOverlayPatches("dsh", join(resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir), patch));
1283
+ const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
1284
+ const patches = bundlePatchPaths(dir, bundle).flatMap((file) => loadOverlayPatches("dsh", file));
1005
1285
  const live = /* @__PURE__ */ new Map();
1006
1286
  for (const entry of this.ctx.loader.entries())
1007
1287
  /* v8 ignore next -- the Loader gives every entry an id before it is listed */
1008
- if (typeof entry.options.id === "string") live.set(entry.options.id, pluginEntryId(entry.id));
1288
+ if (typeof entry.options.id === "string") live.set(entry.options.id, {
1289
+ entryId: pluginEntryId(entry.id),
1290
+ baseUrl: entry.parent.tree.ctx.baseUrl
1291
+ });
1009
1292
  const rows = [];
1293
+ const packages = this.ctx.get("pluginPackages");
1010
1294
  for (const row of flatten(composeEntries([patches.filter((item) => item.insert !== void 0)]))) {
1011
1295
  if (typeof row.id !== "string" || typeof row.name !== "string") continue;
1012
- const entryId = live.get(row.id);
1296
+ const active = live.get(row.id);
1297
+ const entryId = active?.entryId;
1298
+ const base = active?.baseUrl ?? pathToFileURL(join(dir, "package.json")).href;
1299
+ const meta = packages?.metaOf(row.name, base);
1013
1300
  rows.push({
1014
1301
  rowId: row.id,
1015
1302
  moduleName: row.name,
1016
- ...entryId === void 0 ? {} : { entryId }
1303
+ ...entryId === void 0 ? {} : { entryId },
1304
+ ...meta === void 0 ? {} : { meta }
1017
1305
  });
1018
1306
  }
1019
1307
  const declared = new Set(rows.map((row) => row.rowId));
@@ -1097,6 +1385,7 @@ let PluginManager = (() => {
1097
1385
  const manifest = readProfileManifest("dsh", this.profile.dir);
1098
1386
  const previous = manifest.dsh?.profile?.bundles ?? [];
1099
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);
1100
1389
  if (!enabled && previous.includes(name)) {
1101
1390
  if (this.protectsManager(name)) throw new ManagementFailure("management-required");
1102
1391
  }
@@ -1110,14 +1399,24 @@ let PluginManager = (() => {
1110
1399
  }
1111
1400
  };
1112
1401
  await saveManifest(this.profile.dir, manifest);
1402
+ if (enabled) this.protectsManager(name);
1113
1403
  }
1114
1404
  bundleRows(name) {
1115
1405
  const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
1116
1406
  if (info?.dsh?.bundle === void 0) return [];
1117
- return flatten(composeEntries([loadOverlayPatches("dsh", join(resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir), info.dsh.bundle.patch))]));
1407
+ return flatten(composeEntries([bundlePatchPaths(resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir), info.dsh.bundle).flatMap((file) => loadOverlayPatches("dsh", file))]));
1118
1408
  }
1119
1409
  protectsManager(name) {
1120
- return this.bundleRows(name).some((row) => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId);
1410
+ if (this.managementBundles.has(name)) return true;
1411
+ let rows;
1412
+ try {
1413
+ rows = this.bundleRows(name);
1414
+ } catch (_error) {
1415
+ return false;
1416
+ }
1417
+ const protectedBundle = rows.some((row) => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId);
1418
+ if (protectedBundle) this.managementBundles.add(name);
1419
+ return protectedBundle;
1121
1420
  }
1122
1421
  configure(operation) {
1123
1422
  const hmr = this.ownerContext.get("hmr");