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

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 {
@@ -36,6 +39,21 @@ export interface LoaderEntryLike {
36
39
  disabled: boolean | null;
37
40
  }, create?: boolean, force?: boolean): Promise<void>;
38
41
  }
42
+ /** The slice of dsh 0.1.7's `pluginManager` service this gateway calls,
43
+ * structurally: the build compiles against the 0.1.1-rc.2 harness floor,
44
+ * where the package does not exist. `setBundleEnabled` is what dsh's own
45
+ * Plugins page calls for its bundle switch, and it answers with the saved
46
+ * change and how it applied (design 2026-09-26-plugin-manager-delegation,
47
+ * section 2). */
48
+ export interface BundleSelector {
49
+ setBundleEnabled(name: string, enabled: boolean): Promise<{
50
+ application?: unknown;
51
+ error?: {
52
+ code?: unknown;
53
+ diagnostic?: unknown;
54
+ };
55
+ }>;
56
+ }
39
57
  /** Test-only injection points; production callers pass nothing. */
40
58
  export interface ShopGatewayOptions {
41
59
  catalogUrl?: string;
@@ -75,8 +93,25 @@ export interface ShopGatewayOptions {
75
93
  * defaults to the real `process.argv` minus node and the script path. */
76
94
  restartArgv?: string[];
77
95
  /** The JS entry `shop/restart` re-runs; defaults to `process.argv[1]`, the
78
- * script this dsh was started with. */
96
+ * script this dsh was started with. It is also what identifies the running
97
+ * harness for the `dsh.compatibility` verdict: the package that owns this
98
+ * script, through symlinks, is the dsh actually running, and its own
99
+ * app-boot supplies the profile templates (`harness.ts`). A script owned by
100
+ * anything else — a test runner, another host — leaves both halves of that
101
+ * verdict silent. The tests about where the running version comes from pass
102
+ * a fixture install here, which is the production path; the rest inject
103
+ * `readHarness`. */
79
104
  restartScript?: string;
105
+ /** Test-only injection: how the running harness is read from
106
+ * `restartScript`; production uses `readRunningHarness`. It exists because
107
+ * an in-process test cannot import a module from another Windows drive
108
+ * under vitest's module runner, and a Windows runner has the checkout on D:
109
+ * and the temp dir, where every fixture harness lives, on C:. The read's
110
+ * import of the fixture's app-boot fails there, and the table reads empty.
111
+ * Production passes nothing, so the running dsh is read as before. Read
112
+ * once and kept either way (see `harnessRead`), so like the default it
113
+ * must never reject. */
114
+ readHarness?: (script: string | undefined) => Promise<RunningHarness>;
80
115
  /** Test-only injection: the exit the restart calls after the response is
81
116
  * delivered. Production uses `process.exit`. */
82
117
  exit?: (code?: number) => void;
@@ -87,6 +122,9 @@ export interface ShopGatewayOptions {
87
122
  /** Test-only injection: the shop's latest-version lookup; production
88
123
  * fetches the npm packument. */
89
124
  fetchLatestVersion?: () => Promise<string | null>;
125
+ /** Test-only injection: the version of the code this process loaded;
126
+ * production reads it once, when own-version.ts is first evaluated. */
127
+ runningVersion?: string;
90
128
  /** How long the gateway waits after a successful restart response before
91
129
  * exiting the old process; test-only shortening, production uses 2s. */
92
130
  restartExitDelayMs?: number;
@@ -119,6 +157,11 @@ export interface ShopGatewayOptions {
119
157
  * — a live registry request — from every install that finds a command
120
158
  * already queued for its profile. */
121
159
  prefetcher?: Prefetcher;
160
+ /** Test-only injection: the record of packages this process may already
161
+ * have imported. Production shares one module-scope set across every
162
+ * gateway in the process (see `importedThisProcess`), which a test file
163
+ * building many gateways would otherwise share as well. */
164
+ importedModules?: Set<string>;
122
165
  }
123
166
  /** `shop/installStart` result (§7.3): rejections are typed wire values with an
124
167
  * author-readable `detail`, not thrown RPC errors. */
@@ -183,12 +226,14 @@ export type ShopRestartResult = RestartOutcome;
183
226
  * restart was impossible after it had become possible. Static here, dynamic
184
227
  * there; the split is the whole reason this is a separate predicate rather
185
228
  * than a cache of `restart()`'s answer. */
186
- export type RestartBlockedReason = 'windows' | 'systemd' | 'port-zero';
229
+ export type RestartBlockedReason = 'desktop' | 'windows' | 'systemd' | 'port-zero';
187
230
  /** `shop/version` result (§7.3): the RUNNING shop version (from the shipped
188
231
  * package.json, not the manifest's range), the npm latest when the check
189
232
  * could answer (`null` = no answer — advisory, never an error), and the
190
233
  * comparison verdict. */
191
234
  export interface ShopVersionResult {
235
+ /** The version of the code this process is running, read when it loaded.
236
+ * Not the file on disk now: a self-update rewrites that in place. */
192
237
  installed: string;
193
238
  latest: string | null;
194
239
  outdated: boolean;
@@ -196,6 +241,17 @@ export interface ShopVersionResult {
196
241
  * its way. The client drops the restart offer on a reason and renders that
197
242
  * reason's own copy, keeping the pending-change notice either way. */
198
243
  restartBlocked: RestartBlockedReason | null;
244
+ /**
245
+ * The version the profile has installed when it is not the one running —
246
+ * an update that landed and waits for a restart — or null when the running
247
+ * code is what is installed, or the installed copy could not be read.
248
+ *
249
+ * This host always sends it. Absent, it comes from a host older than the
250
+ * field, and the client reads that as a restart still pending: a client
251
+ * newer than its host exists only because the disk holds a newer shop than
252
+ * the process running (design §7.3, 2026-09-27 amendment).
253
+ */
254
+ pendingVersion?: string | null;
199
255
  }
200
256
  /** `shop/updateStart` result (§7.3): the self-update spawn, or a typed
201
257
  * refusal (a version that is not plain semver). */
@@ -249,16 +305,44 @@ export interface ShopCatalogResult {
249
305
  * or the sidecar could not be fetched/verified (§5). */
250
306
  stars: Record<string, number>;
251
307
  /** 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. */
308
+ * peers node resolution cannot find from the profile. This is the HOST half
309
+ * of the verdict only: a module the browser's module table serves — a
310
+ * platform seed word such as `react` — has no package on disk and is still
311
+ * listed here, so the client removes every name its live module table
312
+ * provides before anything renders (`client/module-table.ts`), and renders
313
+ * no peer verdict at all on a page that offers no usable table. A key is
314
+ * absent when the plugin runs here or when no verdict could be formed;
315
+ * same-named entries stay independent. */
255
316
  incompatible: Record<string, string[]>;
317
+ /** Install identity → what the entry's author declared in
318
+ * `dsh.compatibility` that this installation does not meet (design
319
+ * 2026-09-01-harness-compatibility §8.2), and what the running dsh itself
320
+ * will refuse the install on (`peers`, from dsh 0.1.7; design
321
+ * 2026-09-26-dsh-017-readiness, B1). A key is absent when nothing was
322
+ * declared or every declared half is met, and dsh refuses nothing. A half that cannot be judged —
323
+ * the running version unreadable, a range semver cannot parse — is left out
324
+ * while the other is still judged, so an unknown never reads as an
325
+ * accusation; same-named entries stay independent. A process not started
326
+ * by dsh's CLI judges neither half: nothing then says which harness runs
327
+ * (`harness.ts`). */
328
+ incompatibleHarness: Record<string, HarnessVerdict>;
256
329
  }
257
330
  /** Remote-only service exposing the shop Remote methods of §7.3.
258
331
  *
259
332
  * @typert service shop */
260
333
  export declare class ShopGateway extends TypertRemoteService {
261
334
  private readonly options;
335
+ /** The context this gateway was built with: the shop's own fiber. Inside an
336
+ * RPC method `this.ctx` is NOT that — cordis hands a service out with `ctx`
337
+ * rebound to the context that looked it up, so a call arriving over the
338
+ * wire sees the caller's, the typert gateway's (measured on 0.1.5-rc.3 and
339
+ * 0.1.7-rc.2). Anything whose PARENT matters is registered from here. The
340
+ * hot tree above all: registered from the gateway's context it was owned by
341
+ * the gateway's fiber rather than the shop's, was listed under the gateway's
342
+ * loader entry, and — the part a reader saw — sat where dsh's client
343
+ * registry never composed it, so a hot-installed browser half needed a
344
+ * restart that a reload now does (design 2026-09-26-dsh-017-readiness). */
345
+ private readonly home;
262
346
  /** The profile dsh installs into; discovered from this module's own
263
347
  * location when the caller does not supply one. */
264
348
  private readonly profile;
@@ -294,6 +378,8 @@ export declare class ShopGateway extends TypertRemoteService {
294
378
  /** One pump for the whole gateway: batching is per profile and lives inside
295
379
  * it, so a second instance would race the first for the same store. */
296
380
  private readonly prefetcher;
381
+ /** What this process may already have imported (see `importedThisProcess`). */
382
+ private readonly imported;
297
383
  /** The install gate runs against the last loaded snapshot, never a fresh
298
384
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
299
385
  /** Finished install records retained, so a poll sees the true terminal
@@ -313,16 +399,11 @@ export declare class ShopGateway extends TypertRemoteService {
313
399
  /** The user's own `registry=` from `~/.npmrc`, read at most once per
314
400
  * gateway. A wrapper distinguishes a genuine `null` from an unread value. */
315
401
  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;
402
+ /** The harness this process runs, read at most once per gateway: which dsh
403
+ * is running cannot change while it runs, and the read imports a module. A
404
+ * promise, so concurrent first calls share one read; `readRunningHarness`
405
+ * never rejects, so there is no failure to retry. */
406
+ private harnessRead;
326
407
  /** Install records, running and finished; a poll finds one here or reports not found. */
327
408
  private readonly installs;
328
409
  /** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
@@ -347,6 +428,38 @@ export declare class ShopGateway extends TypertRemoteService {
347
428
  * stays lazy so `setEnabled` works in tests via the `profileDir` option
348
429
  * without requiring a real profile above this module. */
349
430
  private profileDirResolved;
431
+ /** Where every "what can this installation's plugins import?" question
432
+ * resolves from: the profile's own Loader root, which is where the harness
433
+ * resolves plugins from. One definition for the peer presence check and the
434
+ * load-time self-check, so neither can drift onto a different notion of
435
+ * "the installation". NOT where the running harness is read from: a copy
436
+ * of `@deepseek-ai/dsh` a plugin's dependencies hoist into the profile is
437
+ * importable from here and is not what runs (`harness.ts`). Throws when no
438
+ * profile directory can be discovered and none is given. */
439
+ private profileAnchor;
440
+ /** dsh's `pluginPackages` service, when the running harness provides one
441
+ * (0.1.7 and later), else null. Read on each use, like `pluginInventory`.
442
+ * It is how such a harness serves its own packages to plugins, which no
443
+ * walk of the profile's disk can see (design
444
+ * 2026-09-01-harness-compatibility §11). */
445
+ private harnessPackages;
446
+ /** dsh's `pluginManager` service, when the running harness provides one
447
+ * (0.1.7 and later), else null. Read on each use, like `pluginPackages`.
448
+ * It is the only way back for a bundle its Plugins page deselected: the
449
+ * 0.1.7 CLI does not select an installed package again on `add`, while
450
+ * 0.1.5's does (both measured 2026-09-27). */
451
+ private pluginManager;
452
+ /** "Does this installation provide `spec`?", asked from `anchor`: the
453
+ * harness's own resolution in front of the walk where the harness has one,
454
+ * the walk alone where it has not. */
455
+ private peerResolver;
456
+ /** `peerResolver`'s twin for the versions the load-time self-check reads. */
457
+ private peerVersionResolver;
458
+ /** `profileDirResolved`, or null when no profile directory can be
459
+ * discovered — for the reads that answer "cannot tell" rather than fail. */
460
+ private profileDirOrNone;
461
+ /** The harness this process runs, read once (see `harnessRead`). */
462
+ private runningHarness;
350
463
  /** The inventory, through the wire remote: an envelope `{ ok, value }` or
351
464
  * `{ ok: false, error }`. Each row is re-validated before trust (same
352
465
  * discipline as the rowConfig cast). */
@@ -358,8 +471,9 @@ export declare class ShopGateway extends TypertRemoteService {
358
471
  * down. A disable can land while the entry's init is still in flight: the
359
472
  * options flip but the finishing init brings the fiber up anyway, and a
360
473
  * 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. */
474
+ * The uninstall path is the one caller: an update used to swap its running
475
+ * instance for a hot mount through here, and no longer mounts at all
476
+ * (design 2026-09-26-market-borrowings §1). */
363
477
  /** The package's owned entry ids, or none when its bundle patch cannot be
364
478
  * read. For the paths where a live disable is an optimization and the
365
479
  * operation must succeed regardless; `setEnabled` reports the failure
@@ -387,8 +501,8 @@ export declare class ShopGateway extends TypertRemoteService {
387
501
  * Through `ownDependencySpec` for the reason spelled out there: a bare index
388
502
  * read answers for `Object.prototype`, and `constructor` is a legal npm
389
503
  * 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.
504
+ * weigh a function as the installed spec, and a phantom `isUpdate` would
505
+ * refuse a hot mount to a package this process never imported.
392
506
  */
393
507
  private installedSpecOf;
394
508
  private ownedEntryIdsOrNone;
@@ -416,15 +530,30 @@ export declare class ShopGateway extends TypertRemoteService {
416
530
  * the loop was computing.
417
531
  */
418
532
  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. */
533
+ /** Enable or disable one installed plugin, hot (§8): the write sets the
534
+ * `disabled` key of each owned entry's row in the user layer and changes
535
+ * nothing else there (design 2026-09-26-market-borrowings §2) — the CLI's
536
+ * watchUserPatches applies it through HMR. The shop's own row and the
537
+ * framework's bundles are never toggleable: disabling the host chain would
538
+ * break HMR itself. */
424
539
  setEnabled(args: {
425
540
  name: string;
426
541
  enabled: boolean;
427
542
  }): Promise<ShopSetEnabledResult>;
543
+ /**
544
+ * `setEnabled` for an installed package whose bundle the profile does not
545
+ * select.
546
+ *
547
+ * Switching it off writes the plugin-level rows and nothing else: none of
548
+ * it runs, and the rows keep it off if dsh selects the bundle again.
549
+ * Switching it on selects the bundle through dsh's own `pluginManager`, the
550
+ * writer behind the switch that deselected it, and then clears the
551
+ * plugin-level rows as an ordinary enable does. A harness without that
552
+ * service gets the way back in words instead. `dsh plugin add <name>` is
553
+ * not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
554
+ * float a registry package to `latest`.
555
+ */
556
+ private setDeselectedEnabled;
428
557
  private rowConfig;
429
558
  /** The user's own registry, read once. The self-update check and catalog
430
559
  * race share this preference so neither path repeatedly touches ~/.npmrc. */
@@ -447,11 +576,16 @@ export declare class ShopGateway extends TypertRemoteService {
447
576
  * static does. One ordered list, read by `restart()` before it commits and
448
577
  * by `version()` so the client can say the same thing up front.
449
578
  *
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.
579
+ * The order is load-bearing for the copy a reader sees. The desktop
580
+ * profile first: dsh itself refuses to launch it, so no platform,
581
+ * supervisor or port could make a restart possible there. Then Windows,
582
+ * because the platform check has no override and reporting the systemd one
583
+ * there sends a Windows user to set `allowRestart: true`, which this gate
584
+ * would still refuse.
454
585
  *
586
+ * - `desktop`: dsh's CLI refuses `--profile desktop` ("managed exclusively
587
+ * by the Electron application"), so the relaunch would exit at once and
588
+ * leave nothing on the port; the app restarts it.
455
589
  * - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
456
590
  * and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
457
591
  * committing would answer `ok: true`, exit this process, and leave nothing
@@ -468,6 +602,26 @@ export declare class ShopGateway extends TypertRemoteService {
468
602
  catalog(args?: {
469
603
  refresh?: boolean;
470
604
  }): Promise<ShopCatalogResult>;
605
+ /**
606
+ * The installs the running dsh will refuse on their harness peers
607
+ * (`peerVerdictsOf`), judged against the exemptions this profile holds —
608
+ * read on every call like the other verdicts, because recording one is
609
+ * exactly the event that must clear a card: the reader runs the command
610
+ * the card shows, presses Refresh, and the install is allowed. Empty when
611
+ * the running dsh has no such check (before 0.1.7) or no profile was
612
+ * found, and when either read throws: a refusal nobody could establish is
613
+ * never shown, least of all as a disabled button.
614
+ */
615
+ private refusedByHarness;
616
+ /**
617
+ * The bundles the profile at `profileDir` composes, or null when its
618
+ * manifest cannot be read — which is no verdict on the profile half rather
619
+ * than an accusation. `dsh.profile.bundles` is the same list
620
+ * `discoverProfile` uses to recognize a profile directory, read through the
621
+ * harness's own parser so a profile this shop was booted inside is read the
622
+ * way dsh reads it.
623
+ */
624
+ private runningProfileBundles;
471
625
  /**
472
626
  * Install one cataloged version into the profile (§7.2). The rejection
473
627
  * paths run against this Host's snapshot before anything is spawned; only a
@@ -534,6 +688,21 @@ export declare class ShopGateway extends TypertRemoteService {
534
688
  * and the client shows the version alone. `installed` is the RUNNING
535
689
  * version (own-version.ts), not the manifest's range spec. */
536
690
  version(): Promise<ShopVersionResult>;
691
+ /**
692
+ * The version of the shop the profile has installed, when it is not the
693
+ * one this process runs; null when they match or the copy cannot be read.
694
+ *
695
+ * Read from the profile's own copy — the directory `dsh plugin add` writes
696
+ * and the next boot loads — rather than from the file beside this module.
697
+ * For the hoisted install those are one directory, but a `link:` install
698
+ * runs from its link target, which an update replaces in the profile and
699
+ * never touches at the target, so reading beside the module would never see
700
+ * the update land.
701
+ *
702
+ * Unreadable is null, never a throw: the version check is advisory, and a
703
+ * missing field would tell the client this host predates it.
704
+ */
705
+ private pendingVersion;
537
706
  /** Update the shop itself to a published version (§7.3): the explicit pin
538
707
  * is the only install form that bypasses pnpm's release cooldown. The
539
708
  * 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;
@@ -7,12 +7,151 @@ import { type EntryIdentity } from '../shared/identity.ts';
7
7
  * drive every verdict and exactly one call site touches the filesystem. */
8
8
  export type PeerResolver = (spec: string) => boolean;
9
9
  /**
10
- * The production resolver: the same question the harness's own
11
- * ClientModuleRegistry asks, through a require anchored at the profile. Asking
12
- * what the loader asks is what keeps this verdict and the runtime's behaviour
13
- * from drifting apart.
10
+ * The two filesystem reads the package lookup makes. Injected so fixtures can
11
+ * drive every branch of the walk — including a permission failure, which a
12
+ * real directory cannot stage when the suite runs as root — while
13
+ * `NODE_LOOKUP_FS` below stays the one place this module touches the disk.
14
+ */
15
+ export interface PackageLookupFs {
16
+ /** `statSync`'s contract: follows symlinks, and throws an errno error
17
+ * (`code` set) for a path it cannot stat. */
18
+ stat(path: string): {
19
+ isDirectory(): boolean;
20
+ isFile(): boolean;
21
+ };
22
+ /** `readFileSync(path, 'utf8')`'s contract: throws for a file it cannot read. */
23
+ readFile(path: string): string;
24
+ }
25
+ /**
26
+ * The directory `spec` is installed at as seen from `fromDir`, or null when
27
+ * it is not installed there: the walk's match (`findPackageDir`), and only
28
+ * when that directory holds a `package.json` that stats as a FILE. Anything
29
+ * else is ABSENT — a match with no manifest, a manifest that is not a file,
30
+ * one whose stat fails — because the loader matches that directory and the
31
+ * import fails there. The case this exists for: an uninstall that stops
32
+ * part-way (on Windows a locked file is enough) can leave
33
+ * `<profile>/node_modules/L/` holding nothing but a nested `node_modules/`,
34
+ * and a plugin declaring L then fails with ERR_MODULE_NOT_FOUND; while any
35
+ * directory counted as present, the badge naming L disappeared. The lookup
36
+ * before that one, `require.resolve('<spec>/package.json')`, required the
37
+ * manifest too.
38
+ *
39
+ * The manifest is stat-ed and never read here: presence is the file, and
40
+ * what it says is the version resolver's question. So a manifest that exists
41
+ * but cannot be read or parsed is a present package with no readable version.
42
+ *
43
+ * One edge is knowingly given up: a directory holding `index.js` and no
44
+ * manifest DOES import — Node's legacy main resolution tries `index.js` in a
45
+ * match without a manifest — but reads absent here. No package manager
46
+ * installs that shape.
47
+ *
48
+ * Throws only for a `spec` that is not a bare package name. This one lookup
49
+ * is behind the presence resolver, the version resolver, and the running
50
+ * harness's own `@deepseek-ai/dsh-app-boot` (`harness.ts`).
51
+ */
52
+ export declare function packageDirectory(fromDir: string, spec: string, fs?: PackageLookupFs): string | null;
53
+ /** `nodeResolver` over an injected filesystem, walking up from `fromDir` —
54
+ * the seam fixtures drive the lookup through. */
55
+ export declare function packageResolver(fromDir: string, fs: PackageLookupFs): PeerResolver;
56
+ /**
57
+ * The production resolver, anchored at the profile. A peer is present when
58
+ * the first ancestor `node_modules/<spec>` that is a directory holds a
59
+ * `package.json` file — the directory the ESM resolver that loads plugins'
60
+ * host halves matches for a bare package, and a manifest in it
61
+ * (`findPackageDir`, `packageDirectory`). From `<profile>/cordis.yml` the
62
+ * walk reads `<profile>/node_modules`, then `$DSH_HOME/profiles/node_modules`
63
+ * — the link farm dsh-app-boot's `healProfilesModuleFallback` keeps, pointing
64
+ * into the global dsh install, on 0.1.5; 0.1.7 keeps none, and on a harness
65
+ * that serves its packages another way `harnessPackageResolver` asks it in
66
+ * front of this walk — and so on up to the root, past any candidate
67
+ * it cannot stat, as Node's reader walks past one (measured on Node 26.6.0
68
+ * with a looping link in front of a real copy: the import loads the copy).
69
+ * The one shape where this and the loader knowingly disagree is a directory
70
+ * holding `index.js` and no manifest, which imports and reads absent here;
71
+ * no package manager installs it (`packageDirectory`).
72
+ *
73
+ * It used to ask `require.resolve('<spec>/package.json')`, a question that
74
+ * answered wrongly twice:
75
+ *
76
+ * - Under a packaged dsh executable (`process.pkg`) the link farm holds ESM
77
+ * proxy packages instead of symlinks, and a proxy's `exports` leaves
78
+ * `./package.json` out on purpose, so resolving it threw
79
+ * ERR_PACKAGE_PATH_NOT_EXPORTED for every proxied peer. Presence read that
80
+ * as present; the version resolver below could read nothing. Reproduced:
81
+ * a proxy at `0.2.0-rc.1` against a declared `^0.1.1-rc.2` gave presence
82
+ * true, version null, mismatches `[]` — the load-time self-check could
83
+ * never fire under a packaged dsh.
84
+ * - Node caches a SUCCESSFUL CJS resolution for the life of the process
85
+ * (`Module._pathCache`, keyed on request + lookup paths) and hands the
86
+ * cached filename back without looking at the disk; a failure is not
87
+ * cached. Measured on Node 26.6.0 in one process: absent → false,
88
+ * installed → true, uninstalled → STILL true. A peer the reader removed
89
+ * left every plugin declaring it unflagged until dsh restarted.
90
+ *
91
+ * So nothing is cached and `exports` is never consulted: every call stats
92
+ * afresh, and a package with a manifest is present whatever its `exports`
93
+ * says. `stat` follows symlinks, so a link to nothing reads absent — which
94
+ * matters, because the link farm adds links and never prunes them: 29 of its
95
+ * 511 dangled on the machine this was measured on, after upgrades.
96
+ *
97
+ * The global folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`,
98
+ * `$PREFIX/lib/node` — are deliberately not searched: the ESM resolver that
99
+ * loads plugin host code does not search them either. That is not academic.
100
+ * pnpm's bin shims export `NODE_PATH` (this repo's own `node_modules/.bin/
101
+ * vitest` points it into the virtual store), and under one the old lookup
102
+ * found harness packages from any anchor at all, a temp directory included.
103
+ *
104
+ * It throws only for a `spec` that is not a bare package name, and
105
+ * `incompatibilityMap` turns the throw into no verdict: a hostile name is not
106
+ * a fact about this installation, and false is an accusation. Nothing on the
107
+ * filesystem makes it throw. It used to throw on a stat failure other than
108
+ * ENOENT or ENOTDIR as well, reasoning that such a candidate "may hold the
109
+ * package" — but the loader never asks what a candidate it cannot stat holds;
110
+ * it walks on (`findPackageDir`), so the throw silenced true badges and
111
+ * established nothing.
14
112
  */
15
113
  export declare function nodeResolver(baseUrl: string): PeerResolver;
114
+ /**
115
+ * The one method of dsh's `pluginPackages` service the peer check reads: the
116
+ * harness's own answer to "which package does this import reach?".
117
+ *
118
+ * dsh 0.1.7 stopped mirroring its installation into
119
+ * `$DSH_HOME/profiles/node_modules`. It serves those packages to every
120
+ * profile through Node's module hooks instead — its runtime resolution
121
+ * routes a plugin's bare import of any package in the installation's
122
+ * dependency closure to the package that declares it — so the walk
123
+ * `nodeResolver` makes, which reads the disk around the profile, finds none
124
+ * of them. Measured on 0.1.7-rc.2 against the live catalog, the walk read
125
+ * `@deepseek-ai/dsh-tools` as missing for 1,038 entries, and the shop badged
126
+ * 2,214 where 0.1.5-rc.3 badges 570 (design
127
+ * 2026-09-01-harness-compatibility §11). `packageOf` answers from that
128
+ * resolution for the importer `parentURL` names; 0.1.5 has no such service.
129
+ *
130
+ * Typed loosely because the answer crosses in from the harness: only a
131
+ * string `manifestPath` is used.
132
+ */
133
+ export interface HarnessPackageLookup {
134
+ packageOf(specifier: string, parentURL: string): {
135
+ manifestPath?: unknown;
136
+ } | undefined;
137
+ }
138
+ /**
139
+ * Presence on a harness that serves its packages through its runtime
140
+ * resolution (`HarnessPackageLookup`): present when the harness reaches a
141
+ * package for `spec` from `parentURL`, the profile anchor, and otherwise
142
+ * whatever `walk` answers — `nodeResolver` over the same anchor, in
143
+ * production. Asking the harness first asks the loader's own question, since
144
+ * a plugin's import goes through the same resolution. The walk stays behind
145
+ * it because either answer can only clear a badge, never raise one, and a
146
+ * false badge is the failure this module exists to avoid.
147
+ *
148
+ * Throws, which `incompatibilityMap` turns into no verdict for the entries
149
+ * declaring `spec`, for a name that is not a bare package name and for
150
+ * anything the service throws. A harness that cannot answer is silence:
151
+ * falling back to the walk alone would read every package it serves as
152
+ * missing.
153
+ */
154
+ export declare function harnessPackageResolver(walk: PeerResolver, lookup: HarnessPackageLookup, parentURL: string, fs?: PackageLookupFs): PeerResolver;
16
155
  /**
17
156
  * Package name → the peer names that did not resolve. A key is present only
18
157
  * when at least one peer is missing, so an absent key means "runs here, or we
@@ -41,15 +180,47 @@ export interface PeerVersionMismatch {
41
180
  /** The version the installation actually provides. */
42
181
  found: string;
43
182
  }
183
+ /** `nodeVersionResolver` over an injected filesystem, walking up from
184
+ * `fromDir` — the seam fixtures drive the lookup through. */
185
+ export declare function packageVersionResolver(fromDir: string, fs: PackageLookupFs): PeerVersionResolver;
44
186
  /**
45
- * The production resolver: `nodeResolver`'s own resolution, kept this time
46
- * instead of collapsed to a boolean. Anything that leaves no version to read
47
- * answers null — a peer that is absent, one that restricts `./package.json`
48
- * in its exports, an unreadable or malformed manifest, a manifest with no
49
- * `version`. Absence is deliberately NOT reported as a version violation:
50
- * `incompatibilityMap` is what covers a missing peer.
187
+ * The production version resolver: `nodeResolver`'s lookup, then the matched
188
+ * directory's own `package.json`, read directly. Reading it directly is the
189
+ * fix, not a shortcut: resolving `<spec>/package.json` is what an exports map
190
+ * can refuse, and a packaged dsh's module proxy refuses it by construction
191
+ * while carrying the proxied package's version in exactly that file (see
192
+ * `nodeResolver`).
193
+ *
194
+ * The directory is the one the presence resolver finds present — the walk's
195
+ * first directory match, holding a `package.json` file (`packageDirectory`;
196
+ * `findPackageDir` cites Node's reader) — so the two never disagree about
197
+ * which copy is installed: a candidate nobody can stat is walked past to the
198
+ * copy further up, whose version is then the answer, exactly as the loader
199
+ * imports that copy (measured on Node 26.6.0 with a looping link in front of
200
+ * it); a match without a manifest is absent, and shadows every copy above
201
+ * it. An `index.js`-only directory, which the loader would import, has no
202
+ * manifest to read a version from either way.
203
+ *
204
+ * Anything that leaves no version to read answers null: a spec that is not a
205
+ * bare package name, a peer that is absent, a manifest that is unreadable or
206
+ * malformed, a `version` that is not a non-empty string. Absence is
207
+ * deliberately NOT reported as a version violation: `incompatibilityMap` is
208
+ * what covers a missing peer.
51
209
  */
52
210
  export declare function nodeVersionResolver(baseUrl: string): PeerVersionResolver;
211
+ /**
212
+ * `harnessPackageResolver`'s twin for versions: read afresh from the manifest
213
+ * of the package the harness reaches for `spec` — the copy a plugin's import
214
+ * loads, which the walk cannot see on dsh 0.1.7 — and `walk`'s answer only
215
+ * when the harness reaches none. A manifest the harness names but nobody can
216
+ * read or parse answers null, never the walk's version: the walk may match
217
+ * another copy, and the version must describe the copy presence found (see
218
+ * `nodeVersionResolver`).
219
+ *
220
+ * Null for a name that is not a bare package name and for a service that
221
+ * throws, as null is already this resolver's no-verdict signal.
222
+ */
223
+ export declare function harnessPackageVersionResolver(walk: PeerVersionResolver, lookup: HarnessPackageLookup, parentURL: string, fs?: PackageLookupFs): PeerVersionResolver;
53
224
  /**
54
225
  * The declared peers whose provided version is outside the declared range,
55
226
  * ordered by peer name. Pure: the module graph arrives through `resolve`.