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.
- package/README.md +6 -5
- package/lib/client.js +455 -197
- package/lib/index.js +712 -164
- package/lib/typert.host.js +38 -25
- package/lib/typert.remote-client.js +23 -18
- package/lib/types/client/ShopTab.d.ts +4 -0
- package/lib/types/client/locales.d.ts +20 -2
- package/lib/types/client/present.d.ts +60 -9
- package/lib/types/client/useFlows.d.ts +66 -0
- package/lib/types/client/useInstall.d.ts +17 -27
- package/lib/types/client/useUninstall.d.ts +24 -17
- package/lib/types/client/useUpdateSelf.d.ts +1 -1
- package/lib/types/host/activation.d.ts +55 -0
- package/lib/types/host/client-half.d.ts +28 -0
- package/lib/types/host/dsh-cli.d.ts +11 -0
- package/lib/types/host/executor.d.ts +18 -5
- package/lib/types/host/hot.d.ts +16 -5
- package/lib/types/host/index.d.ts +111 -16
- package/lib/types/host/peers.d.ts +5 -3
- package/lib/types/host/prefetch.d.ts +67 -0
- package/lib/types/shared/install-state.d.ts +16 -0
- package/package.json +1 -1
|
@@ -1,41 +1,31 @@
|
|
|
1
|
-
/** Install driving
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
26
|
-
|
|
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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|
package/lib/types/host/hot.d.ts
CHANGED
|
@@ -48,12 +48,22 @@ export interface PluginHandle {
|
|
|
48
48
|
dispose(): Promise<unknown> | void;
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* Why a
|
|
52
|
-
* copy in the reader's own dsh language.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
/**
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
|
|
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 /
|
|
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
|
-
|
|
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).
|
|
386
|
-
* are never evicted; an id absent from the map reports
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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';
|