@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.
@@ -37,19 +37,23 @@ import { randomUUID } from 'node:crypto';
37
37
  import { existsSync, readFileSync } from 'node:fs';
38
38
  import { readFile, rm } from 'node:fs/promises';
39
39
  import { join } from 'node:path';
40
+ import { pathToFileURL } from 'node:url';
40
41
  import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
41
42
  import z from '@deepseek-ai/schemastery';
42
43
  import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol';
43
44
  import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory';
44
- import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, } from '@deepseek-ai/dsh-app-boot';
45
- import { bundleManifest, runProfilePnpm, saveManifest, viewProfilePackage } from "./operations.js";
45
+ import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, } from '@deepseek-ai/dsh-app-boot';
46
+ import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from "./operations.js";
46
47
  import { classifyInstallFailure } from "./install-failure.js";
47
48
  import { InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
49
+ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from "./registry.js";
48
50
  import { writePluginEnabled } from "./patch.js";
49
51
  import { ManagementFailure } from "./failure.js";
50
52
  import { approveBuilds, readPendingBuilds } from "./build-approval.js";
51
53
  export { classifyInstallFailure } from "./install-failure.js";
52
54
  export { InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
55
+ /** An http(s) URL, as pnpm's `--registry` takes it. */
56
+ const REGISTRY_URL = /^https?:\/\/\S+$/;
53
57
  const protectedModules = new Set([
54
58
  '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader',
55
59
  '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway',
@@ -88,7 +92,7 @@ function stringField(manifest, field) {
88
92
  return typeof value === 'string' ? value : undefined;
89
93
  }
90
94
  /** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */
91
- function inspectionOf(kind, manifest) {
95
+ function inspectionOf(kind, manifest, registry) {
92
96
  const dsh = manifest.dsh;
93
97
  const declared = typeof dsh === 'object' && dsh !== null ? dsh : undefined;
94
98
  const bundle = declared !== undefined && typeof declared.bundle === 'object' && declared.bundle !== null;
@@ -96,7 +100,7 @@ function inspectionOf(kind, manifest) {
96
100
  const version = stringField(manifest, 'version');
97
101
  const description = stringField(manifest, 'description');
98
102
  return {
99
- status: 'accepted', kind, bundle,
103
+ status: 'accepted', kind, bundle, registry,
100
104
  ...name === undefined ? {} : { name },
101
105
  ...version === undefined ? {} : { version },
102
106
  ...description === undefined || description === '' ? {} : { description },
@@ -105,16 +109,45 @@ function inspectionOf(kind, manifest) {
105
109
  function refused(problem, reason) {
106
110
  return { status: 'refused', problem, reason };
107
111
  }
112
+ /** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */
113
+ function printedError(printed) {
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(printed || 'null');
117
+ }
118
+ catch {
119
+ return ''; /* not JSON: nothing pnpm printed as a refusal */
120
+ }
121
+ const error = typeof parsed === 'object' && parsed !== null ? parsed.error : undefined;
122
+ if (typeof error !== 'object' || error === null)
123
+ return '';
124
+ const { code, message } = error;
125
+ return [code, message].filter((part) => typeof part === 'string').join(' ');
126
+ }
127
+ /** 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. */
128
+ function parsedForRegistry(spec) {
129
+ try {
130
+ return parseInstallSpec(spec);
131
+ }
132
+ catch (error) {
133
+ /* v8 ignore next -- parseInstallSpec throws nothing but its own refusal */
134
+ if (!(error instanceof InvalidInstallSpecError))
135
+ throw error;
136
+ return { kind: 'registry', spec, name: spec };
137
+ }
138
+ }
108
139
  /** Manage profile files and apply their declared reload lifecycle. */
109
140
  let PluginManager = (() => {
110
141
  let _classSuper = TypertRemoteService;
111
142
  let _instanceExtraInitializers = [];
112
143
  let _listPlugins_decorators;
113
144
  let _listBundles_decorators;
145
+ let _registries_decorators;
114
146
  let _inspect_decorators;
115
147
  let _setPluginEnabled_decorators;
116
148
  let _setBundleEnabled_decorators;
117
149
  let _installBundle_decorators;
150
+ let _waitForInstall_decorators;
118
151
  let _cancelInstall_decorators;
119
152
  let _removeBundle_decorators;
120
153
  return class PluginManager extends _classSuper {
@@ -122,18 +155,22 @@ let PluginManager = (() => {
122
155
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
123
156
  _listPlugins_decorators = [Remote];
124
157
  _listBundles_decorators = [Remote];
158
+ _registries_decorators = [Remote];
125
159
  _inspect_decorators = [Remote];
126
160
  _setPluginEnabled_decorators = [Remote];
127
161
  _setBundleEnabled_decorators = [Remote];
128
162
  _installBundle_decorators = [Remote];
163
+ _waitForInstall_decorators = [Remote];
129
164
  _cancelInstall_decorators = [Remote];
130
165
  _removeBundle_decorators = [Remote];
131
166
  __esDecorate(this, null, _listPlugins_decorators, { kind: "method", name: "listPlugins", static: false, private: false, access: { has: obj => "listPlugins" in obj, get: obj => obj.listPlugins }, metadata: _metadata }, null, _instanceExtraInitializers);
132
167
  __esDecorate(this, null, _listBundles_decorators, { kind: "method", name: "listBundles", static: false, private: false, access: { has: obj => "listBundles" in obj, get: obj => obj.listBundles }, metadata: _metadata }, null, _instanceExtraInitializers);
168
+ __esDecorate(this, null, _registries_decorators, { kind: "method", name: "registries", static: false, private: false, access: { has: obj => "registries" in obj, get: obj => obj.registries }, metadata: _metadata }, null, _instanceExtraInitializers);
133
169
  __esDecorate(this, null, _inspect_decorators, { kind: "method", name: "inspect", static: false, private: false, access: { has: obj => "inspect" in obj, get: obj => obj.inspect }, metadata: _metadata }, null, _instanceExtraInitializers);
134
170
  __esDecorate(this, null, _setPluginEnabled_decorators, { kind: "method", name: "setPluginEnabled", static: false, private: false, access: { has: obj => "setPluginEnabled" in obj, get: obj => obj.setPluginEnabled }, metadata: _metadata }, null, _instanceExtraInitializers);
135
171
  __esDecorate(this, null, _setBundleEnabled_decorators, { kind: "method", name: "setBundleEnabled", static: false, private: false, access: { has: obj => "setBundleEnabled" in obj, get: obj => obj.setBundleEnabled }, metadata: _metadata }, null, _instanceExtraInitializers);
136
172
  __esDecorate(this, null, _installBundle_decorators, { kind: "method", name: "installBundle", static: false, private: false, access: { has: obj => "installBundle" in obj, get: obj => obj.installBundle }, metadata: _metadata }, null, _instanceExtraInitializers);
173
+ __esDecorate(this, null, _waitForInstall_decorators, { kind: "method", name: "waitForInstall", static: false, private: false, access: { has: obj => "waitForInstall" in obj, get: obj => obj.waitForInstall }, metadata: _metadata }, null, _instanceExtraInitializers);
137
174
  __esDecorate(this, null, _cancelInstall_decorators, { kind: "method", name: "cancelInstall", static: false, private: false, access: { has: obj => "cancelInstall" in obj, get: obj => obj.cancelInstall }, metadata: _metadata }, null, _instanceExtraInitializers);
138
175
  __esDecorate(this, null, _removeBundle_decorators, { kind: "method", name: "removeBundle", static: false, private: false, access: { has: obj => "removeBundle" in obj, get: obj => obj.removeBundle }, metadata: _metadata }, null, _instanceExtraInitializers);
139
176
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
@@ -144,14 +181,19 @@ let PluginManager = (() => {
144
181
  outputBytes: z.number().step(1).min(1).default(16384),
145
182
  lockWaitMs: z.number().step(1).min(0).default(120000),
146
183
  inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
184
+ registry: z.string().pattern(REGISTRY_URL),
185
+ fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
147
186
  });
148
- ownerEntryId = __runInitializers(this, _instanceExtraInitializers);
187
+ /** Management bundles remain protected if their files become unreadable. */
188
+ managementBundles = (__runInitializers(this, _instanceExtraInitializers), new Set());
189
+ ownerEntryId;
149
190
  packageOperations = new Set();
150
191
  profile;
151
192
  outputBytes;
152
193
  lockWaitMs;
153
194
  inspectTimeoutMs;
154
195
  pnpmCommand;
196
+ configuredRegistries;
155
197
  ownerContext;
156
198
  abort = new AbortController();
157
199
  /** Installations by request id, from their call until it settles. */
@@ -161,10 +203,16 @@ let PluginManager = (() => {
161
203
  this.ownerEntryId = ctx.fiber.entry?.id;
162
204
  this.ownerContext = ctx;
163
205
  this.profile = ctx.profileContext;
206
+ for (const name of this.profile.startedBundles)
207
+ this.protectsManager(name);
164
208
  this.outputBytes = config.outputBytes;
165
209
  this.lockWaitMs = config.lockWaitMs;
166
210
  this.inspectTimeoutMs = config.inspectTimeoutMs;
167
211
  this.pnpmCommand = config.pnpmCommand;
212
+ this.configuredRegistries = {
213
+ registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
214
+ fallbackRegistries: config.fallbackRegistries.map(normalizeRegistry),
215
+ };
168
216
  ctx.effect(() => async () => {
169
217
  this.abort.abort();
170
218
  await Promise.allSettled([...this.packageOperations]);
@@ -192,8 +240,8 @@ let PluginManager = (() => {
192
240
  }
193
241
  /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
194
242
  * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
195
- * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
196
- * bundle, and removal availability.
243
+ * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
244
+ * whether the installation offers the bundle, and removal availability.
197
245
  */
198
246
  listBundles() {
199
247
  const manifest = readProfileManifest('dsh', this.profile.dir);
@@ -207,34 +255,51 @@ let PluginManager = (() => {
207
255
  const optional = OPTIONAL_BUNDLES.includes(name);
208
256
  const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name);
209
257
  const enabled = selected.includes(name);
258
+ const readOnlyReason = this.protectsManager(name) ? 'management-required' : undefined;
210
259
  try {
211
260
  const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
212
261
  if (info === undefined) {
213
262
  if (enabled)
214
- bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] });
263
+ bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
264
+ ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] });
215
265
  continue;
216
266
  }
217
- const readOnlyReason = this.protectsManager(name) ? 'management-required' : undefined;
267
+ const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
268
+ const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href);
218
269
  bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
219
270
  ...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
271
+ ...meta === undefined ? {} : { meta },
220
272
  enabled, installed, optional, removable: removable && readOnlyReason === undefined,
221
273
  ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
222
274
  ...this.declaredRows(name, info) });
223
275
  }
224
276
  catch (error) {
225
277
  if (enabled || installed) {
226
- bundles.push({ name, enabled, installed, optional, removable, error: managementError(error), rows: [], overrides: [] });
278
+ bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
279
+ ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] });
227
280
  }
228
281
  }
229
282
  }
230
283
  return Promise.resolve(bundles);
231
284
  }
285
+ /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
286
+ * @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now.
287
+ */
288
+ async registries() {
289
+ return {
290
+ ...this.configuredRegistries, fallbackRegistries: [...this.configuredRegistries.fallbackRegistries],
291
+ resolved: await readProfileRegistry(this.profile.dir, {
292
+ ...this.profile.packageManager ?? { command: this.pnpmCommand }, timeoutMs: this.inspectTimeoutMs,
293
+ }),
294
+ };
295
+ }
232
296
  /** Read what a spec names before installing it.
233
297
  * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
298
+ * @param options The registry asked first.
234
299
  * @param signal Ends a registry lookup early.
235
300
  * @returns The package the spec names, or why it is refused.
236
301
  */
237
- async inspect(spec, signal) {
302
+ async inspect(spec, options, signal) {
238
303
  let parsed;
239
304
  try {
240
305
  parsed = parseInstallSpec(spec);
@@ -250,12 +315,14 @@ let PluginManager = (() => {
250
315
  const known = new Set([
251
316
  ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
252
317
  ]);
318
+ const plan = registryPlan(options?.registry, await this.registries());
319
+ const registry = plan[0];
253
320
  switch (parsed.kind) {
254
- case 'git': return { status: 'accepted', kind: 'git', bundle: null };
321
+ case 'git': return { status: 'accepted', kind: 'git', bundle: null, registry, host: parsed.host };
255
322
  case 'tarball':
256
323
  if (parsed.path !== undefined && !existsSync(parsed.path))
257
324
  return refused('not-a-package', 'the tarball does not exist');
258
- return { status: 'accepted', kind: 'tarball', bundle: null };
325
+ return { status: 'accepted', kind: 'tarball', bundle: null, registry, ...parsed.host === undefined ? {} : { host: parsed.host } };
259
326
  case 'path': {
260
327
  if (!existsSync(parsed.path))
261
328
  return refused('not-a-package', 'the path does not exist');
@@ -266,7 +333,7 @@ let PluginManager = (() => {
266
333
  catch (error) {
267
334
  return refused('not-a-package', `no readable package.json at the path: ${messageOf(error)}`);
268
335
  }
269
- const inspection = inspectionOf('path', read);
336
+ const inspection = inspectionOf('path', read, registry);
270
337
  if (inspection.name === undefined)
271
338
  return refused('not-a-package', 'the package.json names no package');
272
339
  if (known.has(inspection.name))
@@ -278,36 +345,48 @@ let PluginManager = (() => {
278
345
  case 'registry': {
279
346
  if (known.has(parsed.name))
280
347
  return refused('already-installed', `${parsed.name} is already installed`);
281
- const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
282
- ...this.profile.packageManager ?? { command: this.pnpmCommand },
283
- timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal },
284
- });
285
- const log = `${view.stderr}${view.cause === undefined ? '' : `${messageOf(view.cause)}\n`}`.trim();
286
- if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
287
- const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } });
288
- const reason = log || view.stdout.trim() || `pnpm view exited with ${String(view.exitCode)}`;
289
- if (kind === 'not-found' || kind === 'no-matching-version')
290
- return refused('not-found', reason);
291
- if (kind === 'network')
292
- return refused('network', reason);
293
- return refused('unknown', view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : reason);
294
- }
295
- let answer;
296
- try {
297
- answer = JSON.parse(view.stdout.replace(ANSI_SEQUENCE, '').trim() || 'null');
298
- }
299
- catch (error) {
300
- return refused('unknown', `unreadable pnpm view output: ${messageOf(error)}`);
348
+ const registries = [];
349
+ const refusedBy = (problem, reason) => ({ status: 'refused', problem, reason, registries });
350
+ for (const current of plan) {
351
+ registries.push(current);
352
+ const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
353
+ ...this.profile.packageManager ?? { command: this.pnpmCommand },
354
+ timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal }, registry: current,
355
+ });
356
+ const printed = view.stdout.replace(ANSI_SEQUENCE, '').trim();
357
+ if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
358
+ // pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr.
359
+ const log = [view.stderr.trim(), printedError(printed), view.cause === undefined ? '' : messageOf(view.cause)].filter(Boolean).join('\n');
360
+ const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } });
361
+ // A lookup the caller dropped is not carried to the next registry.
362
+ if (registries.length < plan.length && signal?.aborted !== true && attributeFailure(kind, log, parsed) === 'registry')
363
+ continue;
364
+ const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}`;
365
+ if (kind === 'not-found' || kind === 'no-matching-version')
366
+ return refusedBy('not-found', reason);
367
+ if (kind === 'network' || kind === 'timeout')
368
+ return refusedBy('network', reason);
369
+ return refusedBy('unknown', reason);
370
+ }
371
+ let answer;
372
+ try {
373
+ answer = JSON.parse(printed || 'null');
374
+ }
375
+ catch (error) {
376
+ return refusedBy('unknown', `unreadable pnpm view output: ${messageOf(error)}`);
377
+ }
378
+ // A range answers one object per matching version, oldest first.
379
+ const latest = Array.isArray(answer) ? answer.at(-1) : answer;
380
+ if (typeof latest !== 'object' || latest === null)
381
+ return refusedBy('unknown', 'pnpm view answered no package');
382
+ const inspection = inspectionOf('registry', latest, current);
383
+ const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection;
384
+ if (!named.bundle)
385
+ return refusedBy('not-a-bundle', `${named.name} declares no dsh.bundle`);
386
+ return named;
301
387
  }
302
- // A range answers one object per matching version, oldest first.
303
- const latest = Array.isArray(answer) ? answer.at(-1) : answer;
304
- if (typeof latest !== 'object' || latest === null)
305
- return refused('unknown', 'pnpm view answered no package');
306
- const inspection = inspectionOf('registry', latest);
307
- const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection;
308
- if (!named.bundle)
309
- return refused('not-a-bundle', `${named.name} declares no dsh.bundle`);
310
- return named;
388
+ /* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */
389
+ throw new Error('no registry was asked');
311
390
  }
312
391
  }
313
392
  }
@@ -345,19 +424,19 @@ let PluginManager = (() => {
345
424
  * that fails, is cancelled, or adds a package without a bundle patch restores
346
425
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
347
426
  * @param spec One package spec, including local paths relative to the invocation directory.
348
- * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
349
- * the pending build scripts to allow for this profile before pnpm runs.
350
- * @returns Package-manager diagnostics and observed activation outcome.
427
+ * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names,
428
+ * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.
429
+ * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome.
351
430
  */
352
431
  installBundle(spec, options) {
353
432
  const requestId = options?.requestId;
354
- const control = { abort: new AbortController(), phase: 'installing', settled: Promise.resolve() };
433
+ const control = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) };
355
434
  const stopped = () => control.abort.signal.aborted;
356
435
  if (requestId !== undefined)
357
436
  this.installs.set(requestId, control);
358
- const announce = (phase) => {
437
+ const announce = (phase, attempt) => {
359
438
  if (requestId !== undefined)
360
- this.ownerContext.emit('plugin-manager/install-state', { requestId, phase });
439
+ this.ownerContext.emit('plugin-manager/install-state', { requestId, phase, ...attempt === undefined ? {} : { attempt } });
361
440
  };
362
441
  const result = this.change(async (result) => {
363
442
  if (spec.trim() === '' || spec.startsWith('-'))
@@ -370,13 +449,41 @@ let PluginManager = (() => {
370
449
  }
371
450
  const files = await this.readRestoredFiles();
372
451
  const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
373
- announce('installing');
452
+ const plan = registryPlan(options?.registry, await this.registries());
374
453
  let name;
375
454
  try {
376
- result.packageResult = await this.runPnpm(['add', spec], control.abort.signal, requestId);
377
- if (stopped())
378
- throw new InstallCancelledError();
379
- if (result.packageResult.exitCode !== 0) {
455
+ // The last run is the result's; the registries asked stay listed whatever the outcome.
456
+ let run;
457
+ result.registries = [];
458
+ for (const [index, registry] of plan.entries()) {
459
+ if (index > 0)
460
+ await this.restoreFiles(files);
461
+ // A stop that landed while the files went back, or before the first run, starts no run with a dead signal.
462
+ if (stopped())
463
+ throw new InstallCancelledError();
464
+ result.registries.push(registry);
465
+ announce('installing', { registry, index: index + 1, total: plan.length });
466
+ run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId);
467
+ result.packageResult = run;
468
+ if (stopped())
469
+ throw new InstallCancelledError();
470
+ /* v8 ignore next 2 -- runPnpm classifies every failed run, so kind is never absent here */
471
+ if (run.exitCode === 0 || run.kind === undefined)
472
+ break;
473
+ const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
474
+ // What the last failed run could not reach; a later run that succeeds leaves nothing to say.
475
+ delete result.failedAt;
476
+ if (failedAt !== 'other')
477
+ result.failedAt = failedAt;
478
+ if (failedAt !== 'registry' || index === plan.length - 1)
479
+ break;
480
+ }
481
+ /* v8 ignore next -- the plan is never empty, so a run always settled */
482
+ if (run === undefined)
483
+ throw new Error('no registry was asked');
484
+ if (run.exitCode === 0)
485
+ delete result.failedAt;
486
+ if (run.exitCode !== 0) {
380
487
  // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
381
488
  try {
382
489
  result.pendingBuilds = await readPendingBuilds(this.profile.dir);
@@ -384,7 +491,7 @@ let PluginManager = (() => {
384
491
  catch (error) {
385
492
  this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error);
386
493
  }
387
- throw new Error(result.packageResult.output);
494
+ throw new Error(run.output);
388
495
  }
389
496
  const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
390
497
  const installed = Object.keys(after).filter(name => before[name] !== after[name]);
@@ -397,9 +504,10 @@ let PluginManager = (() => {
397
504
  name = target;
398
505
  const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
399
506
  const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
400
- if (manifest?.dsh?.bundle?.patch === undefined)
507
+ if (manifest?.dsh?.bundle === undefined)
401
508
  throw new ManagementFailure('not-bundle');
402
- loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch));
509
+ for (const file of bundlePatchPaths(dir, manifest.dsh.bundle))
510
+ loadOverlayPatches('dsh', file);
403
511
  }
404
512
  catch (error) {
405
513
  // pnpm has exited by now, so the files it rewrote go back as they were.
@@ -420,11 +528,18 @@ let PluginManager = (() => {
420
528
  result.warnings = await this.reload();
421
529
  });
422
530
  }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install');
423
- /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
424
- control.settled = result.then(() => undefined, () => undefined);
531
+ control.result = result;
425
532
  return result.finally(() => { if (requestId !== undefined)
426
533
  this.installs.delete(requestId); });
427
534
  }
535
+ /** Recover the result of an active installation without cancelling it.
536
+ * @param requestId The id supplied when installation started.
537
+ * @returns The installation's outcome after it settles, or null if no active request has that id.
538
+ * Completed results are not retained; null establishes neither success nor cancellation.
539
+ */
540
+ async waitForInstall(requestId) {
541
+ return this.installs.get(requestId)?.result ?? null;
542
+ }
428
543
  /** Stop an installation this manager owns and wait until its files are back.
429
544
  * @param requestId The id the installation was started with.
430
545
  * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
@@ -438,7 +553,8 @@ let PluginManager = (() => {
438
553
  return { status: 'too-late' };
439
554
  this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'cancelling' });
440
555
  control.abort.abort();
441
- await control.settled;
556
+ /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
557
+ await control.result.then(() => undefined, () => undefined);
442
558
  return { status: 'cancelled' };
443
559
  }
444
560
  /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
@@ -452,8 +568,8 @@ let PluginManager = (() => {
452
568
  if (bundle === undefined || !bundle.removable)
453
569
  throw new ManagementFailure('not-removable');
454
570
  if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
455
- || this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
456
- .some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
571
+ || (bundle.error === undefined && this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
572
+ .some(entry => entry.options.id === row.id && entry.fiber !== undefined))))) {
457
573
  throw new ManagementFailure('stop-profile');
458
574
  }
459
575
  const contributions = bundle.error === undefined ? this.bundleRows(name) : [];
@@ -473,25 +589,32 @@ let PluginManager = (() => {
473
589
  }
474
590
  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
475
591
  declaredRows(name, info) {
476
- const patch = info.dsh?.bundle?.patch;
592
+ const bundle = info.dsh?.bundle;
477
593
  /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
478
- if (patch === undefined)
594
+ if (bundle === undefined)
479
595
  return { rows: [], overrides: [] };
480
596
  const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
481
- const patches = loadOverlayPatches('dsh', join(dir, patch));
597
+ const patches = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file));
482
598
  // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
483
599
  const live = new Map();
484
600
  for (const entry of this.ctx.loader.entries()) {
485
601
  /* v8 ignore next -- the Loader gives every entry an id before it is listed */
486
602
  if (typeof entry.options.id === 'string')
487
- live.set(entry.options.id, pluginEntryId(entry.id));
603
+ live.set(entry.options.id, {
604
+ entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl,
605
+ });
488
606
  }
489
607
  const rows = [];
608
+ const packages = this.ctx.get('pluginPackages');
490
609
  for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
491
610
  if (typeof row.id !== 'string' || typeof row.name !== 'string')
492
611
  continue;
493
- const entryId = live.get(row.id);
494
- rows.push({ rowId: row.id, moduleName: row.name, ...entryId === undefined ? {} : { entryId } });
612
+ const active = live.get(row.id);
613
+ const entryId = active?.entryId;
614
+ const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href;
615
+ const meta = packages?.metaOf(row.name, base);
616
+ rows.push({ rowId: row.id, moduleName: row.name,
617
+ ...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } });
495
618
  }
496
619
  const declared = new Set(rows.map(row => row.rowId));
497
620
  const overrides = [...new Set(patches.flatMap(item => item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))];
@@ -551,6 +674,8 @@ let PluginManager = (() => {
551
674
  if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
552
675
  throw new ManagementFailure('not-bundle');
553
676
  }
677
+ if (enabled)
678
+ this.bundleRows(name);
554
679
  if (!enabled && previous.includes(name)) {
555
680
  if (this.protectsManager(name))
556
681
  throw new ManagementFailure('management-required');
@@ -560,16 +685,31 @@ let PluginManager = (() => {
560
685
  return;
561
686
  manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } };
562
687
  await saveManifest(this.profile.dir, manifest);
688
+ if (enabled)
689
+ this.protectsManager(name);
563
690
  }
564
691
  bundleRows(name) {
565
692
  const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
566
693
  if (info?.dsh?.bundle === undefined)
567
694
  return [];
568
695
  const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
569
- return flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))]));
696
+ return flatten(composeEntries([bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file))]));
570
697
  }
571
698
  protectsManager(name) {
572
- return this.bundleRows(name).some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId);
699
+ if (this.managementBundles.has(name))
700
+ return true;
701
+ let rows;
702
+ try {
703
+ rows = this.bundleRows(name);
704
+ }
705
+ catch (_error) {
706
+ // Unreadable bundles contribute no new rows; listBundles reports their diagnostics.
707
+ return false;
708
+ }
709
+ const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId);
710
+ if (protectedBundle)
711
+ this.managementBundles.add(name);
712
+ return protectedBundle;
573
713
  }
574
714
  configure(operation) {
575
715
  const hmr = this.ownerContext.get('hmr');
@@ -12,7 +12,7 @@ const LOG_KINDS = [
12
12
  ['disk-full', /\bENOSPC\b|no space left on device/i],
13
13
  ['permission', /\bEACCES\b|\bEPERM\b|permission denied/i],
14
14
  ['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
15
- ['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/],
15
+ ['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/],
16
16
  ];
17
17
  /**
18
18
  * Classify a failed run.
@@ -3,7 +3,11 @@
3
3
  * takes, and for a registry name whether it is one the registry can accept.
4
4
  * @module @deepseek-ai/dsh-plugin-manager/install-spec
5
5
  */
6
- /** One spec read into its form and the parts an inspection needs. */
6
+ /**
7
+ * One spec read into its form and the parts an inspection needs. A git spec
8
+ * and a tarball URL carry the `host` pnpm fetches them from, which no registry
9
+ * stands in for; only their dependencies come from the registry.
10
+ */
7
11
  export type ParsedInstallSpec = {
8
12
  readonly kind: 'registry';
9
13
  readonly spec: string;
@@ -17,9 +21,11 @@ export type ParsedInstallSpec = {
17
21
  readonly kind: 'tarball';
18
22
  readonly spec: string;
19
23
  readonly path?: string;
24
+ readonly host?: string;
20
25
  } | {
21
26
  readonly kind: 'git';
22
27
  readonly spec: string;
28
+ readonly host: string;
23
29
  };
24
30
  /** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
25
31
  export declare class InvalidInstallSpecError extends Error {
@@ -8,6 +8,10 @@ import { isAbsolute } from 'node:path';
8
8
  const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
9
9
  const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
10
10
  const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i;
11
+ /** The hosts pnpm's shorthands stand for. */
12
+ const GIT_SHORTHAND_HOSTS = {
13
+ github: 'github.com', gitlab: 'gitlab.com', bitbucket: 'bitbucket.org', gist: 'gist.github.com',
14
+ };
11
15
  /** A tarball, on disk or over HTTP. */
12
16
  const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
13
17
  /** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
@@ -31,6 +35,16 @@ export class InvalidInstallSpecError extends Error {
31
35
  function invalid(spec, reason) {
32
36
  return new InvalidInstallSpecError(spec, reason);
33
37
  }
38
+ /** 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. */
39
+ function gitHost(spec) {
40
+ const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase();
41
+ if (shorthand !== undefined && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand))
42
+ return GIT_SHORTHAND_HOSTS[shorthand];
43
+ const scp = /^git@([^:]+):/i.exec(spec)?.[1];
44
+ if (scp !== undefined)
45
+ return scp.toLowerCase();
46
+ return new URL(spec.replace(/^git\+/i, '')).host;
47
+ }
34
48
  /**
35
49
  * Read a spec into its form. A path must be absolute: the Host's working
36
50
  * directory means nothing to the person typing into a browser, and a
@@ -54,10 +68,10 @@ export function parseInstallSpec(raw) {
54
68
  throw invalid(spec, 'a local path must be absolute');
55
69
  const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec);
56
70
  if (git && !TARBALL_SPEC.test(spec))
57
- return { kind: 'git', spec };
71
+ return { kind: 'git', spec, host: gitHost(spec) };
58
72
  if (/^https?:\/\//i.test(spec)) {
59
73
  if (TARBALL_SPEC.test(spec))
60
- return { kind: 'tarball', spec };
74
+ return { kind: 'tarball', spec, host: new URL(spec).host };
61
75
  throw invalid(spec, 'a URL must point at a git repository or a tarball');
62
76
  }
63
77
  const at = spec.indexOf('@', 1);