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.
- package/README.md +11 -8
- package/lib/client.js +1133 -421
- package/lib/index.js +1400 -178
- package/lib/typert.host.js +69 -22
- package/lib/typert.remote-client.js +52 -13
- package/lib/types/client/ShopTab.d.ts +19 -2
- package/lib/types/client/locales.d.ts +42 -2
- package/lib/types/client/module-table.d.ts +131 -0
- package/lib/types/client/present.d.ts +126 -2
- package/lib/types/client/restart-monitor.d.ts +58 -0
- package/lib/types/host/activation.d.ts +12 -9
- package/lib/types/host/bundle-patch.d.ts +35 -0
- package/lib/types/host/catalog.d.ts +9 -3
- package/lib/types/host/compatibility.d.ts +223 -0
- package/lib/types/host/dsh-cli.d.ts +8 -0
- package/lib/types/host/harness.d.ts +109 -0
- package/lib/types/host/hot.d.ts +8 -8
- package/lib/types/host/index.d.ts +197 -28
- package/lib/types/host/install.d.ts +1 -1
- package/lib/types/host/peers.d.ts +181 -10
- package/lib/types/host/profile.d.ts +61 -4
- package/lib/types/host/restart.d.ts +5 -1
- package/lib/types/host/types.d.ts +27 -1
- package/lib/types/own-version.d.ts +18 -2
- package/package.json +2 -1
|
@@ -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
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
|
|
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
|
-
*
|
|
362
|
-
*
|
|
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
|
|
391
|
-
*
|
|
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):
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `
|
|
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`.
|