@deepseek-ai/dsh-plugin-manager 0.2.1-alpha.1 → 0.2.1-alpha.3

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.
@@ -1,8 +1,20 @@
1
- /** Read package names left undecided by pnpm 11, including after installation cleanup.
1
+ /**
2
+ * Record the build scripts a failed run reported as ignored, then list every name awaiting a decision.
3
+ *
4
+ * pnpm writes those policy entries itself only when it can prompt, and the
5
+ * manager always runs it non-interactively, so the run's own report is the
6
+ * record that survives.
2
7
  * @param dir Current profile directory.
8
+ * @param output Captured output of the failed run.
3
9
  * @returns Exact package names awaiting a build decision; wildcard rules are excluded.
4
10
  */
5
- export declare function readPendingBuilds(dir: string): Promise<string[]>;
11
+ export declare function recordPendingBuilds(dir: string, output: string): Promise<string[]>;
12
+ /**
13
+ * Read the package names a pnpm run reported as ignored build scripts.
14
+ * @param output Captured output of the run.
15
+ * @returns Exact names in report order; wildcard patterns are excluded.
16
+ */
17
+ export declare function ignoredBuildNames(output: string): string[];
6
18
  /** Persist approval without running scripts; the caller holds the profile manifest lock.
7
19
  * @param dir Current profile directory.
8
20
  * @param names Explicit package names from the pending build list.
@@ -4,6 +4,10 @@ import { join } from 'node:path';
4
4
  import { isAlias, isMap, isNode, isScalar, parseDocument, visit } from 'yaml';
5
5
  import { ManagementFailure } from "./failure.js";
6
6
  import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
7
+ /** Value pnpm leaves for a build script that still needs a decision. */
8
+ const UNDECIDED_BUILD = 'set this to true or false';
9
+ /** Where a non-interactive run names the build scripts it skipped. */
10
+ const IGNORED_BUILDS = /\bIgnored build scripts: (.+)/;
7
11
  async function readPolicy(dir) {
8
12
  let text;
9
13
  try {
@@ -27,16 +31,42 @@ async function readPolicy(dir) {
27
31
  throw new Error('allowBuilds must not contain YAML anchors or aliases');
28
32
  }
29
33
  });
34
+ const keys = isMap(builds) ? builds.items.flatMap(({ key }) => isScalar(key) && typeof key.value === 'string' ? [key.value] : []) : [];
30
35
  const pending = isMap(builds) ? builds.items.flatMap(({ key, value }) => isScalar(key) && typeof key.value === 'string' && !/[*?]/.test(key.value)
31
- && isScalar(value) && value.value === 'set this to true or false' ? [key.value] : []) : [];
32
- return { document, pending };
36
+ && isScalar(value) && value.value === UNDECIDED_BUILD ? [key.value] : []) : [];
37
+ return { document, pending, keys };
33
38
  }
34
- /** Read package names left undecided by pnpm 11, including after installation cleanup.
39
+ /**
40
+ * Record the build scripts a failed run reported as ignored, then list every name awaiting a decision.
41
+ *
42
+ * pnpm writes those policy entries itself only when it can prompt, and the
43
+ * manager always runs it non-interactively, so the run's own report is the
44
+ * record that survives.
35
45
  * @param dir Current profile directory.
46
+ * @param output Captured output of the failed run.
36
47
  * @returns Exact package names awaiting a build decision; wildcard rules are excluded.
37
48
  */
38
- export async function readPendingBuilds(dir) {
39
- return (await readPolicy(dir)).pending;
49
+ export async function recordPendingBuilds(dir, output) {
50
+ const { document, pending, keys } = await readPolicy(dir);
51
+ const known = new Set(keys);
52
+ const added = ignoredBuildNames(output).filter(name => !known.has(name));
53
+ if (added.length === 0)
54
+ return pending;
55
+ for (const name of added)
56
+ document.setIn(['allowBuilds', name], UNDECIDED_BUILD);
57
+ await writeFileAtomic(join(dir, 'pnpm-workspace.yaml'), String(document), { mode: 0o600 });
58
+ return [...pending, ...added];
59
+ }
60
+ /**
61
+ * Read the package names a pnpm run reported as ignored build scripts.
62
+ * @param output Captured output of the run.
63
+ * @returns Exact names in report order; wildcard patterns are excluded.
64
+ */
65
+ export function ignoredBuildNames(output) {
66
+ const reported = IGNORED_BUILDS.exec(output)?.[1];
67
+ if (reported === undefined)
68
+ return [];
69
+ return reported.split(',').map(name => name.trim()).filter(name => name !== '' && !/[*?]/.test(name));
40
70
  }
41
71
  /** Persist approval without running scripts; the caller holds the profile manifest lock.
42
72
  * @param dir Current profile directory.
@@ -40,6 +40,11 @@ export declare class PluginManager extends TypertRemoteService {
40
40
  static Config: z<Config>;
41
41
  /** Management bundles remain protected if their files become unreadable. */
42
42
  private readonly managementBundles;
43
+ /**
44
+ * The registry each package was last installed from by this manager in this process. pnpm verifies lockfile tarball
45
+ * URLs against its default registry unless they belong to a named registry, so a later removal names it again.
46
+ */
47
+ private readonly installedFrom;
43
48
  private readonly ownerEntryId;
44
49
  private readonly packageOperations;
45
50
  private readonly profile;
@@ -75,7 +80,7 @@ export declare class PluginManager extends TypertRemoteService {
75
80
  * @returns Current runtime entries with persistent patch targets.
76
81
  */
77
82
  listPlugins(): Promise<PluginInfo[]>;
78
- /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
83
+ /** Read installed, installation-provided, and offline Official catalog bundles, plus selected non-bundle names.
79
84
  * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
80
85
  * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional
81
86
  * display metadata, activation selections, whether the installation offers the bundle, and removal availability.
@@ -112,7 +117,7 @@ export declare class PluginManager extends TypertRemoteService {
112
117
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
113
118
  * @param spec One package spec, including local paths relative to the invocation directory.
114
119
  * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names,
115
- * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.
120
+ * the pending build scripts to allow, whether to save an exact dependency, and the registry asked first.
116
121
  * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome.
117
122
  */
118
123
  installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>;
@@ -146,7 +151,22 @@ export declare class PluginManager extends TypertRemoteService {
146
151
  private bundleRows;
147
152
  private protectsManager;
148
153
  private configure;
154
+ /**
155
+ * Publish the profile's runtime resolution without HMR, unless a deselected startup bundle still runs and needs the
156
+ * current table. A publication that needs a restart waits for the restart the result already reports.
157
+ */
149
158
  private refreshPackages;
159
+ /**
160
+ * Apply a package change: without HMR, publish the runtime resolution when that is safe, then {@link reload}.
161
+ * @param requiredIds Entry ids that must start.
162
+ * @returns The reload's outcome.
163
+ */
164
+ private publishAndReload;
165
+ /**
166
+ * Apply the saved profile; with HMR it recomposes live, including the runtime resolution.
167
+ * @param requiredIds Entry ids that must start.
168
+ * @returns Warnings about entries that were already inactive, and why the change waits for a restart, if it does.
169
+ */
150
170
  private reload;
151
171
  private change;
152
172
  private diskState;
@@ -42,14 +42,15 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
42
42
  import z from '@deepseek-ai/schemastery';
43
43
  import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol';
44
44
  import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory';
45
- import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, } from '@deepseek-ai/dsh-app-boot';
45
+ import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, ResolutionRestartRequired, } from '@deepseek-ai/dsh-app-boot';
46
46
  import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from "./operations.js";
47
47
  import { classifyInstallFailure } from "./install-failure.js";
48
+ import { officialBundleInstallTarget } from "./official-install-target.js";
48
49
  import { dependencySpec, InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
49
50
  import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from "./registry.js";
50
- import { writePluginEnabled } from "./patch.js";
51
+ import { flatten, writePluginEnabled } from "./patch.js";
51
52
  import { incompatiblePlugin, ManagementFailure } from "./failure.js";
52
- import { approveBuilds, readPendingBuilds } from "./build-approval.js";
53
+ import { approveBuilds, recordPendingBuilds } from "./build-approval.js";
53
54
  import { checkGithubConnection } from "./github-connection.js";
54
55
  export { classifyInstallFailure } from "./install-failure.js";
55
56
  export { InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
@@ -61,7 +62,7 @@ const protectedModules = new Set([
61
62
  '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules',
62
63
  '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager',
63
64
  '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry',
64
- '@deepseek-ai/dsh-api-remotes',
65
+ '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access',
65
66
  '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
66
67
  '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools',
67
68
  '@deepseek-ai/dsh-hmr',
@@ -70,10 +71,6 @@ const protectedModules = new Set([
70
71
  const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'];
71
72
  /** pnpm's colour escapes, which a JSON answer may be wrapped in. */
72
73
  const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g;
73
- /** Flatten only the groups addressable by the profile's patch composer. */
74
- function flatten(rows) {
75
- return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config) : [])]);
76
- }
77
74
  /** Preserve the exact observed diagnostic, including non-Error failures. */
78
75
  function messageOf(error) { return error instanceof Error ? error.message : String(error); }
79
76
  /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
@@ -127,6 +124,37 @@ function printedError(printed) {
127
124
  const { code, message } = error;
128
125
  return [code, message].filter((part) => typeof part === 'string').join(' ');
129
126
  }
127
+ /** The bundles a profile manifest selects. */
128
+ function bundleSelection(manifest) {
129
+ return manifest.dsh?.profile?.bundles ?? [];
130
+ }
131
+ /**
132
+ * The package an installation spec names before pnpm runs: a registry name, a local directory's manifest name, or the
133
+ * dependency saved with this git or tarball spec, compared as the plugin list shows saved specs.
134
+ * @param spec The installation spec.
135
+ * @param dependencies The profile's saved dependencies.
136
+ * @param profileDir The profile directory saved local paths are relative to.
137
+ * @returns The package name, or `undefined` when only the installation tells it.
138
+ */
139
+ function specPackageName(spec, dependencies, profileDir) {
140
+ const parsed = parsedForRegistry(spec);
141
+ if (parsed.kind === 'registry')
142
+ return parsed.name;
143
+ if (parsed.kind === 'path')
144
+ return packageName(parsed.path);
145
+ const specs = parsed.kind === 'tarball' && parsed.path !== undefined ? [spec, `file:${parsed.path}`] : [spec];
146
+ return Object.entries(dependencies).find(([dependency, saved]) => specs.includes(dependencySpec(dependency, saved, profileDir)))?.[0];
147
+ }
148
+ /** The package name a local directory declares, or `undefined` without a readable manifest. */
149
+ function packageName(dir) {
150
+ try {
151
+ return readProfileManifest('dsh', dir).name;
152
+ }
153
+ catch (_error) {
154
+ // pnpm reports the unreadable directory itself.
155
+ return undefined;
156
+ }
157
+ }
130
158
  /** 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. */
131
159
  function parsedForRegistry(spec) {
132
160
  try {
@@ -197,6 +225,11 @@ let PluginManager = (() => {
197
225
  });
198
226
  /** Management bundles remain protected if their files become unreadable. */
199
227
  managementBundles = (__runInitializers(this, _instanceExtraInitializers), new Set());
228
+ /**
229
+ * The registry each package was last installed from by this manager in this process. pnpm verifies lockfile tarball
230
+ * URLs against its default registry unless they belong to a named registry, so a later removal names it again.
231
+ */
232
+ installedFrom = new Map();
200
233
  ownerEntryId;
201
234
  packageOperations = new Set();
202
235
  profile;
@@ -251,7 +284,7 @@ let PluginManager = (() => {
251
284
  setVersionExemption(packageVersion, runtimeVersion, enabled, acceptRisk) {
252
285
  return this.change(result => this.configure(async () => {
253
286
  await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true);
254
- result.warnings = await this.reload();
287
+ Object.assign(result, await this.reload());
255
288
  }), { stage: 'enable', target: packageVersion, enabled }, 'bundle');
256
289
  }
257
290
  /** Read current plugins, including why a row cannot be changed through the profile patch.
@@ -274,7 +307,7 @@ let PluginManager = (() => {
274
307
  return { ...entry, patchId: candidate.id };
275
308
  });
276
309
  }
277
- /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
310
+ /** Read installed, installation-provided, and offline Official catalog bundles, plus selected non-bundle names.
278
311
  * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
279
312
  * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional
280
313
  * display metadata, activation selections, whether the installation offers the bundle, and removal availability.
@@ -282,49 +315,75 @@ let PluginManager = (() => {
282
315
  listBundles() {
283
316
  const manifest = readProfileManifest('dsh', this.profile.dir);
284
317
  const exemptions = readProfileVersionExemptions(this.profile.dir);
285
- const selected = manifest.dsh?.profile?.bundles ?? [];
318
+ const selected = bundleSelection(manifest);
286
319
  const recorded = manifest.dependencies ?? {};
287
320
  const dependencies = Object.keys(recorded);
288
321
  const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8'));
289
- const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])];
322
+ const catalog = new Map(OFFICIAL_ON_DEMAND_CATALOG.map(entry => [entry.packageName, entry]));
323
+ const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {}), ...catalog.keys()])];
290
324
  const bundles = [];
325
+ const runtimeVersion = getDshRuntimeVersion();
291
326
  for (const name of names) {
292
327
  const installed = dependencies.includes(name);
293
328
  const optional = OPTIONAL_BUNDLES.includes(name);
294
329
  const enabled = selected.includes(name);
295
330
  const shipped = Object.hasOwn(installation.dependencies ?? {}, name);
296
- // Bundle resolution reads the installation first, so a profile dependency the installation manifest also names,
297
- // like one it forbids removing, is not the loaded copy.
331
+ const offered = catalog.get(name);
332
+ const official = optional || offered !== undefined;
333
+ let target = {};
334
+ let targetError;
335
+ if (offered !== undefined) {
336
+ try {
337
+ target = { installTarget: officialBundleInstallTarget(name, this.profile.installAnchor, runtimeVersion) };
338
+ }
339
+ catch (error) {
340
+ targetError = managementError(error);
341
+ }
342
+ }
343
+ const catalogMeta = offered === undefined ? {} : { meta: offered.meta };
344
+ if (offered !== undefined && !installed && !shipped) {
345
+ bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional,
346
+ removable: enabled, rows: [], overrides: [],
347
+ ...targetError !== undefined ? { error: targetError } : enabled ? { error: { code: 'not-bundle' } } : {} });
348
+ continue;
349
+ }
350
+ // Installation-owned copies remain protected even if a profile also declares their names.
298
351
  const owned = installed && !shipped;
299
- // A selection neither the profile nor the installation holds, such as a retired bundle, is removed by deselecting it.
300
352
  const removable = owned || (enabled && !installed && !shipped);
301
353
  const sourceOf = (packageName) => owned ? { source: dependencySpec(name, recorded[name], this.profile.dir, packageName) } : {};
302
354
  const readOnlyReason = this.protectsManager(name) ? 'management-required' : undefined;
355
+ let availability = 'missing';
356
+ let version;
303
357
  try {
304
358
  const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
359
+ availability = shipped ? 'installation' : 'profile';
305
360
  if (info === undefined) {
306
- if (enabled)
307
- bundles.push({ name, ...sourceOf(), enabled, installed, optional,
308
- removable: removable && readOnlyReason === undefined,
309
- ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] });
361
+ if (enabled || offered !== undefined)
362
+ bundles.push({ name, official, availability, ...catalogMeta, ...target,
363
+ ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
364
+ ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: targetError ?? { code: 'not-bundle' }, rows: [], overrides: [] });
310
365
  continue;
311
366
  }
367
+ version = info.version;
312
368
  const compatibility = evaluatePluginCompatibility(info, exemptions);
313
369
  if (compatibility !== undefined && !compatibility.exempted)
314
370
  throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]);
315
371
  const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
316
- const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href);
317
- bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
372
+ const meta = offered?.meta ?? readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href);
373
+ bundles.push({ name, official, availability, ...target, ...(version === undefined ? {} : { version }),
318
374
  ...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
319
375
  ...meta === undefined ? {} : { meta },
320
376
  ...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
321
377
  ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
322
- ...this.declaredRows(name, info) });
378
+ ...this.declaredRows(name, info),
379
+ ...targetError === undefined ? {} : { error: targetError } });
323
380
  }
324
381
  catch (error) {
325
- if (enabled || installed) {
326
- bundles.push({ name, ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
327
- ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] });
382
+ if (enabled || installed || optional || offered !== undefined) {
383
+ bundles.push({ name, official, availability, ...catalogMeta, ...target, ...(version === undefined ? {} : { version }),
384
+ ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
385
+ ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
386
+ error: targetError ?? managementError(error), rows: [], overrides: [] });
328
387
  }
329
388
  }
330
389
  }
@@ -361,7 +420,7 @@ let PluginManager = (() => {
361
420
  const manifest = readProfileManifest('dsh', this.profile.dir);
362
421
  const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8'));
363
422
  const known = new Set([
364
- ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
423
+ ...bundleSelection(manifest), ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
365
424
  ]);
366
425
  const plan = registryPlan(options?.registry, await this.registries());
367
426
  const registry = plan[0];
@@ -450,8 +509,8 @@ let PluginManager = (() => {
450
509
  throw new ManagementFailure('unknown-plugin');
451
510
  if (row.readOnlyReason !== undefined)
452
511
  throw new ManagementFailure(row.readOnlyReason);
453
- await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled);
454
- result.warnings = await this.reload(enabled ? [row.patchId] : []);
512
+ await writePluginEnabled(this.profile, row.patchId, row.moduleName, enabled);
513
+ Object.assign(result, await this.reload(enabled ? [row.patchId] : []));
455
514
  const current = (await this.listPlugins()).find(item => item.entryId === id);
456
515
  return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined;
457
516
  }), { stage: 'enable', target: id, enabled }, 'plugin');
@@ -464,11 +523,7 @@ let PluginManager = (() => {
464
523
  setBundleEnabled(name, enabled) {
465
524
  return this.change(result => this.configure(async () => {
466
525
  await this.selectBundle(name, enabled);
467
- if (enabled)
468
- await this.refreshPackages();
469
- result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : []);
470
- if (!enabled && this.ownerContext.get('hmr') !== undefined)
471
- await this.refreshPackages();
526
+ Object.assign(result, enabled ? await this.publishAndReload(this.bundleRows(name).map(row => row.id)) : await this.reload());
472
527
  }), { stage: 'enable', target: name, enabled }, 'bundle');
473
528
  }
474
529
  /**
@@ -479,7 +534,7 @@ let PluginManager = (() => {
479
534
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
480
535
  * @param spec One package spec, including local paths relative to the invocation directory.
481
536
  * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names,
482
- * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.
537
+ * the pending build scripts to allow, whether to save an exact dependency, and the registry asked first.
483
538
  * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome.
484
539
  */
485
540
  installBundle(spec, options) {
@@ -497,12 +552,17 @@ let PluginManager = (() => {
497
552
  throw new ManagementFailure('invalid-spec');
498
553
  if (stopped())
499
554
  throw new InstallCancelledError();
555
+ const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
556
+ // With HMR, an installed package is removed before another version is installed, so its plugins stop first.
557
+ const known = specPackageName(spec, before, this.profile.dir);
558
+ if (this.ownerContext.get('hmr') !== undefined && known !== undefined && Object.hasOwn(before, known)) {
559
+ throw new ManagementFailure('already-installed');
560
+ }
500
561
  if (options?.approvedBuilds !== undefined) {
501
562
  await approveBuilds(this.profile.dir, options.approvedBuilds);
502
563
  result.approvedBuilds = options.approvedBuilds;
503
564
  }
504
565
  const files = await this.readRestoredFiles();
505
- const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
506
566
  let name;
507
567
  let version;
508
568
  try {
@@ -530,6 +590,7 @@ let PluginManager = (() => {
530
590
  // The last run is the result's; the registries asked stay listed whatever the outcome.
531
591
  const plan = registryPlan(options?.registry, await this.registries());
532
592
  let run;
593
+ let used = null;
533
594
  for (const [index, registry] of plan.entries()) {
534
595
  if (index > 0)
535
596
  await this.restoreFiles(files);
@@ -537,8 +598,9 @@ let PluginManager = (() => {
537
598
  if (stopped())
538
599
  throw new InstallCancelledError();
539
600
  result.registries.push(registry);
601
+ used = registry;
540
602
  announce('installing', { registry, index: index + 1, total: plan.length });
541
- run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId);
603
+ run = await this.runPnpm(['add', spec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId);
542
604
  result.packageResult = run;
543
605
  if (stopped())
544
606
  throw new InstallCancelledError();
@@ -570,9 +632,9 @@ let PluginManager = (() => {
570
632
  if (succeeded)
571
633
  delete result.failedAt;
572
634
  if (!succeeded) {
573
- // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
635
+ // pnpm-workspace.yaml is not restored, so the names it left undecided, or reported as ignored, are offered for approval.
574
636
  try {
575
- result.pendingBuilds = await readPendingBuilds(this.profile.dir);
637
+ result.pendingBuilds = await recordPendingBuilds(this.profile.dir, run.output);
576
638
  }
577
639
  catch (error) {
578
640
  this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error);
@@ -581,9 +643,12 @@ let PluginManager = (() => {
581
643
  }
582
644
  const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
583
645
  const installed = Object.keys(after).filter(name => before[name] !== after[name]);
584
- // Registry retries can retain the saved range after a partial installation.
585
- if (installed.length === 0)
586
- installed.push(...Object.keys(after).filter(name => spec === name || spec.startsWith(`${name}@`)));
646
+ // Without HMR a repeated installation can leave the saved dependency as it was.
647
+ if (installed.length === 0) {
648
+ const local = specPackageName(spec, after, this.profile.dir);
649
+ if (local !== undefined && Object.hasOwn(after, local))
650
+ installed.push(local);
651
+ }
587
652
  const target = installed[0];
588
653
  if (installed.length !== 1 || target === undefined)
589
654
  throw new ManagementFailure('ambiguous-install');
@@ -598,6 +663,7 @@ let PluginManager = (() => {
598
663
  for (const file of bundlePatchPaths(dir, manifest.dsh.bundle))
599
664
  loadOverlayPatches('dsh', file);
600
665
  version = manifest.version;
666
+ this.installedFrom.set(name, used);
601
667
  }
602
668
  catch (error) {
603
669
  // pnpm has exited by now, so the files it rewrote go back as they were.
@@ -614,11 +680,12 @@ let PluginManager = (() => {
614
680
  return this.configure(async () => {
615
681
  if (options?.enabled !== false)
616
682
  await this.selectBundle(name, true);
617
- if (Object.hasOwn(before, name))
618
- return 'restart-required';
619
- await this.refreshPackages();
620
- if (options?.enabled !== false)
621
- result.warnings = await this.reload();
683
+ // Without HMR the replaced package's plugins keep running until the restart the result already reports.
684
+ if (Object.hasOwn(before, name) && this.ownerContext.get('hmr') === undefined)
685
+ return;
686
+ // A selected bundle's own rows must start.
687
+ const selected = bundleSelection(readProfileManifest('dsh', this.profile.dir)).includes(name);
688
+ Object.assign(result, await this.publishAndReload(selected ? this.bundleRows(name).map(row => row.id) : []));
622
689
  });
623
690
  }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install');
624
691
  control.result = result;
@@ -669,7 +736,7 @@ let PluginManager = (() => {
669
736
  const contributions = bundle.error === undefined ? this.bundleRows(name) : [];
670
737
  if (bundle.enabled) {
671
738
  await this.selectBundle(name, false);
672
- result.warnings = await this.reload();
739
+ Object.assign(result, await this.reload());
673
740
  }
674
741
  if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null
675
742
  && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) {
@@ -679,11 +746,15 @@ let PluginManager = (() => {
679
746
  });
680
747
  if (!installed)
681
748
  return;
682
- result.packageResult = await this.runPnpm(['remove', name]);
749
+ // `pnpm remove` takes no `--registry` option; the generic configuration flag sets the same setting. A package
750
+ // installed before this process started is removed with the registry an installation asks first.
751
+ const registry = this.installedFrom.has(name) ? this.installedFrom.get(name) : this.configuredRegistries.registry;
752
+ result.packageResult = await this.runPnpm(['remove', name, ...typeof registry === 'string' ? [`--config.registry=${registry}`] : []]);
683
753
  if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) {
684
754
  throw new Error(result.packageResult.output);
685
755
  }
686
- await this.configure(() => this.refreshPackages());
756
+ this.installedFrom.delete(name);
757
+ Object.assign(result, await this.configure(() => this.publishAndReload()));
687
758
  }, { stage: 'remove', target: name }, 'remove');
688
759
  }
689
760
  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
@@ -706,7 +777,8 @@ let PluginManager = (() => {
706
777
  const rows = [];
707
778
  const packages = this.ctx.get('pluginPackages');
708
779
  for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
709
- if (typeof row.id !== 'string' || typeof row.name !== 'string')
780
+ // Group rows are containers without a plugin inventory entry; their children carry the switches.
781
+ if (typeof row.id !== 'string' || typeof row.name !== 'string' || row.group === true)
710
782
  continue;
711
783
  const active = live.get(row.id);
712
784
  const entryId = active?.entryId;
@@ -776,7 +848,7 @@ let PluginManager = (() => {
776
848
  }
777
849
  async selectBundle(name, enabled) {
778
850
  const manifest = readProfileManifest('dsh', this.profile.dir);
779
- const previous = manifest.dsh?.profile?.bundles ?? [];
851
+ const previous = bundleSelection(manifest);
780
852
  if (enabled || !previous.includes(name)) {
781
853
  const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
782
854
  if (metadata === undefined)
@@ -828,28 +900,64 @@ let PluginManager = (() => {
828
900
  const apply = () => { this.abort.signal.throwIfAborted(); return operation(); };
829
901
  return hmr === undefined ? apply() : hmr.runExclusive(apply);
830
902
  }
903
+ /**
904
+ * Publish the profile's runtime resolution without HMR, unless a deselected startup bundle still runs and needs the
905
+ * current table. A publication that needs a restart waits for the restart the result already reports.
906
+ */
831
907
  async refreshPackages() {
832
- if (this.ownerContext.get('hmr') === undefined) {
833
- const selected = readProfileManifest('dsh', this.profile.dir).dsh?.profile?.bundles ?? [];
834
- // Deselected startup bundles still run without HMR and need the existing package table.
835
- if (this.profile.startedBundles.some(name => !selected.includes(name)))
836
- return;
908
+ const selected = bundleSelection(readProfileManifest('dsh', this.profile.dir));
909
+ if (this.profile.startedBundles.some(name => !selected.includes(name)))
910
+ return;
911
+ try {
912
+ const packages = this.ownerContext.get('pluginPackages');
913
+ // A dropped mapping's bundle is not running: the guard above keeps the table while a deselected startup bundle runs.
914
+ if (packages?.resolution !== undefined)
915
+ await packages.refresh();
916
+ }
917
+ catch (error) {
918
+ if (!(error instanceof ResolutionRestartRequired))
919
+ throw error;
837
920
  }
838
- await this.ownerContext.get('pluginPackages')?.refresh();
839
921
  }
840
- async reload(requiredIds = []) {
922
+ /**
923
+ * Apply a package change: without HMR, publish the runtime resolution when that is safe, then {@link reload}.
924
+ * @param requiredIds Entry ids that must start.
925
+ * @returns The reload's outcome.
926
+ */
927
+ async publishAndReload(requiredIds = []) {
841
928
  if (this.ownerContext.get('hmr') === undefined)
842
- return [];
843
- return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds);
929
+ await this.refreshPackages();
930
+ return this.reload(requiredIds);
931
+ }
932
+ /**
933
+ * Apply the saved profile; with HMR it recomposes live, including the runtime resolution.
934
+ * @param requiredIds Entry ids that must start.
935
+ * @returns Warnings about entries that were already inactive, and why the change waits for a restart, if it does.
936
+ */
937
+ async reload(requiredIds = []) {
938
+ const hmr = this.ownerContext.get('hmr');
939
+ if (hmr === undefined) {
940
+ // Startup-only profiles recompose at restart; the saved patches still have to compose.
941
+ composeEntries([readProfilePatches('dsh', this.profile)]);
942
+ return { warnings: [] };
943
+ }
944
+ const { warnings, pending, restartReason } = await hmr.reconcileProfile(requiredIds);
945
+ const restart = restartReason ?? pending[0]?.reason;
946
+ return restart === undefined ? { warnings } : { warnings, restartReason: restart };
844
947
  }
845
948
  async change(operation, request, reason) {
846
949
  return withFileLock(join(this.profile.dir, 'package.json'), async () => {
847
950
  this.abort.signal.throwIfAborted();
848
951
  const before = this.diskState();
849
952
  const result = { ...request, changed: false,
850
- application: this.ownerContext.get('hmr') !== undefined ? 'applied' : 'restart-required' };
953
+ ...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } };
851
954
  try {
852
- result.application = await operation(result) ?? result.application;
955
+ const outcome = await operation(result);
956
+ if (outcome !== undefined)
957
+ result.application = outcome;
958
+ // A reload that waits for a restart leaves the change saved but not applied.
959
+ else if (result.application === 'applied' && result.restartReason !== undefined)
960
+ result.application = 'restart-required';
853
961
  }
854
962
  catch (error) {
855
963
  if (error instanceof InstallCancelledError) {
@@ -860,6 +968,8 @@ let PluginManager = (() => {
860
968
  result.error = managementError(error);
861
969
  }
862
970
  }
971
+ if (result.application !== 'restart-required')
972
+ delete result.restartReason;
863
973
  result.changed = before !== this.diskState();
864
974
  this.ownerContext.emit('plugin-manager/changed', { reason });
865
975
  return result;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Offer a source checkout link or an exact published package version. Source installations are
3
+ * identified by the real CLI manifest and its unpacked workspace dependencies, including Desktop's
4
+ * development symlink. An incomplete checkout is an error, never a registry fallback.
5
+ * @param name - Official catalog package name.
6
+ * @param installAnchor - Running application's DSH package.json, possibly reached through a symlink.
7
+ * @param version - Running DSH version used by its release family.
8
+ * @returns An ordinary package-manager spec and its package version.
9
+ * @throws when a source installation lacks its workspace package, bundle patch, or built runtime.
10
+ */
11
+ export declare function officialBundleInstallTarget(name: string, installAnchor: string, version: string): {
12
+ spec: string;
13
+ version: string;
14
+ };
15
+ //# sourceMappingURL=official-install-target.d.ts.map