dsh-plugin-shop 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,41 +1,31 @@
1
- /** Install driving hook for one entry: start, poll to terminal, reset. */
1
+ /** Install driving hooks: the shared poll loop, and the tab's keyed registry. */
2
2
  import { type Dispatch, type SetStateAction } from 'react';
3
3
  import { type InstallView } from './present.ts';
4
+ import { type KeyedFlow, type UseKeyedFlows } from './useFlows.ts';
4
5
  import type { InstallArgs, ShopInstallResult, ShopInstallStatusResult } from '../host/index.ts';
5
- export interface UseInstallResult {
6
- view: InstallView;
7
- start: (args: InstallArgs) => Promise<void>;
8
- reset: () => void;
9
- }
10
- /** The poll loop shared by the install and uninstall drivers: while the view
11
- * is `running`, poll once per second and fold each status through the
12
- * reducer. A poll failure is transient the host retains the record, so the
13
- * next tick finds it. The rejection handler must be present: an unhandled
14
- * rejection here would escape the poll loop. */
6
+ /** The single-view poll loop: while the view is `running`, poll once per
7
+ * second and fold each status through the reducer. A poll failure is
8
+ * transient the host retains the record, so the next tick finds it. The
9
+ * rejection handler must be present: an unhandled rejection here would
10
+ * escape the poll loop.
11
+ *
12
+ * Kept for the self-update (`useUpdateSelf`), which drives ONE flow with no
13
+ * identity to key it by. Everything per-plugin goes through `useKeyedFlows`,
14
+ * whose own loop polls every running identity from one interval. */
15
15
  export declare function usePollStatus(view: InstallView, setView: Dispatch<SetStateAction<InstallView>>, installStatus: (args: {
16
16
  installId: string;
17
17
  }) => Promise<ShopInstallStatusResult>): void;
18
- /** Drive one install: rejections, the polling loop at INSTALL_POLL_MS, and
19
- * terminal states. The interval is cleared on unmount and whenever the view
20
- * leaves `running`, so a done/failed/rejected install never polls again. */
21
- export declare function useInstall(install: (args: InstallArgs) => Promise<ShopInstallResult>, installStatus: (args: {
22
- installId: string;
23
- }) => Promise<ShopInstallStatusResult>): UseInstallResult;
24
18
  /** One entry's install flow, as the tab hands it to a panel. */
25
- export interface InstallFlow {
26
- view: InstallView;
27
- start: (args: InstallArgs) => Promise<void>;
28
- reset: () => void;
29
- }
30
- export interface UseInstallFlows {
31
- /** Two panels asking for the same install identity receive one flow. */
32
- flowFor: (key: string) => InstallFlow;
33
- }
19
+ export type InstallFlow = KeyedFlow<InstallArgs>;
20
+ export type UseInstallFlows = UseKeyedFlows<InstallArgs>;
34
21
  /**
35
22
  * Install flows owned by the tab and keyed by install identity. Keeping the
36
23
  * state above individual cards preserves a running operation when filtering
37
24
  * unmounts its card, and makes the shelf and Outdated panels agree.
25
+ *
26
+ * The registry itself is `useKeyedFlows`; this supplies the one thing that is
27
+ * install-specific — what the starting RPC's answer means.
38
28
  */
39
29
  export declare function useInstallFlows(install: (args: InstallArgs) => Promise<ShopInstallResult>, installStatus: (args: {
40
30
  installId: string;
41
- }) => Promise<ShopInstallStatusResult>, onSettled?: (key: string) => void): UseInstallFlows;
31
+ }) => Promise<ShopInstallStatusResult>, onSettled?: (key: string, outcome: 'done' | 'failed') => void): UseInstallFlows;
@@ -1,21 +1,28 @@
1
- /** Uninstall driving hook for one entry: start, poll to terminal. Shares the
2
- * install view machine and poll loop; the only difference is the start
3
- * mapping — an uninstall business failure (not in the catalog / not
4
- * installed) lands in the `failed` view with the host's published detail,
5
- * never in the install `rejected` codes, which belong to the install gate
6
- * (§7.2). */
1
+ /** Uninstall driving hook: the tab's keyed registry, one start mapping. */
7
2
  import type { ShopInstallStatusResult, ShopUninstallResult } from '../host/index.ts';
8
- import type { InstallView } from './present.ts';
9
- export interface UseUninstallResult {
10
- view: InstallView;
11
- start: (args: {
12
- name: string;
13
- }) => Promise<void>;
14
- }
15
- /** Drive one uninstall: the host's business union, the shared polling loop,
16
- * and terminal states. The `rejected` install state never occurs here. */
17
- export declare function useUninstall(uninstall: (args: {
3
+ import { type KeyedFlow, type UseKeyedFlows } from './useFlows.ts';
4
+ /** One entry's uninstall flow, as the tab hands it to a panel. */
5
+ export type UninstallFlow = KeyedFlow<{
6
+ name: string;
7
+ }>;
8
+ export type UseUninstallFlows = UseKeyedFlows<{
9
+ name: string;
10
+ }>;
11
+ /**
12
+ * Uninstall flows owned by the tab and keyed by install identity. Lifting the
13
+ * state above individual cards is what lets a completed uninstall's outcome
14
+ * survive the installed-projection refresh that follows it — the row backing
15
+ * the card disappears, but the flow keyed by identity does not.
16
+ *
17
+ * The registry itself is `useKeyedFlows`; this supplies the one thing that is
18
+ * uninstall-specific — what the starting RPC's answer means. An uninstall
19
+ * business failure (not in the catalog / not installed) lands in the `failed`
20
+ * view with the host's published detail, never in the install `rejected`
21
+ * codes: those belong to the install gate, and `ShopUninstallResult`'s
22
+ * failure variant has no `code` field to populate.
23
+ */
24
+ export declare function useUninstallFlows(uninstall: (args: {
18
25
  name: string;
19
26
  }) => Promise<ShopUninstallResult>, installStatus: (args: {
20
27
  installId: string;
21
- }) => Promise<ShopInstallStatusResult>): UseUninstallResult;
28
+ }) => Promise<ShopInstallStatusResult>, onSettled?: (key: string, outcome: 'done' | 'failed') => void): UseUninstallFlows;
@@ -4,7 +4,7 @@
4
4
  * `failed` view with the host's detail, never in the install `rejected`
5
5
  * codes. */
6
6
  import type { ShopInstallStatusResult, ShopUpdateResult } from '../host/index.ts';
7
- import type { InstallView } from './present.ts';
7
+ import { type InstallView } from './present.ts';
8
8
  export interface UseUpdateSelfResult {
9
9
  view: InstallView;
10
10
  start: (args: {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What a reader must do before a change the shop just made becomes visible
3
+ * (design 2026-09-11-activation-model §2).
4
+ *
5
+ * A dsh plugin has two halves that go live by different routes. The host half
6
+ * is composed by the loader. The browser half reaches a tab only through
7
+ * `window.__DSH_BOOT__`, which the harness composes and the webserver injects
8
+ * on every index request — so where that graph already holds the change, a
9
+ * tab predating it is stale by one RELOAD rather than by one restart.
10
+ *
11
+ * Three values, not two booleans: `needsRestart` plus `needsReload` would
12
+ * admit a true/true that the system cannot be in, and would make every reader
13
+ * re-derive the precedence.
14
+ *
15
+ * This answers about VISIBILITY, never about correctness. `live` does not
16
+ * claim the plugin works; tiering (§9) and harness compatibility
17
+ * (2026-09-01-harness-compatibility) are what speak to that.
18
+ */
19
+ export type Activation = 'live' | 'reload' | 'restart';
20
+ /**
21
+ * Decide what the reader must do.
22
+ *
23
+ * @param input.hostLive - whether the plugin's host half is in its intended
24
+ * post-change state in this process right now: the hot-mount outcome for an
25
+ * install or update, whether the fiber actually went away for an uninstall,
26
+ * and true for a toggle (the user layer is hot-reloaded).
27
+ * @param input.hasClientHalf - whether the package declares `dsh.client`. An
28
+ * unreadable manifest reports `true`; see `client-half.ts` for why.
29
+ * @param input.clientLive - whether THIS change's browser half is in the
30
+ * graph the webserver hands a reloading tab.
31
+ *
32
+ * `clientLive` is the difference between the two routes a change can take,
33
+ * and it is measured rather than assumed:
34
+ *
35
+ * - A change to the BOOT COMPOSITION — a toggle, an uninstall — moves an
36
+ * entry the registry already enumerates, and the served graph follows it
37
+ * within seconds (§2, measured 2026-09-11). `true`.
38
+ * - A HOT MOUNT — an install or an update — adds to the live loader entries
39
+ * without entering that composition, so the graph a tab reloads into does
40
+ * not contain the package. Measured 2026-09-14 against dsh 0.1.5-rc.1 in
41
+ * `web-full-flow.e2e.ts`: across a reload following a hot mount the graph
42
+ * is byte-identical, same `rev`, while the package's host half is live the
43
+ * whole time. `false` — and a `restart` is then the only honest answer,
44
+ * because there is nothing a reload could fetch.
45
+ *
46
+ * The asymmetry that decides every unknown still runs the same way: offering
47
+ * a step that was not needed costs the reader one action, withholding one
48
+ * that was needed is the defect this module exists to fix. What changed on
49
+ * 2026-09-14 is which step is the needed one after a hot mount.
50
+ */
51
+ export declare function activationOf(input: {
52
+ hostLive: boolean;
53
+ clientLive: boolean;
54
+ hasClientHalf: boolean;
55
+ }): Activation;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Does an installed package have a browser half? (design
3
+ * 2026-09-11-activation-model §3.)
4
+ *
5
+ * The harness's `ClientModuleRegistry` scans the loader's entries for
6
+ * packages declaring `dsh.client` and composes `window.__DSH_BOOT__` from
7
+ * them. That declaration is therefore the whole question: a package that
8
+ * declares it puts something in a browser tab, and a tab opened before the
9
+ * change is showing the state from before it.
10
+ *
11
+ * The read goes through `HotFs`, the same injected seam `hot.ts` uses to
12
+ * read the same file for `dsh.bundle.patch`, so tests never touch disk and
13
+ * exactly one production call site does.
14
+ */
15
+ import type { HotFs } from './hot.ts';
16
+ /**
17
+ * Whether `packageName`, as installed in `profileDir`, declares `dsh.client`.
18
+ *
19
+ * **An unreadable manifest answers `true`.** Offering a reload that was not
20
+ * needed costs the reader one keystroke; withholding one that was needed is
21
+ * the defect this module exists to fix, so the fallback is the safe side of
22
+ * a lopsided asymmetry rather than a guess.
23
+ *
24
+ * The VALUE of `dsh.client` is not inspected — an empty object is a
25
+ * declaration. Only a non-object (the manifest saying something else
26
+ * entirely) reads as no declaration.
27
+ */
28
+ export declare function hasClientHalf(fs: HotFs, profileDir: string, packageName: string): boolean;
@@ -23,6 +23,17 @@
23
23
  */
24
24
  /** The npm package the dsh CLI is published as. */
25
25
  export declare const DSH_PACKAGE = "@deepseek-ai/dsh";
26
+ /**
27
+ * The command that starts `bin` through `execPath` when `bin` names a
28
+ * JavaScript entry, or `null` when `bin` is a program to spawn as given.
29
+ *
30
+ * Extracted from {@link dshCommand} so `prefetch.ts` can reach the same
31
+ * decision for pnpm without a second copy of {@link JS_ENTRY}: both need it
32
+ * for the same two reasons — a packaged JS entry is what a caller pinning an
33
+ * installation can name, and a `.mjs` test fixture is the only fake CLI that
34
+ * can be spawned on Windows at all.
35
+ */
36
+ export declare function jsEntryCommand(bin: string, args: readonly string[], execPath: string): DshCommand | null;
26
37
  export interface DshCliFs {
27
38
  exists: (path: string) => boolean;
28
39
  read: (path: string) => string;
@@ -3,11 +3,17 @@
3
3
  * `dsh plugin remove` (uninstall); the only differences are the verb and the
4
4
  * post-exit manifest confirmation. */
5
5
  import type { HotRestartReason } from './hot.ts';
6
- export type InstallState = 'running' | 'done' | 'failed';
6
+ import type { Activation } from './activation.ts';
7
+ import type { Prefetcher } from './prefetch.ts';
8
+ import { type InstallState } from '../shared/install-state.ts';
9
+ export type { InstallState } from '../shared/install-state.ts';
7
10
  export interface InstallStatus {
8
11
  state: InstallState;
9
12
  log: string[];
10
- needsRestart?: boolean;
13
+ /** What the reader must do for this change to be visible (design
14
+ * 2026-09-11-activation-model). Present only on "done". Absent means the
15
+ * install is still running or failed, never "nothing to do". */
16
+ activation?: Activation;
11
17
  restartReason?: HotRestartReason;
12
18
  detail?: string;
13
19
  }
@@ -102,6 +108,11 @@ export interface KillFns {
102
108
  killPid: (pid: number) => void;
103
109
  taskkill: (pid: number) => void;
104
110
  }
111
+ /** The real kill. Exported for the tests that inject a RECORDING `KillFns`:
112
+ * a recorder that only records asserts that a kill was requested and leaves
113
+ * the child running, which for a `detached` fixture is an immortal node
114
+ * process per run — see `prefetch.test.ts`'s delegating recorder. */
115
+ export declare const nodeKills: KillFns;
105
116
  /** Kill a child and its descendants (process group on POSIX, taskkill tree on Windows). */
106
117
  export declare function killTree(pid: number | undefined, platform: NodeJS.Platform, kills?: KillFns): void;
107
118
  /** One stream's line assembler, retaining partial UTF-8 and text lines. */
@@ -163,13 +174,16 @@ export declare function startInstall(options: {
163
174
  env?: NodeJS.ProcessEnv;
164
175
  platform?: NodeJS.Platform;
165
176
  expectedName?: string;
177
+ /** The download phase, when the caller has one — see `spawnPluginCli`.
178
+ * An uninstall has nothing to fetch and `startUninstall` takes none. */
179
+ prefetcher?: Prefetcher;
166
180
  /** A further post-exit check, run only once the bundle-activation confirm
167
181
  * has passed. Returning a detail fails the install with it. The package is
168
182
  * on disk by then, so this is for facts that are unreadable until it is —
169
183
  * see `collidingEntryId`. */
170
184
  alsoConfirm?: (home: string | undefined) => string | null;
171
185
  afterDone?: (home: string | undefined) => Promise<{
172
- needsRestart: boolean;
186
+ activation: Activation;
173
187
  restartReason?: HotRestartReason;
174
188
  } | void>;
175
189
  onStatus?: (status: InstallStatus) => void;
@@ -189,10 +203,9 @@ export declare function startUninstall(options: {
189
203
  env?: NodeJS.ProcessEnv;
190
204
  expectedName?: string;
191
205
  afterDone?: (home: string | undefined) => Promise<{
192
- needsRestart: boolean;
206
+ activation: Activation;
193
207
  restartReason?: HotRestartReason;
194
208
  } | void>;
195
209
  onStatus?: (status: InstallStatus) => void;
196
210
  timeoutMs?: number;
197
211
  }): RunningInstall;
198
- export {};
@@ -48,12 +48,22 @@ export interface PluginHandle {
48
48
  dispose(): Promise<unknown> | void;
49
49
  }
50
50
  /**
51
- * Why a hot mount could not activate — a stable code the client turns into
52
- * copy in the reader's own dsh language. It distinguishes "restart will fix
53
- * it" (`timeout`, `mount-failed`) from "this package can never hot-mount"
54
- * (`no-patch`, `not-simple`) and "this harness cannot" (`host-unsupported`).
51
+ * Why a restart is needed after the hot path ran — a stable code the client
52
+ * turns into copy in the reader's own dsh language.
53
+ *
54
+ * Four of the five say the MOUNT could not activate, and distinguish
55
+ * "restart will fix it" (`timeout`, `mount-failed`) from "this package can
56
+ * never hot-mount" (`no-patch`, `not-simple`) and "this harness cannot"
57
+ * (`host-unsupported`).
58
+ *
59
+ * `client-half` is the one that does not: the mount SUCCEEDED and the host
60
+ * half is running, but the package declares `dsh.client` and a hot mount does
61
+ * not enter the composition the client registry enumerates, so no reload can
62
+ * fetch its browser half (measured 2026-09-14 — see `activation.ts`). It
63
+ * exists so the reader is not told "installed; restart dsh to activate"
64
+ * about a plugin that is demonstrably already running.
55
65
  */
56
- export type HotRestartReason = 'no-patch' | 'not-simple' | 'host-unsupported' | 'timeout' | 'mount-failed';
66
+ export type HotRestartReason = 'no-patch' | 'not-simple' | 'host-unsupported' | 'timeout' | 'mount-failed' | 'client-half';
57
67
  export interface HotMountResult {
58
68
  ok: boolean;
59
69
  /** null exactly when ok. */
@@ -78,6 +88,7 @@ export interface HotFs {
78
88
  write: (path: string, data: string) => void;
79
89
  list: (path: string) => string[];
80
90
  }
91
+ export declare const nodeHotFs: HotFs;
81
92
  /**
82
93
  * Parse a bundle patch into the plain insert rows a hot tree can replicate.
83
94
  *
@@ -5,12 +5,16 @@ import { loadCatalog, type LoadCatalogOptions } from './catalog.ts';
5
5
  import type { CatalogEntry, DeniedEntry } from './types.ts';
6
6
  import { type InstallArgs, type InstallRejectionCode } from './install.ts';
7
7
  import { type InstallStatus } from './executor.ts';
8
- import { hotMount, hotUnmount } from './hot.ts';
8
+ import { hotMount, hotUnmount, type HotFs } from './hot.ts';
9
+ import { type Activation } from './activation.ts';
9
10
  import { type RestartOutcome } from './restart.ts';
10
11
  import { type RepoPinFs } from './repo-pins.ts';
12
+ import { type InstallState } from '../shared/install-state.ts';
13
+ import { type Prefetcher } from './prefetch.ts';
11
14
  import { type PeerResolver, type PeerVersionResolver } from './peers.ts';
12
15
  export type { InstallArgs, InstallRejectionCode } from './install.ts';
13
16
  export type { HotRestartReason } from './hot.ts';
17
+ export type { Activation } from './activation.ts';
14
18
  export type { CatalogEntry } from './types.ts';
15
19
  /** One Loader inventory entry, structurally — the shop never depends on
16
20
  * cordis-plugin-loader, whose types do not reach this package's typecheck. */
@@ -59,6 +63,10 @@ export interface ShopGatewayOptions {
59
63
  mount: typeof hotMount;
60
64
  unmount: typeof hotUnmount;
61
65
  };
66
+ /** Test-only injection: the filesystem `packageHasClientHalf` reads
67
+ * `dsh.client` through; the same seam `hot.ts` reads its patch through.
68
+ * Production uses `nodeHotFs`. */
69
+ hotFs?: HotFs;
62
70
  /** Test-only injection: the Loader's boot-layer entries; production reads
63
71
  * them from `ctx.loader`. */
64
72
  loaderEntries?: () => Array<LoaderEntryLike>;
@@ -106,12 +114,18 @@ export interface ShopGatewayOptions {
106
114
  /** Test-only injection: the peer ranges the self-check judges against;
107
115
  * production reads them from the shipped package.json. */
108
116
  peerRanges?: Record<string, string>;
117
+ /** Test-only injection: the download phase's pump; production builds the
118
+ * real one. A test gateway left with the real pump spawns `pnpm store add`
119
+ * — a live registry request — from every install that finds a command
120
+ * already queued for its profile. */
121
+ prefetcher?: Prefetcher;
109
122
  }
110
123
  /** `shop/installStart` result (§7.3): rejections are typed wire values with an
111
124
  * author-readable `detail`, not thrown RPC errors. */
112
125
  export type ShopInstallResult = {
113
126
  ok: true;
114
127
  installId: string;
128
+ state: InstallState;
115
129
  } | {
116
130
  ok: false;
117
131
  code: InstallRejectionCode;
@@ -120,12 +134,30 @@ export type ShopInstallResult = {
120
134
  export interface ShopInstallStatusResult extends InstallStatus {
121
135
  found: boolean;
122
136
  }
123
- /** `shop/setEnabled` result (§7.3): an unknown name is a typed wire value,
124
- * not a thrown RPC error. */
125
- export interface ShopSetEnabledResult {
126
- ok: boolean;
127
- detail?: string;
128
- }
137
+ /**
138
+ * `shop/setEnabled` result (§7.3): an unknown name is a typed wire value, not
139
+ * a thrown RPC error.
140
+ *
141
+ * A union rather than one flat shape with two optional fields, so that
142
+ * "`activation` is present exactly when `ok`" is something the compiler
143
+ * holds instead of a sentence this comment asks every reader to hold. Every
144
+ * `ok` answer says what the reader must do for the toggle to be visible — a
145
+ * toggled package with a browser half needs a reload, which this result used
146
+ * to be unable to say (design 2026-09-11-activation-model §6) — and every
147
+ * refusal carries the detail that names why.
148
+ *
149
+ * The client still defaults an absent `activation` conservatively. This type
150
+ * binds the process it is compiled into; the WIRE outlives it, because a tab
151
+ * can reach an older in-process host for as long as one reload after a shop
152
+ * self-update lands on disk.
153
+ */
154
+ export type ShopSetEnabledResult = {
155
+ ok: true;
156
+ activation: Activation;
157
+ } | {
158
+ ok: false;
159
+ detail: string;
160
+ };
129
161
  /** `shop/uninstallStart` result (§7.3): a name outside the catalog or not
130
162
  * installed is a typed wire value with an author-readable `detail`, not a
131
163
  * thrown RPC error. */
@@ -139,6 +171,19 @@ export type ShopUninstallResult = {
139
171
  /** `shop/restart` result (§7.3): the restarted server's URL, or a typed
140
172
  * failure — on failure the old process is still serving. */
141
173
  export type ShopRestartResult = RestartOutcome;
174
+ /** Why `shop/restart` would refuse, for a reason fixed for the life of this
175
+ * process: the platform, the supervisor (env and ppid are read once at
176
+ * construction), and the launch argv. Every one of them is decided before the
177
+ * first request arrives, which is what lets `version()` advertise it and the
178
+ * client name it without asking again.
179
+ *
180
+ * `restart()` carries one further refusal this deliberately omits — a running
181
+ * install — because that one is transient: a mount-time answer about it would
182
+ * be stale the moment the install finished, and a card would go on saying a
183
+ * restart was impossible after it had become possible. Static here, dynamic
184
+ * there; the split is the whole reason this is a separate predicate rather
185
+ * than a cache of `restart()`'s answer. */
186
+ export type RestartBlockedReason = 'windows' | 'systemd' | 'port-zero';
142
187
  /** `shop/version` result (§7.3): the RUNNING shop version (from the shipped
143
188
  * package.json, not the manifest's range), the npm latest when the check
144
189
  * could answer (`null` = no answer — advisory, never an error), and the
@@ -147,16 +192,17 @@ export interface ShopVersionResult {
147
192
  installed: string;
148
193
  latest: string | null;
149
194
  outdated: boolean;
150
- /** Whether `shop/restart` is usable: false when a supervisor owns this
151
- * process and no `allowRestart` override is set. The client hides the
152
- * restart offer on false but keeps the pending-change notice. */
153
- restartSupported: boolean;
195
+ /** Why `shop/restart` would refuse, or null when nothing static stands in
196
+ * its way. The client drops the restart offer on a reason and renders that
197
+ * reason's own copy, keeping the pending-change notice either way. */
198
+ restartBlocked: RestartBlockedReason | null;
154
199
  }
155
200
  /** `shop/updateStart` result (§7.3): the self-update spawn, or a typed
156
201
  * refusal (a version that is not plain semver). */
157
202
  export type ShopUpdateResult = {
158
203
  ok: true;
159
204
  installId: string;
205
+ state: InstallState;
160
206
  } | {
161
207
  ok: false;
162
208
  detail: string;
@@ -219,6 +265,7 @@ export declare class ShopGateway extends TypertRemoteService {
219
265
  private readonly profileDir?;
220
266
  private readonly inventory?;
221
267
  private readonly hot?;
268
+ private readonly hotFs?;
222
269
  private readonly loaderEntriesInjected?;
223
270
  private readonly dshBin;
224
271
  /** The argv `shop/restart` re-spawns: the real process argv minus node and
@@ -244,10 +291,13 @@ export declare class ShopGateway extends TypertRemoteService {
244
291
  /** The release-tarball fetch for the install-time integrity check; global
245
292
  * fetch in production, a fixture response in tests. */
246
293
  private readonly fetchTarball;
294
+ /** One pump for the whole gateway: batching is per profile and lives inside
295
+ * it, so a second instance would race the first for the same store. */
296
+ private readonly prefetcher;
247
297
  /** The install gate runs against the last loaded snapshot, never a fresh
248
298
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
249
299
  /** Finished install records retained, so a poll sees the true terminal
250
- * state (§8: done / needsRestart / failure detail). Oldest evicted on add. */
300
+ * state (§8: done / activation / failure detail). Oldest evicted on add. */
251
301
  private static readonly MAX_FINISHED_INSTALLS;
252
302
  /** How long the gateway waits after a successful restart response before
253
303
  * exiting the old process — the browser must receive the URL first. */
@@ -342,7 +392,30 @@ export declare class ShopGateway extends TypertRemoteService {
342
392
  */
343
393
  private installedSpecOf;
344
394
  private ownedEntryIdsOrNone;
345
- private liveDisableIds;
395
+ /** Whether an installed package declares `dsh.client`. Reads through the
396
+ * `hotFs` option — `HotFs` is `hot.ts`'s type, but this gateway forwards
397
+ * the option only HERE, never into `hotMount`, which takes its own `fs`
398
+ * from `HotDeps`. A fixture therefore drives this read alone, which is the
399
+ * point: it is the only way to state what a package declared BEFORE an
400
+ * update overwrote its manifest. */
401
+ private packageHasClientHalf;
402
+ /**
403
+ * Bring every live entry the package owns down, best effort, and report
404
+ * whether its host half is DOWN when this returns.
405
+ *
406
+ * "Nothing matched" is down: a package with no live entry is not running,
407
+ * which is the ordinary case for removing a plugin that never loaded this
408
+ * session. Only a matched entry whose fiber outlives the retries — or
409
+ * whose `update` throws — leaves the plugin UP, and that is the one case
410
+ * an uninstall must not describe as stopped.
411
+ *
412
+ * The old spelling answered "did any update succeed", which is a different
413
+ * question: `update` resolving says the row was accepted, not that the
414
+ * instance went away. The retry loop below exists precisely because those
415
+ * two come apart, so reading the first as the second threw away the answer
416
+ * the loop was computing.
417
+ */
418
+ private liveEntriesDown;
346
419
  /** Enable or disable one installed plugin, hot (§8): a disable writes the
347
420
  * row to the user layer, an enable drops it again so the bundle default
348
421
  * rules — the CLI's watchUserPatches applies either through HMR. The shop's
@@ -370,6 +443,26 @@ export declare class ShopGateway extends TypertRemoteService {
370
443
  /** Whether the two-phase handoff can run at all here. `restart.ts` drives
371
444
  * it through `sh`, `kill -0` and `sleep`, none of which Windows has. */
372
445
  private restartPlatformSupported;
446
+ /** Why a restart would be refused for this process, or null when nothing
447
+ * static does. One ordered list, read by `restart()` before it commits and
448
+ * by `version()` so the client can say the same thing up front.
449
+ *
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.
454
+ *
455
+ * - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
456
+ * and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
457
+ * committing would answer `ok: true`, exit this process, and leave nothing
458
+ * to take the port — dsh would simply be gone.
459
+ * - `systemd`: under a unit the two-phase handoff kills itself, because the
460
+ * main process exiting also kills the unit's cgroup and takes the detached
461
+ * helper with it; the service never comes back. Overridable, and the only
462
+ * one of the three that is.
463
+ * - `port-zero`: the OS hands the NEW process a fresh port the browser
464
+ * cannot know, so a restart would strand the client on a dead origin. */
465
+ private staticRestartBlock;
373
466
  private allowRestartConfigured;
374
467
  /** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
375
468
  catalog(args?: {
@@ -382,10 +475,12 @@ export declare class ShopGateway extends TypertRemoteService {
382
475
  */
383
476
  install(args: InstallArgs): Promise<ShopInstallResult>;
384
477
  /** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
385
- * oldest finished ones (insertion order, oldest first). Running records
386
- * are never evicted; an id absent from the map reports `found: false`. */
478
+ * oldest finished ones (insertion order, oldest first). Live records
479
+ * running AND queued — are never evicted; an id absent from the map reports
480
+ * `found: false`. */
387
481
  private evictFinishedInstalls;
388
- /** Whether any command this gateway started is still running. */
482
+ /** Whether any command this gateway started is still running — or still
483
+ * waiting its turn. */
389
484
  private hasRunningCommand;
390
485
  /** Poll one install's progress (§7.2); unknown ids report `found: false`. */
391
486
  installStatus(args: {
@@ -56,9 +56,11 @@ export declare function nodeVersionResolver(baseUrl: string): PeerVersionResolve
56
56
  *
57
57
  * `includePrerelease` is load-bearing, not a convenience. The harness ships
58
58
  * nothing but `-rc` versions, so under strict semver `^0.1.1-rc.2` excludes
59
- * `0.1.2-rc.1` the version that is installed and works and every future
60
- * rc bump would raise a false alarm. With it on, the range still excludes an
61
- * older prerelease (`0.1.1-rc.1`) and a minor- or major-line move
59
+ * every later rc on the same 0.1 line including whichever one is installed
60
+ * and working, named as a property because that version moves and a comment
61
+ * naming it goes stale in place — and every future rc bump would raise a
62
+ * false alarm. With it on, the range still excludes an older prerelease
63
+ * (`0.1.1-rc.1`) and a minor- or major-line move
62
64
  * (`0.2.0-rc.1`, `1.0.0`), which are the moves that actually break a plugin
63
65
  * path. Discrimination on both sides is the whole point: one false warning
64
66
  * teaches a reader to ignore every warning.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The download phase that runs in front of the per-profile install mutex.
3
+ *
4
+ * `dsh plugin add` is one opaque call: it resolves, fetches, links and writes
5
+ * in a single pnpm invocation, so the shop cannot split it. What it can do is
6
+ * warm pnpm's content store first, from outside the mutex, so the serialized
7
+ * install is a store hit. Measured 2026-09-10: an npm or github spec warmed
8
+ * this way installs with `downloaded 0`. Design doc §3.
9
+ *
10
+ * Best-effort by construction. Every failure here — pnpm absent, a non-zero
11
+ * batch, a timeout, a store guessed wrong — leaves `dsh plugin add` to fetch
12
+ * what the store lacks, exactly as it does today. Nothing about whether an
13
+ * install SUCCEEDS may depend on this module.
14
+ */
15
+ import { type ChildProcess, type SpawnOptions } from 'node:child_process';
16
+ import { type KillFns } from './executor.ts';
17
+ /**
18
+ * Whether warming the store for `spec` can save the install any work.
19
+ *
20
+ * A raw https tarball URL cannot, measured twice on 2026-09-10: pnpm
21
+ * re-fetches such a URL on EVERY install even when the store already holds
22
+ * that exact tarball — it must read the `package.json` inside it and that
23
+ * read does not go through the store — and `pnpm store add` on a URL resolves
24
+ * no dependency closure. So a prefetch of that form is one extra full
25
+ * download for no measured saving. The three spec forms are indistinguishable
26
+ * at the `store add` boundary (all exit 0, all print `+ <spec>`), which is why
27
+ * this exclusion is recorded rather than left to look like an oversight.
28
+ * Design doc §3.
29
+ */
30
+ export declare function isPrefetchableSpec(spec: string): boolean;
31
+ /** Why no download phase happened, when one did not. The caller writes the
32
+ * line a user reads, so it needs the reason and not a bare `false`. */
33
+ export type PrefetchRequest = {
34
+ started: true;
35
+ } | {
36
+ started: false;
37
+ reason: 'unsupported-spec' | 'no-pnpm';
38
+ };
39
+ export interface Prefetcher {
40
+ /** Queue `spec` for this profile's next batch, starting one if none runs.
41
+ * `log` receives the outcome of whichever batch ends up carrying this spec —
42
+ * one batch serves several installs, so a batch's exit is reported to each
43
+ * of them. */
44
+ request: (args: {
45
+ profile: string;
46
+ spec: string;
47
+ cwd: string;
48
+ env?: NodeJS.ProcessEnv;
49
+ log?: (line: string) => void;
50
+ }) => PrefetchRequest;
51
+ /** This spec's install has settled. Kills the batch when nothing else needs it. */
52
+ release: (profile: string, spec: string) => void;
53
+ }
54
+ /** The one call this module makes on `spawn`: the argv form, whose child
55
+ * carries the `error`/`exit` listeners `start` needs. `typeof nodeSpawn` is an
56
+ * overload set that also admits two-argument forms this module never builds a
57
+ * command line for, and a test's recording wrapper cannot satisfy those — so
58
+ * the seam is named, and a caller still passes `spawn` itself. */
59
+ export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
60
+ export declare function createPrefetcher(options?: {
61
+ pnpmBin?: string;
62
+ spawn?: SpawnFn;
63
+ platform?: NodeJS.Platform;
64
+ execPath?: string;
65
+ timeoutMs?: number;
66
+ kills?: KillFns;
67
+ }): Prefetcher;
@@ -0,0 +1,16 @@
1
+ /** The lifecycle of one install or uninstall command (§7.2, §7.3).
2
+ *
3
+ * Declared here rather than beside either consumer because it crosses the RPC
4
+ * boundary: `executor.ts` produces it, `present.ts` consumes it, and each
5
+ * used to spell the union itself with nothing keeping the two in agreement.
6
+ *
7
+ * `downloading` and `running` are BOTH non-terminal. Until `downloading`
8
+ * existed there was exactly one non-terminal state, so `!== 'running'` was a
9
+ * safe synonym for "finished" and three call sites wrote it that way. Ask
10
+ * `isTerminalInstallState` instead — the next state added must not silently
11
+ * reclassify a live install as finished.
12
+ */
13
+ export type InstallState = 'downloading' | 'running' | 'done' | 'failed';
14
+ /** Whether the host is done with this record: it will not change again, and a
15
+ * poller may stop. */
16
+ export declare function isTerminalInstallState(state: InstallState): state is 'done' | 'failed';