dsh-plugin-shop 0.8.3 → 0.8.4-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,10 +12,13 @@ import { type RepoPinFs } from './repo-pins.ts';
12
12
  import { type InstallState } from '../shared/install-state.ts';
13
13
  import { type Prefetcher } from './prefetch.ts';
14
14
  import { type PeerResolver, type PeerVersionResolver } from './peers.ts';
15
+ import { type HarnessVerdict } from './compatibility.ts';
16
+ import { type RunningHarness } from './harness.ts';
15
17
  export type { InstallArgs, InstallRejectionCode } from './install.ts';
16
18
  export type { HotRestartReason } from './hot.ts';
17
19
  export type { Activation } from './activation.ts';
18
20
  export type { CatalogEntry } from './types.ts';
21
+ export type { HarnessVerdict } from './compatibility.ts';
19
22
  /** One Loader inventory entry, structurally — the shop never depends on
20
23
  * cordis-plugin-loader, whose types do not reach this package's typecheck. */
21
24
  export interface InventoryEntry {
@@ -75,8 +78,25 @@ export interface ShopGatewayOptions {
75
78
  * defaults to the real `process.argv` minus node and the script path. */
76
79
  restartArgv?: string[];
77
80
  /** The JS entry `shop/restart` re-runs; defaults to `process.argv[1]`, the
78
- * script this dsh was started with. */
81
+ * script this dsh was started with. It is also what identifies the running
82
+ * harness for the `dsh.compatibility` verdict: the package that owns this
83
+ * script, through symlinks, is the dsh actually running, and its own
84
+ * app-boot supplies the profile templates (`harness.ts`). A script owned by
85
+ * anything else — a test runner, another host — leaves both halves of that
86
+ * verdict silent. The tests about where the running version comes from pass
87
+ * a fixture install here, which is the production path; the rest inject
88
+ * `readHarness`. */
79
89
  restartScript?: string;
90
+ /** Test-only injection: how the running harness is read from
91
+ * `restartScript`; production uses `readRunningHarness`. It exists because
92
+ * an in-process test cannot import a module from another Windows drive
93
+ * under vitest's module runner, and a Windows runner has the checkout on D:
94
+ * and the temp dir, where every fixture harness lives, on C:. The read's
95
+ * import of the fixture's app-boot fails there, and the table reads empty.
96
+ * Production passes nothing, so the running dsh is read as before. Read
97
+ * once and kept either way (see `harnessRead`), so like the default it
98
+ * must never reject. */
99
+ readHarness?: (script: string | undefined) => Promise<RunningHarness>;
80
100
  /** Test-only injection: the exit the restart calls after the response is
81
101
  * delivered. Production uses `process.exit`. */
82
102
  exit?: (code?: number) => void;
@@ -87,6 +107,9 @@ export interface ShopGatewayOptions {
87
107
  /** Test-only injection: the shop's latest-version lookup; production
88
108
  * fetches the npm packument. */
89
109
  fetchLatestVersion?: () => Promise<string | null>;
110
+ /** Test-only injection: the version of the code this process loaded;
111
+ * production reads it once, when own-version.ts is first evaluated. */
112
+ runningVersion?: string;
90
113
  /** How long the gateway waits after a successful restart response before
91
114
  * exiting the old process; test-only shortening, production uses 2s. */
92
115
  restartExitDelayMs?: number;
@@ -119,6 +142,11 @@ export interface ShopGatewayOptions {
119
142
  * — a live registry request — from every install that finds a command
120
143
  * already queued for its profile. */
121
144
  prefetcher?: Prefetcher;
145
+ /** Test-only injection: the record of packages this process may already
146
+ * have imported. Production shares one module-scope set across every
147
+ * gateway in the process (see `importedThisProcess`), which a test file
148
+ * building many gateways would otherwise share as well. */
149
+ importedModules?: Set<string>;
122
150
  }
123
151
  /** `shop/installStart` result (§7.3): rejections are typed wire values with an
124
152
  * author-readable `detail`, not thrown RPC errors. */
@@ -183,12 +211,14 @@ export type ShopRestartResult = RestartOutcome;
183
211
  * restart was impossible after it had become possible. Static here, dynamic
184
212
  * there; the split is the whole reason this is a separate predicate rather
185
213
  * than a cache of `restart()`'s answer. */
186
- export type RestartBlockedReason = 'windows' | 'systemd' | 'port-zero';
214
+ export type RestartBlockedReason = 'desktop' | 'windows' | 'systemd' | 'port-zero';
187
215
  /** `shop/version` result (§7.3): the RUNNING shop version (from the shipped
188
216
  * package.json, not the manifest's range), the npm latest when the check
189
217
  * could answer (`null` = no answer — advisory, never an error), and the
190
218
  * comparison verdict. */
191
219
  export interface ShopVersionResult {
220
+ /** The version of the code this process is running, read when it loaded.
221
+ * Not the file on disk now: a self-update rewrites that in place. */
192
222
  installed: string;
193
223
  latest: string | null;
194
224
  outdated: boolean;
@@ -196,6 +226,17 @@ export interface ShopVersionResult {
196
226
  * its way. The client drops the restart offer on a reason and renders that
197
227
  * reason's own copy, keeping the pending-change notice either way. */
198
228
  restartBlocked: RestartBlockedReason | null;
229
+ /**
230
+ * The version the profile has installed when it is not the one running —
231
+ * an update that landed and waits for a restart — or null when the running
232
+ * code is what is installed, or the installed copy could not be read.
233
+ *
234
+ * This host always sends it. Absent, it comes from a host older than the
235
+ * field, and the client reads that as a restart still pending: a client
236
+ * newer than its host exists only because the disk holds a newer shop than
237
+ * the process running (design §7.3, 2026-09-27 amendment).
238
+ */
239
+ pendingVersion?: string | null;
199
240
  }
200
241
  /** `shop/updateStart` result (§7.3): the self-update spawn, or a typed
201
242
  * refusal (a version that is not plain semver). */
@@ -249,16 +290,44 @@ export interface ShopCatalogResult {
249
290
  * or the sidecar could not be fetched/verified (§5). */
250
291
  stars: Record<string, number>;
251
292
  /** Install identity (`npm:<name>` / `github:<repo>#<subdir>`) → the declared
252
- * peers this installation does not provide. A key is absent when the plugin
253
- * runs here or when no verdict could be formed; same-named entries stay
254
- * independent. */
293
+ * peers node resolution cannot find from the profile. This is the HOST half
294
+ * of the verdict only: a module the browser's module table serves — a
295
+ * platform seed word such as `react` — has no package on disk and is still
296
+ * listed here, so the client removes every name its live module table
297
+ * provides before anything renders (`client/module-table.ts`), and renders
298
+ * no peer verdict at all on a page that offers no usable table. A key is
299
+ * absent when the plugin runs here or when no verdict could be formed;
300
+ * same-named entries stay independent. */
255
301
  incompatible: Record<string, string[]>;
302
+ /** Install identity → what the entry's author declared in
303
+ * `dsh.compatibility` that this installation does not meet (design
304
+ * 2026-09-01-harness-compatibility §8.2), and what the running dsh itself
305
+ * will refuse the install on (`peers`, from dsh 0.1.7; design
306
+ * 2026-09-26-dsh-017-readiness, B1). A key is absent when nothing was
307
+ * declared or every declared half is met, and dsh refuses nothing. A half that cannot be judged —
308
+ * the running version unreadable, a range semver cannot parse — is left out
309
+ * while the other is still judged, so an unknown never reads as an
310
+ * accusation; same-named entries stay independent. A process not started
311
+ * by dsh's CLI judges neither half: nothing then says which harness runs
312
+ * (`harness.ts`). */
313
+ incompatibleHarness: Record<string, HarnessVerdict>;
256
314
  }
257
315
  /** Remote-only service exposing the shop Remote methods of §7.3.
258
316
  *
259
317
  * @typert service shop */
260
318
  export declare class ShopGateway extends TypertRemoteService {
261
319
  private readonly options;
320
+ /** The context this gateway was built with: the shop's own fiber. Inside an
321
+ * RPC method `this.ctx` is NOT that — cordis hands a service out with `ctx`
322
+ * rebound to the context that looked it up, so a call arriving over the
323
+ * wire sees the caller's, the typert gateway's (measured on 0.1.5-rc.3 and
324
+ * 0.1.7-rc.2). Anything whose PARENT matters is registered from here. The
325
+ * hot tree above all: registered from the gateway's context it was owned by
326
+ * the gateway's fiber rather than the shop's, was listed under the gateway's
327
+ * loader entry, and — the part a reader saw — sat where dsh's client
328
+ * registry never composed it, so a hot-installed browser half needed a
329
+ * restart that a reload now does (design 2026-09-26-dsh-017-readiness). */
330
+ private readonly home;
262
331
  /** The profile dsh installs into; discovered from this module's own
263
332
  * location when the caller does not supply one. */
264
333
  private readonly profile;
@@ -278,6 +347,11 @@ export declare class ShopGateway extends TypertRemoteService {
278
347
  private readonly exit;
279
348
  private readonly restartExitDelayMs;
280
349
  private readonly restartParentPid;
350
+ /** The restart this process committed, once it has: every later
351
+ * `shop/restart` answers with it. A page that lost the first answer to an
352
+ * HMR swap asks again (client/restart-monitor.ts), and inside the exit
353
+ * delay that ask reaches this same process. */
354
+ private committedRestart;
281
355
  private readonly latestVersion;
282
356
  private readonly pinFs;
283
357
  private readonly allowRestart?;
@@ -294,6 +368,11 @@ export declare class ShopGateway extends TypertRemoteService {
294
368
  /** One pump for the whole gateway: batching is per profile and lives inside
295
369
  * it, so a second instance would race the first for the same store. */
296
370
  private readonly prefetcher;
371
+ /** What this process may already have imported (see `importedThisProcess`). */
372
+ private readonly imported;
373
+ /** Routes the output dsh's `pluginManager` streams to the record whose
374
+ * request id it carries (see the constructor). */
375
+ private readonly managerLogs;
297
376
  /** The install gate runs against the last loaded snapshot, never a fresh
298
377
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
299
378
  /** Finished install records retained, so a poll sees the true terminal
@@ -313,17 +392,13 @@ export declare class ShopGateway extends TypertRemoteService {
313
392
  /** The user's own `registry=` from `~/.npmrc`, read at most once per
314
393
  * gateway. A wrapper distinguishes a genuine `null` from an unread value. */
315
394
  private npmRegistryCache;
316
- /** The incompatibility map already computed for `lastSnapshot`, keyed by
317
- * that snapshot's own object identity. Design §3 asks for the verdict
318
- * once per loaded snapshot, not once per RPC call: `loadCatalog` serves
319
- * the same snapshot from its on-disk cache for minutes at a time, so
320
- * without this, reopening the tab within that window would re-walk
321
- * `node_modules` for every distinct peer name again — and Node only
322
- * caches a SUCCESSFUL resolution, so a genuinely missing peer pays a full
323
- * failed walk on every single call. Recomputed only when `catalog()`
324
- * loads a snapshot that is not this exact object. */
325
- private incompatibleCache;
326
- /** Install records, running and finished; a poll finds one here or reports not found. */
395
+ /** The harness this process runs, read at most once per gateway: which dsh
396
+ * is running cannot change while it runs, and the read imports a module. A
397
+ * promise, so concurrent first calls share one read; `readRunningHarness`
398
+ * never rejects, so there is no failure to retry. */
399
+ private harnessRead;
400
+ /** Install records, running and finished, from the CLI and from dsh's
401
+ * `pluginManager` alike; a poll finds one here or reports not found. */
327
402
  private readonly installs;
328
403
  /** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
329
404
  private readonly installOrder;
@@ -347,6 +422,40 @@ export declare class ShopGateway extends TypertRemoteService {
347
422
  * stays lazy so `setEnabled` works in tests via the `profileDir` option
348
423
  * without requiring a real profile above this module. */
349
424
  private profileDirResolved;
425
+ /** Where every "what can this installation's plugins import?" question
426
+ * resolves from: the profile's own Loader root, which is where the harness
427
+ * resolves plugins from. One definition for the peer presence check and the
428
+ * load-time self-check, so neither can drift onto a different notion of
429
+ * "the installation". NOT where the running harness is read from: a copy
430
+ * of `@deepseek-ai/dsh` a plugin's dependencies hoist into the profile is
431
+ * importable from here and is not what runs (`harness.ts`). Throws when no
432
+ * profile directory can be discovered and none is given. */
433
+ private profileAnchor;
434
+ /** dsh's `pluginPackages` service, when the running harness provides one
435
+ * (0.1.7 and later), else null. Read on each use, like `pluginInventory`.
436
+ * It is how such a harness serves its own packages to plugins, which no
437
+ * walk of the profile's disk can see (design
438
+ * 2026-09-01-harness-compatibility §11). */
439
+ private harnessPackages;
440
+ /** dsh's `pluginManager` service, when the running harness provides all of
441
+ * it (0.1.7 and later), else null. Never cached across calls, like
442
+ * `pluginPackages`, but read once per RPC, at its start, and passed down:
443
+ * read again after an await, a service disposed in between would let a
444
+ * desktop mutation past the desktop gate and onto the CLI path, which dsh
445
+ * refuses for that profile. Design 2026-09-26-plugin-manager-delegation,
446
+ * section 3.1. */
447
+ private pluginManager;
448
+ /** "Does this installation provide `spec`?", asked from `anchor`: the
449
+ * harness's own resolution in front of the walk where the harness has one,
450
+ * the walk alone where it has not. */
451
+ private peerResolver;
452
+ /** `peerResolver`'s twin for the versions the load-time self-check reads. */
453
+ private peerVersionResolver;
454
+ /** `profileDirResolved`, or null when no profile directory can be
455
+ * discovered — for the reads that answer "cannot tell" rather than fail. */
456
+ private profileDirOrNone;
457
+ /** The harness this process runs, read once (see `harnessRead`). */
458
+ private runningHarness;
350
459
  /** The inventory, through the wire remote: an envelope `{ ok, value }` or
351
460
  * `{ ok: false, error }`. Each row is re-validated before trust (same
352
461
  * discipline as the rowConfig cast). */
@@ -358,8 +467,9 @@ export declare class ShopGateway extends TypertRemoteService {
358
467
  * down. A disable can land while the entry's init is still in flight: the
359
468
  * options flip but the finishing init brings the fiber up anyway, and a
360
469
  * plain re-update no-ops on the empty diff (dsh-market themes.ts:74-93).
361
- * For an update swap this sequencing is mandatory, not defensive: two live
362
- * instances of a service-providing plugin would collide at provision. */
470
+ * The uninstall path is the one caller: an update used to swap its running
471
+ * instance for a hot mount through here, and no longer mounts at all
472
+ * (design 2026-09-26-market-borrowings §1). */
363
473
  /** The package's owned entry ids, or none when its bundle patch cannot be
364
474
  * read. For the paths where a live disable is an optimization and the
365
475
  * operation must succeed regardless; `setEnabled` reports the failure
@@ -387,10 +497,15 @@ export declare class ShopGateway extends TypertRemoteService {
387
497
  * Through `ownDependencySpec` for the reason spelled out there: a bare index
388
498
  * read answers for `Object.prototype`, and `constructor` is a legal npm
389
499
  * name. The cost on this path is behavioural twice over — the gate would
390
- * weigh a function as the installed spec, and a phantom `isUpdate` would run
391
- * `liveDisableIds` against something absent.
500
+ * weigh a function as the installed spec, and a phantom `isUpdate` would
501
+ * refuse a hot mount to a package this process never imported.
392
502
  */
393
503
  private installedSpecOf;
504
+ /** Add every dependency the profile manifest holds to the `imported`
505
+ * record (see `importedThisProcess`): each is a package dsh's loader may
506
+ * have loaded in this process, at boot or by an install since. An
507
+ * unreadable manifest adds nothing. */
508
+ private recordDependenciesImported;
394
509
  private ownedEntryIdsOrNone;
395
510
  /** Whether an installed package declares `dsh.client`. Reads through the
396
511
  * `hotFs` option — `HotFs` is `hot.ts`'s type, but this gateway forwards
@@ -416,15 +531,50 @@ export declare class ShopGateway extends TypertRemoteService {
416
531
  * the loop was computing.
417
532
  */
418
533
  private liveEntriesDown;
419
- /** Enable or disable one installed plugin, hot (§8): a disable writes the
420
- * row to the user layer, an enable drops it again so the bundle default
421
- * rules — the CLI's watchUserPatches applies either through HMR. The shop's
422
- * own row and the framework's bundles are never toggleable: disabling the
423
- * host chain would break HMR itself. */
534
+ /** Enable or disable one installed plugin, hot (§8): the write sets the
535
+ * `disabled` key of each owned entry's row in the user layer and changes
536
+ * nothing else there (design 2026-09-26-market-borrowings §2) — the CLI's
537
+ * watchUserPatches applies it through HMR. The shop's own row and the
538
+ * framework's bundles are never toggleable: disabling the host chain would
539
+ * break HMR itself. */
424
540
  setEnabled(args: {
425
541
  name: string;
426
542
  enabled: boolean;
427
543
  }): Promise<ShopSetEnabledResult>;
544
+ /**
545
+ * `setEnabled` for an installed package whose bundle the profile does not
546
+ * select.
547
+ *
548
+ * Switching it off writes the plugin-level rows and nothing else: none of
549
+ * it runs, and the rows keep it off if dsh selects the bundle again.
550
+ * Switching it on selects the bundle through dsh's own `pluginManager`, the
551
+ * writer behind the switch that deselected it, and then clears the
552
+ * plugin-level rows as an ordinary enable does. A harness without that
553
+ * service gets the way back in words instead. `dsh plugin add <name>` is
554
+ * not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
555
+ * float a registry package to `latest`.
556
+ */
557
+ private setDeselectedEnabled;
558
+ /**
559
+ * dsh's answer to a switch or a bundle selection, read the one way both
560
+ * callers need it (R32): the change, scrubbed for a desktop reader, and the
561
+ * detail that fails the request, or null when dsh applied it. `act` is what
562
+ * was asked, as "switch <name>", `request` names the request itself, as
563
+ * "the switch for <name>", and `stays` is the state the plugin keeps when
564
+ * the request does not take effect. An application this shop does not know
565
+ * fails rather than reading as applied: a later harness may answer
566
+ * something new.
567
+ *
568
+ * `overridden` fails too (R39). dsh answers it when, after it wrote the row
569
+ * and reloaded, the live entry still differs from the request
570
+ * (dsh-plugin-manager 0.1.7-rc.2, setPluginEnabled), and its README names
571
+ * what outranks the profile's own patch: home and invocation patches keep
572
+ * their higher priority. The change is saved and the plugin runs as it
573
+ * did, so a success would flip the client's switch over a plugin that did
574
+ * not change, and that switch never re-reads the live state.
575
+ * setBundleEnabled never answers it; a selection reads it the same way.
576
+ */
577
+ private readManagerAnswer;
428
578
  private rowConfig;
429
579
  /** The user's own registry, read once. The self-update check and catalog
430
580
  * race share this preference so neither path repeatedly touches ~/.npmrc. */
@@ -447,11 +597,16 @@ export declare class ShopGateway extends TypertRemoteService {
447
597
  * static does. One ordered list, read by `restart()` before it commits and
448
598
  * by `version()` so the client can say the same thing up front.
449
599
  *
450
- * The order is the order the refusals were written in and is load-bearing
451
- * for the copy a reader sees: Windows first, because the platform check has
452
- * no override and reporting the systemd one there sends a Windows user to
453
- * set `allowRestart: true`, which this gate would still refuse.
600
+ * The order is load-bearing for the copy a reader sees. The desktop
601
+ * profile first: dsh itself refuses to launch it, so no platform,
602
+ * supervisor or port could make a restart possible there. Then Windows,
603
+ * because the platform check has no override and reporting the systemd one
604
+ * there sends a Windows user to set `allowRestart: true`, which this gate
605
+ * would still refuse.
454
606
  *
607
+ * - `desktop`: dsh's CLI refuses `--profile desktop` ("managed exclusively
608
+ * by the Electron application"), so the relaunch would exit at once and
609
+ * leave nothing on the port; the app restarts it.
455
610
  * - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
456
611
  * and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
457
612
  * committing would answer `ok: true`, exit this process, and leave nothing
@@ -468,12 +623,72 @@ export declare class ShopGateway extends TypertRemoteService {
468
623
  catalog(args?: {
469
624
  refresh?: boolean;
470
625
  }): Promise<ShopCatalogResult>;
626
+ /**
627
+ * The installs the running dsh will refuse on their harness peers
628
+ * (`peerVerdictsOf`), judged against the exemptions this profile holds —
629
+ * read on every call like the other verdicts, because recording one is
630
+ * exactly the event that must clear a card: the reader runs the command
631
+ * the card shows, presses Refresh, and the install is allowed. Empty when
632
+ * the running dsh has no such check (before 0.1.7) or no profile was
633
+ * found, and when either read throws: a refusal nobody could establish is
634
+ * never shown, least of all as a disabled button.
635
+ */
636
+ private refusedByHarness;
637
+ /**
638
+ * The bundles the profile at `profileDir` composes, or null when its
639
+ * manifest cannot be read — which is no verdict on the profile half rather
640
+ * than an accusation. `dsh.profile.bundles` is the same list
641
+ * `discoverProfile` uses to recognize a profile directory, read through the
642
+ * harness's own parser so a profile this shop was booted inside is read the
643
+ * way dsh reads it.
644
+ */
645
+ private runningProfileBundles;
471
646
  /**
472
647
  * Install one cataloged version into the profile (§7.2). The rejection
473
648
  * paths run against this Host's snapshot before anything is spawned; only a
474
649
  * passing request reaches the executor.
475
650
  */
476
651
  install(args: InstallArgs): Promise<ShopInstallResult>;
652
+ /**
653
+ * What would stop the profile from starting now that `name`'s files are on
654
+ * disk, or null: an entry id another installed package already declares,
655
+ * or a bundle patch declaration this dsh cannot read. Neither is knowable
656
+ * before the files land. The package stays on disk either way, so each
657
+ * detail ends with `undo`, the sentence that says how to take the install
658
+ * back on the path that made it.
659
+ */
660
+ private postInstallHazard;
661
+ /** Remember a github entry's pinned commit: the manifest records only
662
+ * `github:owner/slug`, so the pins file is how `installed()` reports
663
+ * outdated honestly. A failed install leaves a pin behind, but the
664
+ * manifest presence gate keeps it invisible. */
665
+ private recordGithubPin;
666
+ /** Forget an uninstalled package's pins: its own, and its identity pin when
667
+ * a catalog row matched the installed spec. Both uninstall paths call this
668
+ * once their operation has started, so a stale pin never outlives the
669
+ * uninstall in the shop's cache either way. The plugin manager path tracks
670
+ * its record first, so a write here that throws cannot hide an operation
671
+ * already queued. */
672
+ private forgetPins;
673
+ /** What `managerOutcome` reads dsh's answer against. The gateway's own
674
+ * facts are set here once, for every operation through the service: the
675
+ * profile, whether it is the desktop one, and the deadline a timeout
676
+ * detail names. */
677
+ private outcomeContext;
678
+ /**
679
+ * One install-shaped operation through dsh's `pluginManager` (design
680
+ * 2026-09-26-plugin-manager-delegation, section 6): `installBundle(spec)`
681
+ * under the record's own id as its request id, so the output dsh streams
682
+ * for it reaches this record, in the profile's queue with the download
683
+ * phase the CLI path has. At the shop's deadline dsh is asked to cancel it;
684
+ * a service that offers no `cancelInstall` gets no deadline, and its record
685
+ * settles when dsh answers. `install` and `updateStart` differ only in the
686
+ * mechanism line and in how they read the answer.
687
+ */
688
+ private startManagerInstall;
689
+ /** Keep a started record where `installStatus`, the restart gate and
690
+ * eviction find it, then bound the finished ones. */
691
+ private track;
477
692
  /** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
478
693
  * oldest finished ones (insertion order, oldest first). Live records —
479
694
  * running AND queued — are never evicted; an id absent from the map reports
@@ -534,6 +749,21 @@ export declare class ShopGateway extends TypertRemoteService {
534
749
  * and the client shows the version alone. `installed` is the RUNNING
535
750
  * version (own-version.ts), not the manifest's range spec. */
536
751
  version(): Promise<ShopVersionResult>;
752
+ /**
753
+ * The version of the shop the profile has installed, when it is not the
754
+ * one this process runs; null when they match or the copy cannot be read.
755
+ *
756
+ * Read from the profile's own copy — the directory `dsh plugin add` writes
757
+ * and the next boot loads — rather than from the file beside this module.
758
+ * For the hoisted install those are one directory, but a `link:` install
759
+ * runs from its link target, which an update replaces in the profile and
760
+ * never touches at the target, so reading beside the module would never see
761
+ * the update land.
762
+ *
763
+ * Unreadable is null, never a throw: the version check is advisory, and a
764
+ * missing field would tell the client this host predates it.
765
+ */
766
+ private pendingVersion;
537
767
  /** Update the shop itself to a published version (§7.3): the explicit pin
538
768
  * is the only install form that bypasses pnpm's release cooldown. The
539
769
  * version is re-validated as plain semver at the boundary — the spec
@@ -1,7 +1,7 @@
1
1
  /** Install gate: the gate rejection paths of §7.2, as a pure function. */
2
2
  import type { CatalogSnapshot } from './catalog.ts';
3
3
  import type { CatalogEntry } from './types.ts';
4
- export type InstallRejectionCode = 'not-in-catalog' | 'denied' | 'version-mismatch' | 'needs-acknowledgement' | 'tarball-integrity' | 'ambiguous-identity' | 'name-taken';
4
+ export type InstallRejectionCode = 'not-in-catalog' | 'denied' | 'version-mismatch' | 'needs-acknowledgement' | 'tarball-integrity' | 'ambiguous-identity' | 'name-taken' | 'desktop-profile';
5
5
  export interface InstallArgs {
6
6
  name: string;
7
7
  version: string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * One package operation through dsh's `pluginManager` service, tracked as the
3
+ * same `RunningInstall` the CLI executor produces: design
4
+ * 2026-09-26-plugin-manager-delegation, section 6. It runs in the same
5
+ * per-profile queue as CLI commands, so the two paths are never concurrent in
6
+ * one profile.
7
+ */
8
+ import { type RunningInstall } from './executor.ts';
9
+ import { type ManagerOutcome } from './plugin-manager.ts';
10
+ import type { Prefetcher } from './prefetch.ts';
11
+ /** The first line of every record this runner writes. The shop's log panel
12
+ * shows it and the 0.1.7 e2e asserts it, so a silent fall back to the CLI
13
+ * fails a case instead of passing it. */
14
+ export declare const MECHANISM_PREFIX = "via dsh's plugin manager:";
15
+ /** Routes the service's `plugin-manager/install-log` chunks to the record
16
+ * whose request id they carry. A chunk for any other id is dropped. dsh
17
+ * tags each chunk with the stream it came from; a non-string stream reads
18
+ * as `stdout`, matching a harness that predates the tag. */
19
+ export declare class ManagerLogs {
20
+ private readonly sinks;
21
+ open(requestId: string, sink: (text: string, stream: string) => void): () => void;
22
+ chunk(requestId: unknown, text: unknown, stream?: unknown): void;
23
+ }
24
+ export interface ManagerOperationOptions {
25
+ profile: string;
26
+ /** The record's id, handed to the service as its request id. */
27
+ requestId: string;
28
+ /** What the mechanism line says after the prefix, e.g. `install dsh-a@1.0.0`. */
29
+ mechanism: string;
30
+ logs: ManagerLogs;
31
+ run: (requestId: string) => Promise<unknown>;
32
+ cancel?: (requestId: string) => Promise<unknown>;
33
+ outcome: (raw: unknown) => ManagerOutcome;
34
+ alsoConfirm?: () => string | null;
35
+ /** The download phase, as on the CLI path: `spec` is what it warms. */
36
+ prefetcher?: Prefetcher;
37
+ spec?: string;
38
+ env?: NodeJS.ProcessEnv;
39
+ timeoutMs?: number;
40
+ /** The desktop profile, whose readers can run no `dsh plugin` command: a
41
+ * message the service call throws is scrubbed as dsh's answer is. */
42
+ desktop?: boolean;
43
+ }
44
+ /** What a thrown value says about itself, and total: describing an error is
45
+ * never itself the reason a settle fails. An `Error` gives its `message`,
46
+ * anything else `String(value)`. A message that is not a string, or a
47
+ * description that throws anywhere, reads as one fixed string: a Proxy whose
48
+ * getPrototypeOf trap throws defeats `instanceof` itself, a `message` getter
49
+ * can throw, and a null-prototype object has no conversion to a string. */
50
+ export declare const messageOf: (error: unknown) => string;
51
+ export declare function startManagerOperation(options: ManagerOperationOptions): RunningInstall;