@deepseek-ai/dsh-plugin-manager 0.1.6-alpha.2 → 0.1.7-alpha.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
@@ -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,80 @@ function parseInstallSpec(raw) {
305
363
  range
306
364
  };
307
365
  }
366
+ /**
367
+ * Parse a registry URL into the form pnpm compares registries in: lower-case host, trailing slash.
368
+ * @param url - the registry as configured or requested.
369
+ * @returns the normalized URL.
370
+ * @throws {Error} for anything but an http(s) URL.
371
+ */
372
+ function normalizeRegistry(url) {
373
+ let parsed;
374
+ try {
375
+ parsed = new URL(url);
376
+ } catch {}
377
+ if (parsed === void 0 || parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`a registry must be an http(s) URL: ${url}`);
378
+ if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";
379
+ return parsed.href;
380
+ }
381
+ /**
382
+ * The registries one operation asks, first to last.
383
+ *
384
+ * The configured set is the configured first registry and the fallbacks; a
385
+ * requested registry is asked first when it is one of them, alone otherwise,
386
+ * so a private registry never falls through to a public one. pnpm's own
387
+ * registry (`null`) belongs to the set only when what it names is known to
388
+ * be public: npm's own registry or one of the configured fallbacks. While it
389
+ * names anything else, or is unknown, it is asked alone, and a public
390
+ * registry asked instead never falls back into it. A registry pnpm's own
391
+ * configuration already names is asked once.
392
+ * @param requested - the caller's registry; undefined defers to the configured first one.
393
+ * @param configured - the configured first registry, the fallbacks after it, and what pnpm's own configuration names.
394
+ * @returns the registries to ask, in order; never empty.
395
+ */
396
+ function registryPlan(requested, configured) {
397
+ const own = configured.resolved === null ? null : normalizeRegistry(configured.resolved);
398
+ const fallbacks = configured.fallbackRegistries.map(normalizeRegistry);
399
+ const ownIsPublic = own !== null && (own === "https://registry.npmjs.org/" || fallbacks.includes(own));
400
+ const keyOf = (registry) => registry === null ? own : normalizeRegistry(registry);
401
+ const known = [];
402
+ const keys = [];
403
+ for (const registry of [configured.registry, ...configured.fallbackRegistries]) {
404
+ if (registry === null && !ownIsPublic) continue;
405
+ const key = keyOf(registry);
406
+ if (keys.includes(key)) continue;
407
+ known.push(registry === null ? null : normalizeRegistry(registry));
408
+ keys.push(key);
409
+ }
410
+ const first = requested === void 0 ? configured.registry : requested;
411
+ const firstKey = keyOf(first);
412
+ const normalizedFirst = first === null ? null : normalizeRegistry(first);
413
+ if ((first === null || firstKey === own) && !ownIsPublic) return [normalizedFirst];
414
+ if (!keys.includes(firstKey)) return [normalizedFirst];
415
+ return [normalizedFirst, ...known.filter((_registry, index) => keys[index] !== firstKey)];
416
+ }
417
+ /** The failures after which another registry can answer differently: this one was unreachable, or its copy may be stale. */
418
+ const NEXT_REGISTRY_KINDS = new Set([
419
+ "network",
420
+ "timeout",
421
+ "not-found",
422
+ "no-matching-version"
423
+ ]);
424
+ /** A line of pnpm or git output that reports a failure, as opposed to a warning or progress line. */
425
+ const ERROR_LINE = /ERR_|ERROR|\berror\b|fatal:|Could not resolve|unable to access|ssh:|\bE[A-Z]{4,}\b/;
426
+ /**
427
+ * What a failed attempt could not reach or get an answer from.
428
+ * @param kind - how the attempt failed.
429
+ * @param log - what the attempt printed.
430
+ * @param spec - the spec the attempt installed.
431
+ * @returns `registry` for a failure another registry can change; `spec-host` when an error line names the host a git
432
+ * or tarball spec is fetched from, which no registry stands in for; `other` for a failure neither explains.
433
+ */
434
+ function attributeFailure(kind, log, spec) {
435
+ if (!NEXT_REGISTRY_KINDS.has(kind)) return "other";
436
+ const host = spec.kind === "git" || spec.kind === "tarball" ? spec.host?.toLowerCase() : void 0;
437
+ if (host === void 0) return "registry";
438
+ return log.split("\n").some((line) => ERROR_LINE.test(line) && line.toLowerCase().includes(host)) ? "spec-host" : "registry";
439
+ }
308
440
  //#endregion
309
441
  //#region lib/types/patch.js
310
442
  /** Comment-preserving profile plugin enablement edits. */
@@ -443,6 +575,8 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
443
575
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
444
576
  done = true;
445
577
  };
578
+ /** An http(s) URL, as pnpm's `--registry` takes it. */
579
+ const REGISTRY_URL = /^https?:\/\/\S+$/;
446
580
  const protectedModules = new Set([
447
581
  "@deepseek-ai/dsh-plugin-manager",
448
582
  "@deepseek-ai/cordis-plugin-loader",
@@ -493,7 +627,7 @@ function stringField(manifest, field) {
493
627
  return typeof value === "string" ? value : void 0;
494
628
  }
495
629
  /** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */
496
- function inspectionOf(kind, manifest) {
630
+ function inspectionOf(kind, manifest, registry) {
497
631
  const dsh = manifest.dsh;
498
632
  const declared = typeof dsh === "object" && dsh !== null ? dsh : void 0;
499
633
  const bundle = declared !== void 0 && typeof declared.bundle === "object" && declared.bundle !== null;
@@ -504,6 +638,7 @@ function inspectionOf(kind, manifest) {
504
638
  status: "accepted",
505
639
  kind,
506
640
  bundle,
641
+ registry,
507
642
  ...name === void 0 ? {} : { name },
508
643
  ...version === void 0 ? {} : { version },
509
644
  ...description === void 0 || description === "" ? {} : { description }
@@ -516,16 +651,45 @@ function refused(problem, reason) {
516
651
  reason
517
652
  };
518
653
  }
654
+ /** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */
655
+ function printedError(printed) {
656
+ let parsed;
657
+ try {
658
+ parsed = JSON.parse(printed || "null");
659
+ } catch {
660
+ return "";
661
+ }
662
+ const error = typeof parsed === "object" && parsed !== null ? parsed.error : void 0;
663
+ if (typeof error !== "object" || error === null) return "";
664
+ const { code, message } = error;
665
+ return [code, message].filter((part) => typeof part === "string").join(" ");
666
+ }
667
+ /** 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. */
668
+ function parsedForRegistry(spec) {
669
+ try {
670
+ return parseInstallSpec(spec);
671
+ } catch (error) {
672
+ /* v8 ignore next -- parseInstallSpec throws nothing but its own refusal */
673
+ if (!(error instanceof InvalidInstallSpecError)) throw error;
674
+ return {
675
+ kind: "registry",
676
+ spec,
677
+ name: spec
678
+ };
679
+ }
680
+ }
519
681
  /** Manage profile files and apply their declared reload lifecycle. */
520
682
  let PluginManager = (() => {
521
683
  let _classSuper = TypertRemoteService;
522
684
  let _instanceExtraInitializers = [];
523
685
  let _listPlugins_decorators;
524
686
  let _listBundles_decorators;
687
+ let _registries_decorators;
525
688
  let _inspect_decorators;
526
689
  let _setPluginEnabled_decorators;
527
690
  let _setBundleEnabled_decorators;
528
691
  let _installBundle_decorators;
692
+ let _waitForInstall_decorators;
529
693
  let _cancelInstall_decorators;
530
694
  let _removeBundle_decorators;
531
695
  return class PluginManager extends _classSuper {
@@ -533,10 +697,12 @@ let PluginManager = (() => {
533
697
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
534
698
  _listPlugins_decorators = [Remote];
535
699
  _listBundles_decorators = [Remote];
700
+ _registries_decorators = [Remote];
536
701
  _inspect_decorators = [Remote];
537
702
  _setPluginEnabled_decorators = [Remote];
538
703
  _setBundleEnabled_decorators = [Remote];
539
704
  _installBundle_decorators = [Remote];
705
+ _waitForInstall_decorators = [Remote];
540
706
  _cancelInstall_decorators = [Remote];
541
707
  _removeBundle_decorators = [Remote];
542
708
  __esDecorate(this, null, _listPlugins_decorators, {
@@ -561,6 +727,17 @@ let PluginManager = (() => {
561
727
  },
562
728
  metadata: _metadata
563
729
  }, null, _instanceExtraInitializers);
730
+ __esDecorate(this, null, _registries_decorators, {
731
+ kind: "method",
732
+ name: "registries",
733
+ static: false,
734
+ private: false,
735
+ access: {
736
+ has: (obj) => "registries" in obj,
737
+ get: (obj) => obj.registries
738
+ },
739
+ metadata: _metadata
740
+ }, null, _instanceExtraInitializers);
564
741
  __esDecorate(this, null, _inspect_decorators, {
565
742
  kind: "method",
566
743
  name: "inspect",
@@ -605,6 +782,17 @@ let PluginManager = (() => {
605
782
  },
606
783
  metadata: _metadata
607
784
  }, null, _instanceExtraInitializers);
785
+ __esDecorate(this, null, _waitForInstall_decorators, {
786
+ kind: "method",
787
+ name: "waitForInstall",
788
+ static: false,
789
+ private: false,
790
+ access: {
791
+ has: (obj) => "waitForInstall" in obj,
792
+ get: (obj) => obj.waitForInstall
793
+ },
794
+ metadata: _metadata
795
+ }, null, _instanceExtraInitializers);
608
796
  __esDecorate(this, null, _cancelInstall_decorators, {
609
797
  kind: "method",
610
798
  name: "cancelInstall",
@@ -639,15 +827,20 @@ let PluginManager = (() => {
639
827
  pnpmCommand: z.string().default("pnpm"),
640
828
  outputBytes: z.number().step(1).min(1).default(16384),
641
829
  lockWaitMs: z.number().step(1).min(0).default(12e4),
642
- inspectTimeoutMs: z.number().step(1).min(1e3).default(2e4)
830
+ inspectTimeoutMs: z.number().step(1).min(1e3).default(2e4),
831
+ registry: z.string().pattern(REGISTRY_URL),
832
+ fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default(["https://registry.npmmirror.com/"])
643
833
  });
644
- ownerEntryId = __runInitializers(this, _instanceExtraInitializers);
834
+ /** Management bundles remain protected if their files become unreadable. */
835
+ managementBundles = (__runInitializers(this, _instanceExtraInitializers), /* @__PURE__ */ new Set());
836
+ ownerEntryId;
645
837
  packageOperations = /* @__PURE__ */ new Set();
646
838
  profile;
647
839
  outputBytes;
648
840
  lockWaitMs;
649
841
  inspectTimeoutMs;
650
842
  pnpmCommand;
843
+ configuredRegistries;
651
844
  ownerContext;
652
845
  abort = new AbortController();
653
846
  /** Installations by request id, from their call until it settles. */
@@ -657,10 +850,15 @@ let PluginManager = (() => {
657
850
  this.ownerEntryId = ctx.fiber.entry?.id;
658
851
  this.ownerContext = ctx;
659
852
  this.profile = ctx.profileContext;
853
+ for (const name of this.profile.startedBundles) this.protectsManager(name);
660
854
  this.outputBytes = config.outputBytes;
661
855
  this.lockWaitMs = config.lockWaitMs;
662
856
  this.inspectTimeoutMs = config.inspectTimeoutMs;
663
857
  this.pnpmCommand = config.pnpmCommand;
858
+ this.configuredRegistries = {
859
+ registry: config.registry === void 0 ? null : normalizeRegistry(config.registry),
860
+ fallbackRegistries: config.fallbackRegistries.map(normalizeRegistry)
861
+ };
664
862
  ctx.effect(() => async () => {
665
863
  this.abort.abort();
666
864
  await Promise.allSettled([...this.packageOperations]);
@@ -691,8 +889,8 @@ let PluginManager = (() => {
691
889
  }
692
890
  /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
693
891
  * 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.
892
+ * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
893
+ * whether the installation offers the bundle, and removal availability.
696
894
  */
697
895
  listBundles() {
698
896
  const manifest = readProfileManifest("dsh", this.profile.dir);
@@ -710,6 +908,7 @@ let PluginManager = (() => {
710
908
  const optional = OPTIONAL_BUNDLES.includes(name);
711
909
  const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name);
712
910
  const enabled = selected.includes(name);
911
+ const readOnlyReason = this.protectsManager(name) ? "management-required" : void 0;
713
912
  try {
714
913
  const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
715
914
  if (info === void 0) {
@@ -718,18 +917,21 @@ let PluginManager = (() => {
718
917
  enabled,
719
918
  installed,
720
919
  optional,
721
- removable,
920
+ removable: removable && readOnlyReason === void 0,
921
+ ...readOnlyReason === void 0 ? {} : { readOnlyReason },
722
922
  error: { code: "not-bundle" },
723
923
  rows: [],
724
924
  overrides: []
725
925
  });
726
926
  continue;
727
927
  }
728
- const readOnlyReason = this.protectsManager(name) ? "management-required" : void 0;
928
+ const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
929
+ const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, "package.json")).href);
729
930
  bundles.push({
730
931
  name,
731
932
  ...info.version === void 0 ? {} : { version: info.version },
732
933
  ...info.description === void 0 || info.description === "" ? {} : { description: info.description },
934
+ ...meta === void 0 ? {} : { meta },
733
935
  enabled,
734
936
  installed,
735
937
  optional,
@@ -743,7 +945,8 @@ let PluginManager = (() => {
743
945
  enabled,
744
946
  installed,
745
947
  optional,
746
- removable,
948
+ removable: removable && readOnlyReason === void 0,
949
+ ...readOnlyReason === void 0 ? {} : { readOnlyReason },
747
950
  error: managementError(error),
748
951
  rows: [],
749
952
  overrides: []
@@ -752,12 +955,26 @@ let PluginManager = (() => {
752
955
  }
753
956
  return Promise.resolve(bundles);
754
957
  }
958
+ /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
959
+ * @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now.
960
+ */
961
+ async registries() {
962
+ return {
963
+ ...this.configuredRegistries,
964
+ fallbackRegistries: [...this.configuredRegistries.fallbackRegistries],
965
+ resolved: await readProfileRegistry(this.profile.dir, {
966
+ ...this.profile.packageManager ?? { command: this.pnpmCommand },
967
+ timeoutMs: this.inspectTimeoutMs
968
+ })
969
+ };
970
+ }
755
971
  /** Read what a spec names before installing it.
756
972
  * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
973
+ * @param options The registry asked first.
757
974
  * @param signal Ends a registry lookup early.
758
975
  * @returns The package the spec names, or why it is refused.
759
976
  */
760
- async inspect(spec, signal) {
977
+ async inspect(spec, options, signal) {
761
978
  let parsed;
762
979
  try {
763
980
  parsed = parseInstallSpec(spec);
@@ -773,18 +990,24 @@ let PluginManager = (() => {
773
990
  ...Object.keys(manifest.dependencies ?? {}),
774
991
  ...Object.keys(installation.dependencies ?? {})
775
992
  ]);
993
+ const plan = registryPlan(options?.registry, await this.registries());
994
+ const registry = plan[0];
776
995
  switch (parsed.kind) {
777
996
  case "git": return {
778
997
  status: "accepted",
779
998
  kind: "git",
780
- bundle: null
999
+ bundle: null,
1000
+ registry,
1001
+ host: parsed.host
781
1002
  };
782
1003
  case "tarball":
783
1004
  if (parsed.path !== void 0 && !existsSync(parsed.path)) return refused("not-a-package", "the tarball does not exist");
784
1005
  return {
785
1006
  status: "accepted",
786
1007
  kind: "tarball",
787
- bundle: null
1008
+ bundle: null,
1009
+ registry,
1010
+ ...parsed.host === void 0 ? {} : { host: parsed.host }
788
1011
  };
789
1012
  case "path": {
790
1013
  if (!existsSync(parsed.path)) return refused("not-a-package", "the path does not exist");
@@ -794,7 +1017,7 @@ let PluginManager = (() => {
794
1017
  } catch (error) {
795
1018
  return refused("not-a-package", `no readable package.json at the path: ${messageOf(error)}`);
796
1019
  }
797
- const inspection = inspectionOf("path", read);
1020
+ const inspection = inspectionOf("path", read, registry);
798
1021
  if (inspection.name === void 0) return refused("not-a-package", "the package.json names no package");
799
1022
  if (known.has(inspection.name)) return refused("already-installed", `${inspection.name} is already installed`);
800
1023
  if (!inspection.bundle) return refused("not-a-bundle", `${inspection.name} declares no dsh.bundle`);
@@ -802,38 +1025,57 @@ let PluginManager = (() => {
802
1025
  }
803
1026
  case "registry": {
804
1027
  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 }
1028
+ const registries = [];
1029
+ const refusedBy = (problem, reason) => ({
1030
+ status: "refused",
1031
+ problem,
1032
+ reason,
1033
+ registries
809
1034
  });
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 }
1035
+ for (const current of plan) {
1036
+ registries.push(current);
1037
+ const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
1038
+ ...this.profile.packageManager ?? { command: this.pnpmCommand },
1039
+ timeoutMs: this.inspectTimeoutMs,
1040
+ ...signal === void 0 ? {} : { signal },
1041
+ registry: current
816
1042
  });
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)}`);
1043
+ const printed = view.stdout.replace(ANSI_SEQUENCE, "").trim();
1044
+ if (view.exitCode !== 0 || view.cause !== void 0 || view.timedOut) {
1045
+ const log = [
1046
+ view.stderr.trim(),
1047
+ printedError(printed),
1048
+ view.cause === void 0 ? "" : messageOf(view.cause)
1049
+ ].filter(Boolean).join("\n");
1050
+ const kind = classifyInstallFailure({
1051
+ log,
1052
+ timedOut: view.timedOut,
1053
+ ...view.cause === void 0 ? {} : { cause: view.cause }
1054
+ });
1055
+ if (registries.length < plan.length && signal?.aborted !== true && attributeFailure(kind, log, parsed) === "registry") continue;
1056
+ const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}`;
1057
+ if (kind === "not-found" || kind === "no-matching-version") return refusedBy("not-found", reason);
1058
+ if (kind === "network" || kind === "timeout") return refusedBy("network", reason);
1059
+ return refusedBy("unknown", reason);
1060
+ }
1061
+ let answer;
1062
+ try {
1063
+ answer = JSON.parse(printed || "null");
1064
+ } catch (error) {
1065
+ return refusedBy("unknown", `unreadable pnpm view output: ${messageOf(error)}`);
1066
+ }
1067
+ const latest = Array.isArray(answer) ? answer.at(-1) : answer;
1068
+ if (typeof latest !== "object" || latest === null) return refusedBy("unknown", "pnpm view answered no package");
1069
+ const inspection = inspectionOf("registry", latest, current);
1070
+ const named = inspection.name === void 0 ? {
1071
+ ...inspection,
1072
+ name: parsed.name
1073
+ } : inspection;
1074
+ if (!named.bundle) return refusedBy("not-a-bundle", `${named.name} declares no dsh.bundle`);
1075
+ return named;
827
1076
  }
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;
1077
+ /* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */
1078
+ throw new Error("no registry was asked");
837
1079
  }
838
1080
  }
839
1081
  }
@@ -876,23 +1118,24 @@ let PluginManager = (() => {
876
1118
  * that fails, is cancelled, or adds a package without a bundle patch restores
877
1119
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
878
1120
  * @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.
1121
+ * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names,
1122
+ * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.
1123
+ * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome.
882
1124
  */
883
1125
  installBundle(spec, options) {
884
1126
  const requestId = options?.requestId;
885
1127
  const control = {
886
1128
  abort: new AbortController(),
887
1129
  phase: "installing",
888
- settled: Promise.resolve()
1130
+ result: Promise.resolve(null)
889
1131
  };
890
1132
  const stopped = () => control.abort.signal.aborted;
891
1133
  if (requestId !== void 0) this.installs.set(requestId, control);
892
- const announce = (phase) => {
1134
+ const announce = (phase, attempt) => {
893
1135
  if (requestId !== void 0) this.ownerContext.emit("plugin-manager/install-state", {
894
1136
  requestId,
895
- phase
1137
+ phase,
1138
+ ...attempt === void 0 ? {} : { attempt }
896
1139
  });
897
1140
  };
898
1141
  const result = this.change(async (result) => {
@@ -904,18 +1147,44 @@ let PluginManager = (() => {
904
1147
  }
905
1148
  const files = await this.readRestoredFiles();
906
1149
  const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {};
907
- announce("installing");
1150
+ const plan = registryPlan(options?.registry, await this.registries());
908
1151
  let name;
909
1152
  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) {
1153
+ let run;
1154
+ result.registries = [];
1155
+ for (const [index, registry] of plan.entries()) {
1156
+ if (index > 0) await this.restoreFiles(files);
1157
+ if (stopped()) throw new InstallCancelledError();
1158
+ result.registries.push(registry);
1159
+ announce("installing", {
1160
+ registry,
1161
+ index: index + 1,
1162
+ total: plan.length
1163
+ });
1164
+ run = await this.runPnpm([
1165
+ "add",
1166
+ spec,
1167
+ ...registryArguments(registry)
1168
+ ], control.abort.signal, requestId);
1169
+ result.packageResult = run;
1170
+ if (stopped()) throw new InstallCancelledError();
1171
+ /* v8 ignore next 2 -- runPnpm classifies every failed run, so kind is never absent here */
1172
+ if (run.exitCode === 0 || run.kind === void 0) break;
1173
+ const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
1174
+ delete result.failedAt;
1175
+ if (failedAt !== "other") result.failedAt = failedAt;
1176
+ if (failedAt !== "registry" || index === plan.length - 1) break;
1177
+ }
1178
+ /* v8 ignore next -- the plan is never empty, so a run always settled */
1179
+ if (run === void 0) throw new Error("no registry was asked");
1180
+ if (run.exitCode === 0) delete result.failedAt;
1181
+ if (run.exitCode !== 0) {
913
1182
  try {
914
1183
  result.pendingBuilds = await readPendingBuilds(this.profile.dir);
915
1184
  } catch (error) {
916
1185
  this.ownerContext.logger.warn("Could not read pending build approvals after pnpm failed", error);
917
1186
  }
918
- throw new Error(result.packageResult.output);
1187
+ throw new Error(run.output);
919
1188
  }
920
1189
  const after = readProfileManifest("dsh", this.profile.dir).dependencies ?? {};
921
1190
  const installed = Object.keys(after).filter((name) => before[name] !== after[name]);
@@ -925,8 +1194,8 @@ let PluginManager = (() => {
925
1194
  name = target;
926
1195
  const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
927
1196
  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));
1197
+ if (manifest?.dsh?.bundle === void 0) throw new ManagementFailure("not-bundle");
1198
+ for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches("dsh", file);
930
1199
  } catch (error) {
931
1200
  await this.restoreFiles(files);
932
1201
  throw error;
@@ -946,12 +1215,19 @@ let PluginManager = (() => {
946
1215
  target: spec,
947
1216
  enabled: options?.enabled !== false
948
1217
  }, "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);
1218
+ control.result = result;
951
1219
  return result.finally(() => {
952
1220
  if (requestId !== void 0) this.installs.delete(requestId);
953
1221
  });
954
1222
  }
1223
+ /** Recover the result of an active installation without cancelling it.
1224
+ * @param requestId The id supplied when installation started.
1225
+ * @returns The installation's outcome after it settles, or null if no active request has that id.
1226
+ * Completed results are not retained; null establishes neither success nor cancellation.
1227
+ */
1228
+ async waitForInstall(requestId) {
1229
+ return this.installs.get(requestId)?.result ?? null;
1230
+ }
955
1231
  /** Stop an installation this manager owns and wait until its files are back.
956
1232
  * @param requestId The id the installation was started with.
957
1233
  * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
@@ -966,7 +1242,8 @@ let PluginManager = (() => {
966
1242
  phase: "cancelling"
967
1243
  });
968
1244
  control.abort.abort();
969
- await control.settled;
1245
+ /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
1246
+ await control.result.then(() => void 0, () => void 0);
970
1247
  return { status: "cancelled" };
971
1248
  }
972
1249
  /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
@@ -978,7 +1255,7 @@ let PluginManager = (() => {
978
1255
  await this.configure(async () => {
979
1256
  const bundle = (await this.listBundles()).find((item) => item.name === name);
980
1257
  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");
1258
+ 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
1259
  const contributions = bundle.error === void 0 ? this.bundleRows(name) : [];
983
1260
  if (bundle.enabled) {
984
1261
  await this.selectBundle(name, false);
@@ -995,25 +1272,34 @@ let PluginManager = (() => {
995
1272
  }
996
1273
  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
997
1274
  declaredRows(name, info) {
998
- const patch = info.dsh?.bundle?.patch;
1275
+ const bundle = info.dsh?.bundle;
999
1276
  /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
1000
- if (patch === void 0) return {
1277
+ if (bundle === void 0) return {
1001
1278
  rows: [],
1002
1279
  overrides: []
1003
1280
  };
1004
- const patches = loadOverlayPatches("dsh", join(resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir), patch));
1281
+ const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
1282
+ const patches = bundlePatchPaths(dir, bundle).flatMap((file) => loadOverlayPatches("dsh", file));
1005
1283
  const live = /* @__PURE__ */ new Map();
1006
1284
  for (const entry of this.ctx.loader.entries())
1007
1285
  /* 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));
1286
+ if (typeof entry.options.id === "string") live.set(entry.options.id, {
1287
+ entryId: pluginEntryId(entry.id),
1288
+ baseUrl: entry.parent.tree.ctx.baseUrl
1289
+ });
1009
1290
  const rows = [];
1291
+ const packages = this.ctx.get("pluginPackages");
1010
1292
  for (const row of flatten(composeEntries([patches.filter((item) => item.insert !== void 0)]))) {
1011
1293
  if (typeof row.id !== "string" || typeof row.name !== "string") continue;
1012
- const entryId = live.get(row.id);
1294
+ const active = live.get(row.id);
1295
+ const entryId = active?.entryId;
1296
+ const base = active?.baseUrl ?? pathToFileURL(join(dir, "package.json")).href;
1297
+ const meta = packages?.metaOf(row.name, base);
1013
1298
  rows.push({
1014
1299
  rowId: row.id,
1015
1300
  moduleName: row.name,
1016
- ...entryId === void 0 ? {} : { entryId }
1301
+ ...entryId === void 0 ? {} : { entryId },
1302
+ ...meta === void 0 ? {} : { meta }
1017
1303
  });
1018
1304
  }
1019
1305
  const declared = new Set(rows.map((row) => row.rowId));
@@ -1097,6 +1383,7 @@ let PluginManager = (() => {
1097
1383
  const manifest = readProfileManifest("dsh", this.profile.dir);
1098
1384
  const previous = manifest.dsh?.profile?.bundles ?? [];
1099
1385
  if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === void 0) throw new ManagementFailure("not-bundle");
1386
+ if (enabled) this.bundleRows(name);
1100
1387
  if (!enabled && previous.includes(name)) {
1101
1388
  if (this.protectsManager(name)) throw new ManagementFailure("management-required");
1102
1389
  }
@@ -1110,14 +1397,24 @@ let PluginManager = (() => {
1110
1397
  }
1111
1398
  };
1112
1399
  await saveManifest(this.profile.dir, manifest);
1400
+ if (enabled) this.protectsManager(name);
1113
1401
  }
1114
1402
  bundleRows(name) {
1115
1403
  const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
1116
1404
  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))]));
1405
+ return flatten(composeEntries([bundlePatchPaths(resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir), info.dsh.bundle).flatMap((file) => loadOverlayPatches("dsh", file))]));
1118
1406
  }
1119
1407
  protectsManager(name) {
1120
- return this.bundleRows(name).some((row) => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId);
1408
+ if (this.managementBundles.has(name)) return true;
1409
+ let rows;
1410
+ try {
1411
+ rows = this.bundleRows(name);
1412
+ } catch (_error) {
1413
+ return false;
1414
+ }
1415
+ const protectedBundle = rows.some((row) => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId);
1416
+ if (protectedBundle) this.managementBundles.add(name);
1417
+ return protectedBundle;
1121
1418
  }
1122
1419
  configure(operation) {
1123
1420
  const hmr = this.ownerContext.get("hmr");