/** * The panel's state: one catalog load, a text filter, and which row's command * was just copied. * * A plain snapshot store rather than a framework: the panel has one async * source and three fields, and the renderer only needs `getSnapshot` and * `subscribe`. * @module dsh-research/client/store */ import type { CatalogResponse, InstallResponse, MarketItem, RestartResponse } from '../shared/route.js'; /** What the panel renders. */ export interface MarketState { /** Whether the first load is still in flight. */ readonly loading: boolean; /** Entries from the catalog, in catalog order. */ readonly items: readonly MarketItem[]; /** Catalog revision, shown so a reader can judge freshness. */ readonly revision: string; /** Set when the snapshot is a cached one served after a failed refresh. */ readonly stale: boolean; /** Load failure, if the catalog could not be read at all. */ readonly error?: string; /** Current filter text. */ readonly query: string; /** Id of the row whose command was copied most recently. */ readonly copied?: string; /** Bundles already in the profile, so installed rows say so. */ readonly installed: readonly string[]; /** On-disk version per installed npm name, for comparing with the catalog. */ readonly installedVersions: Readonly>; /** Profile an install mutates, named in the panel so it is never a surprise. */ readonly profile: string; /** npm name currently installing, if any. */ readonly installing?: string; /** When the current mutation started, so the row can show how long it has run. */ readonly startedAt?: number; /** Per-package failure text from the last attempt. */ readonly failures: Readonly>; /** npm names changed during this session, which need a restart to take effect. */ readonly pendingRestart: readonly string[]; /** How far a requested restart has got, once one has been asked for. */ readonly restartPhase?: RestartPhase; /** Where the replacement's output went, for a boot that fails. */ readonly restartLog?: string; /** Whether a terminal owns the host, so only the person there can restart it. */ readonly attached: boolean; /** The command that starts the host again, shown when that person is the reader. */ readonly restartCommand: string; } /** * Where a requested restart got to. * * The phases exist because "restarting…" on its own was a lie by omission: the * host went down, came back detached, and the panel sat on that word forever * while the person watched a terminal that never printed anything again. Each * phase below is something the panel can say that is actually true. */ export type RestartPhase = /** Asked, and waiting to see the host go away and come back. */ 'waiting' /** Confirmed back, and answering on this route again. */ | 'back' /** It went down and did not come back within the budget. */ | 'lost' /** It never went down, so nothing changed. */ | 'stayed' /** Only the person at the terminal can restart it; nothing was done. */ | 'manual'; /** How often the panel probes the host while waiting for it to come back. */ export declare const PROBE_INTERVAL_MS = 700; /** How long the host has to actually exit before the panel says it did not. */ export declare const DOWN_BUDGET_MS = 15000; /** How long the replacement has to answer before the panel says it never did. */ export declare const UP_BUDGET_MS = 90000; /** Minimal snapshot store shape the renderer binds as a hook. */ export interface Store { getSnapshot(): T; subscribe(listener: () => void): () => void; } /** Same-origin fetch of the Node half's catalog route; injectable for tests. */ export type CatalogFetch = (lang: string, signal?: AbortSignal) => Promise; /** POST to the Node half's install route; injectable for tests. */ export type InstallRequest = (npm: string) => Promise; /** * The text a failed mutation shows: the headline, then the CLI tail beneath it. * * Both mutation paths build it here so neither can quietly drop the diagnosis. * The remove path used to take only `error`, which is why an uninstall that * failed because the package was not in the profile reported nothing but * "dsh plugin remove exited 1". * @param answer - the host's reply. * @param fallback - headline for a reply that carries no error string. * @returns headline, and the output after a blank line when there is any. */ export declare function failureText(answer: InstallResponse, fallback: string): string; /** Default installer: `POST /research-market/install` on the page origin. */ export declare const requestInstall: InstallRequest; /** Default remover: `POST /research-market/remove` on the page origin. */ export declare const requestRemove: InstallRequest; /** Ask the host to restart itself. */ export declare const requestRestart: () => Promise; /** * Whether the host is answering this plugin's own route. * * The catalog route rather than the page root, because it answers the question * that matters: a replacement that booted without this plugin is not a * replacement the panel should call "back". * @param lang - which feed to ask for; any answer counts, including an error. * @returns whether the host answered at all. */ export declare const probeHost: (lang: string) => Promise; /** Default fetcher: `GET /research-market/catalog` on the page origin. */ export declare const fetchCatalogRoute: CatalogFetch; /** Create a snapshot store over a mutable value. */ export declare function createStore(initial: T): Store & { set(next: T): void; }; /** * True when `candidate` is a strictly newer release than `current`. * Dotted numeric segments compared left to right; a non-numeric segment * (prerelease tags and the like) compares as 0, erring toward silence — * a missing offer costs a day, a wrong downgrade button costs trust. */ export declare function versionNewer(candidate: string, current: string): boolean; /** The install command for one entry, or `undefined` when npm name is unknown. */ export declare function installCommand(item: MarketItem): string | undefined; /** Case-insensitive filter over the fields a reader would search. */ export declare function filterItems(items: readonly MarketItem[], query: string): readonly MarketItem[]; /** Owns the panel's state and the one request behind it. */ export declare class MarketController { private readonly load; private readonly request; private readonly remove; private readonly restart; private readonly probe; private readonly sleep; private readonly now; readonly store: Store & { set(next: MarketState): void; }; private copyTimer; /** * @param load - catalog fetcher; defaults to the same-origin route. * @param request - installer; defaults to the same-origin route. * @param remove - remover; defaults to the same-origin route. * @param restart - restart requester; defaults to the same-origin route. * @param probe - host liveness probe; injectable so the restart wait is testable. * @param sleep - delay between probes; injectable so tests do not wait. * @param now - clock, for the probe budgets. */ constructor(load?: CatalogFetch, request?: InstallRequest, remove?: InstallRequest, restart?: () => Promise, probe?: (lang: string) => Promise, sleep?: (ms: number) => Promise, now?: () => number); /** Whether this entry is already a bundle in the profile. */ isInstalled(item: MarketItem): boolean; /** * Install one entry. The Node half re-checks the name against the catalog, * so a caller cannot widen what this reaches. * @param item - the catalog entry to install. */ install(item: MarketItem): Promise; /** * The catalog version an installed entry should move to, or undefined when * it is current, ahead, not installed, or either side has no version. * Strictly newer, not merely different: a disk version ahead of the catalog * happens to developers running a linked checkout and to anyone in the * minutes between an npm publish and the feed adopting it — and both were * being shown a "downgrade" button. Converging those cases back to the * catalog helps nobody; the feed catches up on its own within minutes. */ updateFor(item: MarketItem): string | undefined; /** Current snapshot. */ state(): MarketState; /** Entries after the current filter. */ visible(): readonly MarketItem[]; /** Interface language, so the panel reads the catalog feed that matches it. */ private lang; /** * Point later loads at a language's feed. * @param lang - `zh` or anything else, which means English. */ setLanguage(lang: string): void; /** Which feed later loads read. */ language(): string; /** Load the catalog once. Safe to call again; the last answer wins. */ refresh(): Promise; /** Set the filter text. */ search(query: string): void; /** Copy one entry's install command and flag the row briefly. */ copy(item: MarketItem): Promise; /** * Remove one entry from the profile. * @param item - the catalog entry to remove. */ uninstall(item: MarketItem): Promise; /** * Ask the host to restart, so the changes made here take effect, and then * watch until the answer is known. * * The watching is the point. Nothing reloads this page when the host is * replaced — the earlier version set a flag and assumed the page was "about * to go away", which it never was, so the panel showed "restarting…" * indefinitely whatever actually happened. The phases below are each * something the panel can state without guessing. */ restartHost(): Promise; /** * Wait for the host to go away and come back. * * Down first, then up — and in that order for a reason. Probing for "up" * straight away would find the process we just asked to exit, still serving * for its last half-second, and report a restart that has not happened yet. * @returns which of the three things happened. */ private awaitReturn; /** * Probe until the host's liveness matches `want`, or the budget runs out. * @param want - the liveness being waited for. * @param budgetMs - how long to keep asking. * @returns whether it happened inside the budget. */ private until; /** Stop the pending copy reset; called when the section unmounts. */ dispose(): void; }