@deepseek-ai/dsh-plugin-manager 0.1.7-alpha.2 → 0.1.7-rc.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.
@@ -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, } from '@deepseek-ai/dsh-app-boot';
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';
46
46
  import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from "./operations.js";
47
47
  import { classifyInstallFailure } from "./install-failure.js";
48
48
  import { InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
49
49
  import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from "./registry.js";
50
50
  import { writePluginEnabled } from "./patch.js";
51
- import { ManagementFailure } from "./failure.js";
51
+ import { incompatiblePlugin, ManagementFailure } from "./failure.js";
52
52
  import { approveBuilds, readPendingBuilds } from "./build-approval.js";
53
+ import { checkGithubConnection } from "./github-connection.js";
53
54
  export { classifyInstallFailure } from "./install-failure.js";
54
55
  export { InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
55
56
  /** An http(s) URL, as pnpm's `--registry` takes it. */
@@ -77,7 +78,9 @@ function flatten(rows) {
77
78
  function messageOf(error) { return error instanceof Error ? error.message : String(error); }
78
79
  /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
79
80
  function managementError(error) {
80
- return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(error) };
81
+ if (!(error instanceof ManagementFailure))
82
+ return { code: 'operation-error', diagnostic: messageOf(error) };
83
+ return { code: error.code, ...error.incompatible === undefined ? {} : { incompatible: error.incompatible } };
81
84
  }
82
85
  /** The caller stopped an installation; its files are restored before this is thrown. */
83
86
  class InstallCancelledError extends Error {
@@ -140,6 +143,8 @@ function parsedForRegistry(spec) {
140
143
  let PluginManager = (() => {
141
144
  let _classSuper = TypertRemoteService;
142
145
  let _instanceExtraInitializers = [];
146
+ let _listVersionExemptions_decorators;
147
+ let _setVersionExemption_decorators;
143
148
  let _listPlugins_decorators;
144
149
  let _listBundles_decorators;
145
150
  let _registries_decorators;
@@ -153,6 +158,8 @@ let PluginManager = (() => {
153
158
  return class PluginManager extends _classSuper {
154
159
  static {
155
160
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
161
+ _listVersionExemptions_decorators = [Remote];
162
+ _setVersionExemption_decorators = [Remote];
156
163
  _listPlugins_decorators = [Remote];
157
164
  _listBundles_decorators = [Remote];
158
165
  _registries_decorators = [Remote];
@@ -163,6 +170,8 @@ let PluginManager = (() => {
163
170
  _waitForInstall_decorators = [Remote];
164
171
  _cancelInstall_decorators = [Remote];
165
172
  _removeBundle_decorators = [Remote];
173
+ __esDecorate(this, null, _listVersionExemptions_decorators, { kind: "method", name: "listVersionExemptions", static: false, private: false, access: { has: obj => "listVersionExemptions" in obj, get: obj => obj.listVersionExemptions }, metadata: _metadata }, null, _instanceExtraInitializers);
174
+ __esDecorate(this, null, _setVersionExemption_decorators, { kind: "method", name: "setVersionExemption", static: false, private: false, access: { has: obj => "setVersionExemption" in obj, get: obj => obj.setVersionExemption }, metadata: _metadata }, null, _instanceExtraInitializers);
166
175
  __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);
167
176
  __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
177
  __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);
@@ -181,6 +190,8 @@ let PluginManager = (() => {
181
190
  outputBytes: z.number().step(1).min(1).default(16384),
182
191
  lockWaitMs: z.number().step(1).min(0).default(120000),
183
192
  inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
193
+ githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000),
194
+ idleTimeoutMs: z.number().step(1).min(1000).default(600000),
184
195
  registry: z.string().pattern(REGISTRY_URL),
185
196
  fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
186
197
  });
@@ -192,6 +203,8 @@ let PluginManager = (() => {
192
203
  outputBytes;
193
204
  lockWaitMs;
194
205
  inspectTimeoutMs;
206
+ githubConnectionTimeoutMs;
207
+ idleTimeoutMs;
195
208
  pnpmCommand;
196
209
  configuredRegistries;
197
210
  ownerContext;
@@ -208,6 +221,8 @@ let PluginManager = (() => {
208
221
  this.outputBytes = config.outputBytes;
209
222
  this.lockWaitMs = config.lockWaitMs;
210
223
  this.inspectTimeoutMs = config.inspectTimeoutMs;
224
+ this.githubConnectionTimeoutMs = config.githubConnectionTimeoutMs;
225
+ this.idleTimeoutMs = config.idleTimeoutMs;
211
226
  this.pnpmCommand = config.pnpmCommand;
212
227
  this.configuredRegistries = {
213
228
  registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
@@ -218,6 +233,27 @@ let PluginManager = (() => {
218
233
  await Promise.allSettled([...this.packageOperations]);
219
234
  }, 'plugin-manager: package cancellation');
220
235
  }
236
+ /** Read exact plugin-version exemptions saved in this profile.
237
+ * @returns Accepted package-name@version keys with the runtime versions they may run on, and any
238
+ * record or file problem the reader rejected, which the caller reports instead of failing.
239
+ */
240
+ listVersionExemptions() {
241
+ const { exemptions, warnings } = readProfileCompatibility(this.profile.dir);
242
+ return { exemptions, warnings };
243
+ }
244
+ /** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
245
+ * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
246
+ * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
247
+ * @param enabled Whether to grant rather than revoke the exemption.
248
+ * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
249
+ * @returns Saved and runtime outcomes. Startup-only profiles require restart.
250
+ */
251
+ setVersionExemption(packageVersion, runtimeVersion, enabled, acceptRisk) {
252
+ return this.change(result => this.configure(async () => {
253
+ await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true);
254
+ result.warnings = await this.reload();
255
+ }), { stage: 'enable', target: packageVersion, enabled }, 'bundle');
256
+ }
221
257
  /** Read current plugins, including why a row cannot be changed through the profile patch.
222
258
  * @returns Current runtime entries with persistent patch targets.
223
259
  */
@@ -245,6 +281,7 @@ let PluginManager = (() => {
245
281
  */
246
282
  listBundles() {
247
283
  const manifest = readProfileManifest('dsh', this.profile.dir);
284
+ const exemptions = readProfileVersionExemptions(this.profile.dir);
248
285
  const selected = manifest.dsh?.profile?.bundles ?? [];
249
286
  const dependencies = Object.keys(manifest.dependencies ?? {});
250
287
  const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8'));
@@ -264,6 +301,9 @@ let PluginManager = (() => {
264
301
  ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] });
265
302
  continue;
266
303
  }
304
+ const compatibility = evaluatePluginCompatibility(info, exemptions);
305
+ if (compatibility !== undefined && !compatibility.exempted)
306
+ throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]);
267
307
  const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
268
308
  const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href);
269
309
  bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
@@ -420,7 +460,9 @@ let PluginManager = (() => {
420
460
  }), { stage: 'enable', target: name, enabled }, 'bundle');
421
461
  }
422
462
  /**
423
- * Install a package using the same pnpm implementation as dsh plugin. A run
463
+ * Install a package using the same pnpm implementation as dsh plugin. GitHub
464
+ * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
465
+ * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
424
466
  * that fails, is cancelled, or adds a package without a bundle patch restores
425
467
  * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
426
468
  * @param spec One package spec, including local paths relative to the invocation directory.
@@ -431,7 +473,7 @@ let PluginManager = (() => {
431
473
  installBundle(spec, options) {
432
474
  const requestId = options?.requestId;
433
475
  const control = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) };
434
- const stopped = () => control.abort.signal.aborted;
476
+ const stopped = () => control.abort.signal.aborted || this.abort.signal.aborted;
435
477
  if (requestId !== undefined)
436
478
  this.installs.set(requestId, control);
437
479
  const announce = (phase, attempt) => {
@@ -449,12 +491,32 @@ let PluginManager = (() => {
449
491
  }
450
492
  const files = await this.readRestoredFiles();
451
493
  const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
452
- const plan = registryPlan(options?.registry, await this.registries());
453
494
  let name;
454
495
  try {
496
+ result.registries = [];
497
+ const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
498
+ timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes,
499
+ signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
500
+ ...this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env },
501
+ });
502
+ this.packageOperations.add(connection);
503
+ let connectionFailure;
504
+ try {
505
+ connectionFailure = await connection;
506
+ }
507
+ finally {
508
+ this.packageOperations.delete(connection);
509
+ }
510
+ if (stopped())
511
+ throw new InstallCancelledError();
512
+ if (connectionFailure?.kind === 'network' || connectionFailure?.kind === 'timeout') {
513
+ result.packageResult = connectionFailure;
514
+ result.failedAt = 'spec-host';
515
+ throw new Error(connectionFailure.output);
516
+ }
455
517
  // The last run is the result's; the registries asked stay listed whatever the outcome.
518
+ const plan = registryPlan(options?.registry, await this.registries());
456
519
  let run;
457
- result.registries = [];
458
520
  for (const [index, registry] of plan.entries()) {
459
521
  if (index > 0)
460
522
  await this.restoreFiles(files);
@@ -467,12 +529,21 @@ let PluginManager = (() => {
467
529
  result.packageResult = run;
468
530
  if (stopped())
469
531
  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)
532
+ // A compatibility refusal is the package's own answer, so no other registry is asked.
533
+ if (run.incompatible !== undefined)
534
+ throw new ManagementFailure('incompatible-version', run.incompatible);
535
+ // A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0.
536
+ if (run.exitCode === 0 && run.timedOut !== true)
537
+ break;
538
+ /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */
539
+ if (run.kind === undefined)
472
540
  break;
473
- const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
474
541
  // What the last failed run could not reach; a later run that succeeds leaves nothing to say.
475
542
  delete result.failedAt;
543
+ // A run this manager terminated got no answer from the registry at all, so no registry explains it.
544
+ if (run.timedOut === true)
545
+ break;
546
+ const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
476
547
  if (failedAt !== 'other')
477
548
  result.failedAt = failedAt;
478
549
  if (failedAt !== 'registry' || index === plan.length - 1)
@@ -481,9 +552,11 @@ let PluginManager = (() => {
481
552
  /* v8 ignore next -- the plan is never empty, so a run always settled */
482
553
  if (run === undefined)
483
554
  throw new Error('no registry was asked');
484
- if (run.exitCode === 0)
555
+ // A terminated run reports no usable exit status, so neither its files nor its bundle are trusted.
556
+ const succeeded = run.exitCode === 0 && run.timedOut !== true;
557
+ if (succeeded)
485
558
  delete result.failedAt;
486
- if (run.exitCode !== 0) {
559
+ if (!succeeded) {
487
560
  // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
488
561
  try {
489
562
  result.pendingBuilds = await readPendingBuilds(this.profile.dir);
@@ -506,6 +579,9 @@ let PluginManager = (() => {
506
579
  const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
507
580
  if (manifest?.dsh?.bundle === undefined)
508
581
  throw new ManagementFailure('not-bundle');
582
+ const compatibility = evaluatePluginCompatibility(manifest, readProfileVersionExemptions(this.profile.dir));
583
+ if (compatibility !== undefined && !compatibility.exempted)
584
+ throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]);
509
585
  for (const file of bundlePatchPaths(dir, manifest.dsh.bundle))
510
586
  loadOverlayPatches('dsh', file);
511
587
  }
@@ -542,7 +618,7 @@ let PluginManager = (() => {
542
618
  }
543
619
  /** Stop an installation this manager owns and wait until its files are back.
544
620
  * @param requestId The id the installation was started with.
545
- * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
621
+ * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
546
622
  * applied, `not-running` for any other id.
547
623
  */
548
624
  async cancelInstall(requestId) {
@@ -583,8 +659,9 @@ let PluginManager = (() => {
583
659
  }
584
660
  });
585
661
  result.packageResult = await this.runPnpm(['remove', name]);
586
- if (result.packageResult.exitCode !== 0)
662
+ if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) {
587
663
  throw new Error(result.packageResult.output);
664
+ }
588
665
  }, { stage: 'remove', target: name }, 'remove');
589
666
  }
590
667
  /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
@@ -629,7 +706,8 @@ let PluginManager = (() => {
629
706
  const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, {
630
707
  execution: 'service', ...this.profile.packageManager ?? { command: this.pnpmCommand },
631
708
  signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
632
- outputBytes: this.outputBytes, activateNewBundles: false,
709
+ outputBytes: this.outputBytes, activateNewBundles: false, idleTimeoutMs: this.idleTimeoutMs,
710
+ lookupTimeoutMs: this.inspectTimeoutMs,
633
711
  onOutput: (text, stream) => {
634
712
  this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text });
635
713
  },
@@ -640,7 +718,13 @@ let PluginManager = (() => {
640
718
  this.ownerContext.emit('plugin-manager/install-log', {
641
719
  ...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode,
642
720
  });
643
- return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) };
721
+ // A terminated run keeps its own kind even when pnpm trapped the signal and exited 0.
722
+ if (result.exitCode === 0 && result.timedOut !== true)
723
+ return result;
724
+ return {
725
+ ...result,
726
+ kind: classifyInstallFailure({ log: result.output, ...result.timedOut === true ? { timedOut: true } : {} }),
727
+ };
644
728
  }
645
729
  catch (error) {
646
730
  this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null });
@@ -671,11 +755,17 @@ let PluginManager = (() => {
671
755
  async selectBundle(name, enabled) {
672
756
  const manifest = readProfileManifest('dsh', this.profile.dir);
673
757
  const previous = manifest.dsh?.profile?.bundles ?? [];
674
- if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
675
- throw new ManagementFailure('not-bundle');
758
+ if (enabled || !previous.includes(name)) {
759
+ const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
760
+ if (metadata === undefined)
761
+ throw new ManagementFailure('not-bundle');
762
+ if (enabled) {
763
+ const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir));
764
+ if (compatibility !== undefined && !compatibility.exempted)
765
+ throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]);
766
+ this.bundleRows(name);
767
+ }
676
768
  }
677
- if (enabled)
678
- this.bundleRows(name);
679
769
  if (!enabled && previous.includes(name)) {
680
770
  if (this.protectsManager(name))
681
771
  throw new ManagementFailure('management-required');
@@ -745,7 +835,7 @@ let PluginManager = (() => {
745
835
  }, { waitMs: this.lockWaitMs });
746
836
  }
747
837
  diskState() {
748
- return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml'].map((file) => {
838
+ return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml', PROFILE_COMPATIBILITY_FILENAME].map((file) => {
749
839
  try {
750
840
  return readFileSync(join(this.profile.dir, file), 'utf8');
751
841
  }
@@ -12,6 +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
+ ['unknown', /The requested URL returned error: 40[134]\b/],
15
16
  ['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
17
  ];
17
18
  /**
@@ -1,5 +1,6 @@
1
1
  import { type ProfileManifest } from '@deepseek-ai/dsh-app-boot';
2
2
  import type { PackageResult, Registry } from './types.ts';
3
+ export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot';
3
4
  /** Profile and invocation locations supplied by the launcher. */
4
5
  export interface PackageOperationContext {
5
6
  profile: string;
@@ -24,6 +25,15 @@ export interface PackageOperationOptions {
24
25
  onOutput?: (text: string, stream: 'stdout' | 'stderr') => void;
25
26
  activateNewBundles?: boolean;
26
27
  lockWaitMs?: number;
28
+ /**
29
+ * Terminate the run once its captured output has been silent for this long, in
30
+ * milliseconds. A run stopped this way reports `timedOut`; without a bound, a
31
+ * child that stops progressing without exiting holds its caller forever. A run
32
+ * with inherited descriptors captures nothing and is never bound.
33
+ */
34
+ idleTimeoutMs?: number;
35
+ /** Bound on the pre-install registry lookup, in milliseconds; without one a fixed bound applies. */
36
+ lookupTimeoutMs?: number;
27
37
  }
28
38
  /** Resolve relative package specs against the caller's directory.
29
39
  * @param argument One pnpm argument.
@@ -44,10 +54,14 @@ export declare function bundleManifest(name: string, dir: string, anchor: string
44
54
  */
45
55
  export declare function saveManifest(dir: string, manifest: ProfileManifest): Promise<void>;
46
56
  /** Execute pnpm inside a profile whose caller already holds the profile write lock.
57
+ * Newly installed or updated direct dependencies are checked even when activation is disabled;
58
+ * an untouched dependency never blocks an unrelated operation and stays denied at startup.
59
+ * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk.
47
60
  * @param context Launcher-owned profile and resolution locations.
48
61
  * @param args Pnpm arguments, before relative path anchoring.
49
62
  * @param options Output, activation and cancellation policy.
50
- * @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
63
+ * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
64
+ * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
51
65
  */
52
66
  export declare function runProfilePnpm(context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions): Promise<PackageResult>;
53
67
  /** Initialize and run the dsh plugin command with the same write lock as the service.