@deepseek-ai/dsh-plugin-manager 0.2.1-alpha.2 → 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.
@@ -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;
@@ -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,13 +42,13 @@ 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, OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, } 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
48
  import { officialBundleInstallTarget } from "./official-install-target.js";
49
49
  import { dependencySpec, InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
50
50
  import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from "./registry.js";
51
- import { writePluginEnabled } from "./patch.js";
51
+ import { flatten, writePluginEnabled } from "./patch.js";
52
52
  import { incompatiblePlugin, ManagementFailure } from "./failure.js";
53
53
  import { approveBuilds, recordPendingBuilds } from "./build-approval.js";
54
54
  import { checkGithubConnection } from "./github-connection.js";
@@ -62,7 +62,7 @@ const protectedModules = new Set([
62
62
  '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules',
63
63
  '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager',
64
64
  '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry',
65
- '@deepseek-ai/dsh-api-remotes',
65
+ '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access',
66
66
  '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
67
67
  '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools',
68
68
  '@deepseek-ai/dsh-hmr',
@@ -71,10 +71,6 @@ const protectedModules = new Set([
71
71
  const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'];
72
72
  /** pnpm's colour escapes, which a JSON answer may be wrapped in. */
73
73
  const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g;
74
- /** Flatten only the groups addressable by the profile's patch composer. */
75
- function flatten(rows) {
76
- return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config) : [])]);
77
- }
78
74
  /** Preserve the exact observed diagnostic, including non-Error failures. */
79
75
  function messageOf(error) { return error instanceof Error ? error.message : String(error); }
80
76
  /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
@@ -128,6 +124,37 @@ function printedError(printed) {
128
124
  const { code, message } = error;
129
125
  return [code, message].filter((part) => typeof part === 'string').join(' ');
130
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
+ }
131
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. */
132
159
  function parsedForRegistry(spec) {
133
160
  try {
@@ -198,6 +225,11 @@ let PluginManager = (() => {
198
225
  });
199
226
  /** Management bundles remain protected if their files become unreadable. */
200
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();
201
233
  ownerEntryId;
202
234
  packageOperations = new Set();
203
235
  profile;
@@ -252,7 +284,7 @@ let PluginManager = (() => {
252
284
  setVersionExemption(packageVersion, runtimeVersion, enabled, acceptRisk) {
253
285
  return this.change(result => this.configure(async () => {
254
286
  await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true);
255
- result.warnings = await this.reload();
287
+ Object.assign(result, await this.reload());
256
288
  }), { stage: 'enable', target: packageVersion, enabled }, 'bundle');
257
289
  }
258
290
  /** Read current plugins, including why a row cannot be changed through the profile patch.
@@ -283,7 +315,7 @@ let PluginManager = (() => {
283
315
  listBundles() {
284
316
  const manifest = readProfileManifest('dsh', this.profile.dir);
285
317
  const exemptions = readProfileVersionExemptions(this.profile.dir);
286
- const selected = manifest.dsh?.profile?.bundles ?? [];
318
+ const selected = bundleSelection(manifest);
287
319
  const recorded = manifest.dependencies ?? {};
288
320
  const dependencies = Object.keys(recorded);
289
321
  const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8'));
@@ -388,7 +420,7 @@ let PluginManager = (() => {
388
420
  const manifest = readProfileManifest('dsh', this.profile.dir);
389
421
  const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8'));
390
422
  const known = new Set([
391
- ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
423
+ ...bundleSelection(manifest), ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
392
424
  ]);
393
425
  const plan = registryPlan(options?.registry, await this.registries());
394
426
  const registry = plan[0];
@@ -477,8 +509,8 @@ let PluginManager = (() => {
477
509
  throw new ManagementFailure('unknown-plugin');
478
510
  if (row.readOnlyReason !== undefined)
479
511
  throw new ManagementFailure(row.readOnlyReason);
480
- await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled);
481
- 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] : []));
482
514
  const current = (await this.listPlugins()).find(item => item.entryId === id);
483
515
  return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined;
484
516
  }), { stage: 'enable', target: id, enabled }, 'plugin');
@@ -491,11 +523,7 @@ let PluginManager = (() => {
491
523
  setBundleEnabled(name, enabled) {
492
524
  return this.change(result => this.configure(async () => {
493
525
  await this.selectBundle(name, enabled);
494
- if (enabled)
495
- await this.refreshPackages();
496
- result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : []);
497
- if (!enabled && this.ownerContext.get('hmr') !== undefined)
498
- await this.refreshPackages();
526
+ Object.assign(result, enabled ? await this.publishAndReload(this.bundleRows(name).map(row => row.id)) : await this.reload());
499
527
  }), { stage: 'enable', target: name, enabled }, 'bundle');
500
528
  }
501
529
  /**
@@ -524,12 +552,17 @@ let PluginManager = (() => {
524
552
  throw new ManagementFailure('invalid-spec');
525
553
  if (stopped())
526
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
+ }
527
561
  if (options?.approvedBuilds !== undefined) {
528
562
  await approveBuilds(this.profile.dir, options.approvedBuilds);
529
563
  result.approvedBuilds = options.approvedBuilds;
530
564
  }
531
565
  const files = await this.readRestoredFiles();
532
- const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
533
566
  let name;
534
567
  let version;
535
568
  try {
@@ -557,6 +590,7 @@ let PluginManager = (() => {
557
590
  // The last run is the result's; the registries asked stay listed whatever the outcome.
558
591
  const plan = registryPlan(options?.registry, await this.registries());
559
592
  let run;
593
+ let used = null;
560
594
  for (const [index, registry] of plan.entries()) {
561
595
  if (index > 0)
562
596
  await this.restoreFiles(files);
@@ -564,6 +598,7 @@ let PluginManager = (() => {
564
598
  if (stopped())
565
599
  throw new InstallCancelledError();
566
600
  result.registries.push(registry);
601
+ used = registry;
567
602
  announce('installing', { registry, index: index + 1, total: plan.length });
568
603
  run = await this.runPnpm(['add', spec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId);
569
604
  result.packageResult = run;
@@ -608,11 +643,11 @@ let PluginManager = (() => {
608
643
  }
609
644
  const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
610
645
  const installed = Object.keys(after).filter(name => before[name] !== after[name]);
611
- // Registry retries can retain the saved range after a partial installation.
646
+ // Without HMR a repeated installation can leave the saved dependency as it was.
612
647
  if (installed.length === 0) {
613
- const parsed = parsedForRegistry(spec);
614
- const localName = parsed.kind === 'path' ? readProfileManifest('dsh', parsed.path).name : undefined;
615
- installed.push(...Object.keys(after).filter(name => name === localName || spec === name || spec.startsWith(`${name}@`)));
648
+ const local = specPackageName(spec, after, this.profile.dir);
649
+ if (local !== undefined && Object.hasOwn(after, local))
650
+ installed.push(local);
616
651
  }
617
652
  const target = installed[0];
618
653
  if (installed.length !== 1 || target === undefined)
@@ -628,6 +663,7 @@ let PluginManager = (() => {
628
663
  for (const file of bundlePatchPaths(dir, manifest.dsh.bundle))
629
664
  loadOverlayPatches('dsh', file);
630
665
  version = manifest.version;
666
+ this.installedFrom.set(name, used);
631
667
  }
632
668
  catch (error) {
633
669
  // pnpm has exited by now, so the files it rewrote go back as they were.
@@ -644,11 +680,12 @@ let PluginManager = (() => {
644
680
  return this.configure(async () => {
645
681
  if (options?.enabled !== false)
646
682
  await this.selectBundle(name, true);
647
- if (Object.hasOwn(before, name))
648
- return 'restart-required';
649
- await this.refreshPackages();
650
- if (options?.enabled !== false)
651
- 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) : []));
652
689
  });
653
690
  }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install');
654
691
  control.result = result;
@@ -699,7 +736,7 @@ let PluginManager = (() => {
699
736
  const contributions = bundle.error === undefined ? this.bundleRows(name) : [];
700
737
  if (bundle.enabled) {
701
738
  await this.selectBundle(name, false);
702
- result.warnings = await this.reload();
739
+ Object.assign(result, await this.reload());
703
740
  }
704
741
  if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null
705
742
  && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) {
@@ -709,11 +746,15 @@ let PluginManager = (() => {
709
746
  });
710
747
  if (!installed)
711
748
  return;
712
- 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}`] : []]);
713
753
  if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) {
714
754
  throw new Error(result.packageResult.output);
715
755
  }
716
- await this.configure(() => this.refreshPackages());
756
+ this.installedFrom.delete(name);
757
+ Object.assign(result, await this.configure(() => this.publishAndReload()));
717
758
  }, { stage: 'remove', target: name }, 'remove');
718
759
  }
719
760
  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
@@ -807,7 +848,7 @@ let PluginManager = (() => {
807
848
  }
808
849
  async selectBundle(name, enabled) {
809
850
  const manifest = readProfileManifest('dsh', this.profile.dir);
810
- const previous = manifest.dsh?.profile?.bundles ?? [];
851
+ const previous = bundleSelection(manifest);
811
852
  if (enabled || !previous.includes(name)) {
812
853
  const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
813
854
  if (metadata === undefined)
@@ -859,28 +900,64 @@ let PluginManager = (() => {
859
900
  const apply = () => { this.abort.signal.throwIfAborted(); return operation(); };
860
901
  return hmr === undefined ? apply() : hmr.runExclusive(apply);
861
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
+ */
862
907
  async refreshPackages() {
863
- if (this.ownerContext.get('hmr') === undefined) {
864
- const selected = readProfileManifest('dsh', this.profile.dir).dsh?.profile?.bundles ?? [];
865
- // Deselected startup bundles still run without HMR and need the existing package table.
866
- if (this.profile.startedBundles.some(name => !selected.includes(name)))
867
- 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;
868
920
  }
869
- await this.ownerContext.get('pluginPackages')?.refresh();
870
921
  }
871
- 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 = []) {
872
928
  if (this.ownerContext.get('hmr') === undefined)
873
- return [];
874
- 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 };
875
947
  }
876
948
  async change(operation, request, reason) {
877
949
  return withFileLock(join(this.profile.dir, 'package.json'), async () => {
878
950
  this.abort.signal.throwIfAborted();
879
951
  const before = this.diskState();
880
952
  const result = { ...request, changed: false,
881
- application: this.ownerContext.get('hmr') !== undefined ? 'applied' : 'restart-required' };
953
+ ...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } };
882
954
  try {
883
- 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';
884
961
  }
885
962
  catch (error) {
886
963
  if (error instanceof InstallCancelledError) {
@@ -891,6 +968,8 @@ let PluginManager = (() => {
891
968
  result.error = managementError(error);
892
969
  }
893
970
  }
971
+ if (result.application !== 'restart-required')
972
+ delete result.restartReason;
894
973
  result.changed = before !== this.diskState();
895
974
  this.ownerContext.emit('plugin-manager/changed', { reason });
896
975
  return result;
@@ -62,6 +62,7 @@ export declare function saveManifest(dir: string, manifest: ProfileManifest): Pr
62
62
  * @param options Output, activation and cancellation policy.
63
63
  * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
64
64
  * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
65
+ * The diagnostic file is closed on return or rejection, including preparation and spawn failures.
65
66
  */
66
67
  export declare function runProfilePnpm(context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions): Promise<PackageResult>;
67
68
  /** Initialize and run the dsh plugin command with the same write lock as the service.