Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm run test
- run: pnpm run build
- run: pnpm run guard:shadowed-peer
- run: pnpm pack --dry-run
1 change: 1 addition & 0 deletions .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm run test
- run: pnpm run build
- run: pnpm run guard:shadowed-peer
- name: Pack and checksum
run: |
package="$(pnpm pack --pack-destination release-assets | tail -n 1)"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm run test
- run: pnpm run build
- run: pnpm run guard:shadowed-peer
- name: Pack immutable release artifact
run: |
mkdir -p release-assets
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project are documented here.

## Unreleased

No version bump yet: this is the fix for a load failure that made the plugin disappear on some machines, and it changes no user-facing contract beyond one new advisory warning.

- **`@deepseek-ai/schemastery` is resolved explicitly instead of imported by bare specifier.** The host half used to `import z from '@deepseek-ai/schemastery'`, which Node resolves *from the importing file*, so any copy left inside the plugin's install directory won over the copy DSH ships. That is not theoretical: an unmanaged dev `node_modules` (copied in by a local-directory install, which pnpm never removes) pinned `3.18.2`, which has no `volatile()`, and `lib/index.js` threw `TypeError: …volatile is not a function` while it was being imported — the whole host half was gone before any plugin code ran. `src/host/schemastery.ts` now resolves the peer itself, platform copies first (the running DSH installation, then the profile peer farm at `$DSH_HOME/profiles/node_modules`, then the profile tree), and only then falls back to Node's walk. The first candidate that actually exposes `volatile()` wins, and the capability is verified rather than assumed.
- **A missing `volatile()` degrades the form instead of killing the plugin.** When only a stale copy is reachable the plugin still loads: `Config` is built with ordinary fields, the Host logs the resolution, and the panel shows a new `stale-schemastery` warning (severity `notice`, so the chip never repaints) naming the version, the resolved path and the exact directory to remove. Only a total miss — no schemastery anywhere, which means the plugin is not running inside a working DSH installation — still throws, and it names every candidate it tried.
- **The regression is locked three ways.** `tests/host/schemastery.spec.ts` drives the resolver with injected candidates (platform copy beats a shadowing copy; a stale-only environment loads and reports; unresolvable candidates are skipped; the default order is platform-first, plugin-local last) and adds a source guard that fails if any file under `src/` takes a *value* import of the peer again. `tests/guards/shadowed-peer.mjs` (wired into `pnpm run verify`, CI and both release workflows, after the build) loads the **built** entry with a fake `3.18.2` planted beside it, asserting it still loads and that, with a platform copy present, the platform copy wins.
- **Tests: 114 passing** (up from 98) across 15 files.
- **Unchanged**: the status read, the RPC channel, the client rendering path, the sidebar chip, the detail panel, the LAN fallback and the profile-patch preference model. The three preferences are still the only volatile fields.

## 0.1.8 — 2026-09-25

Verified DeepSeek Harness: `0.1.7-rc.2` (the latest release candidate, and what npm's `next` dist-tag publishes), also `0.1.7-rc.1` and `0.1.7-alpha.2`. Full bilingual release notes: [`docs/releases/v0.1.8.md`](docs/releases/v0.1.8.md).
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ The Host caches one registry response, 360 minutes by default, and only a normal
**Nothing reaches the npm registry.**
The plugin reports the failure and still shows the locally detected running version. Registry access is HTTPS-only to `registry.npmjs.org`; a proxy or offline host produces that warning rather than a wrong version.

**The plugin disappeared after an upgrade, and the host log says `volatile is not a function`.**
Up to `0.1.5` this plugin declared `@deepseek-ai/schemastery` as a regular dependency. From `0.1.6` it is a **peer that DSH provides**, and the `volatile()` DSH adds to it is what turns the three preferences into this entry's settings form. If a copy of that package is left inside the plugin's own install directory — a dev `node_modules` copied in by a local-directory install, which pnpm never removes — Node used to resolve that stale copy, and the missing method threw while the host half was being imported, so the plugin disappeared before it could report anything at all. Newer builds resolve the platform copy explicitly and load either way, and when only a stale copy is reachable the panel shows a `stale-schemastery` notice naming the directory. Remove the leftover copy and restart DSH:

```sh
rm -rf ~/.dsh/profiles/<profile>/node_modules/dsh-update-status/node_modules
```

## Security boundary

- Registry access is limited to HTTPS `registry.npmjs.org`; redirects are rejected.
Expand Down
7 changes: 7 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ Host 会缓存一次 registry 响应,默认 360 分钟,且只有普通读取
**完全连不上 npm registry。**
插件会报告失败,并仍然显示本地检测到的运行版本。registry 访问只允许 HTTPS 的 `registry.npmjs.org`;代理或离线环境会给出这条警告,而不是编造一个版本号。

**升级之后插件整个消失了,宿主日志里是 `volatile is not a function`。**
`0.1.5` 及更早版本把 `@deepseek-ai/schemastery` 声明为普通依赖;从 `0.1.6` 起它是**由 DSH 提供的 peer**,而 DSH 给它加的 `volatile()` 正是把三个偏好变成这个条目设置表单的机制。如果该包的一份旧副本被留在插件自己的安装目录里(用本地目录安装时被一起拷进来的 dev `node_modules`,pnpm 永远不会清理),Node 就会解析到那份旧副本,缺失的方法在宿主半 import 阶段直接抛错,插件在来得及报告任何信息之前就消失了。新版本改为显式解析平台那份,两种情况都能加载;只找得到旧副本时,面板会给出 `stale-schemastery` 提示并指出该删除哪个目录。删除残留目录后重启 DSH:

```sh
rm -rf ~/.dsh/profiles/<profile>/node_modules/dsh-update-status/node_modules
```

## 安全边界

- registry 访问只允许 HTTPS `registry.npmjs.org`,并拒绝重定向。
Expand Down
29 changes: 29 additions & 0 deletions lib/client.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/client.js.map

Large diffs are not rendered by default.

78 changes: 75 additions & 3 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import z from "@deepseek-ai/schemastery";
import { Context } from "@deepseek-ai/cordis";
import z from "@deepseek-ai/schemastery";
//#region src/shared/types.d.ts
declare const RELEASE_CHANNELS: readonly ['latest', 'next', 'alpha'];
type ReleaseChannel = (typeof RELEASE_CHANNELS)[number];
Expand Down Expand Up @@ -38,6 +38,18 @@ type UpdateWarning = {
code: 'preview-unverified';
channel: ReleaseChannel;
version: string;
} |
/**
* The Host resolved a `@deepseek-ai/schemastery` that DSH does not ship, so the
* Loader's volatile projection is unavailable and the preference fields cannot
* be marked volatile. Advisory: the version answer itself is complete, and the
* text carries the exact directory to remove. See `host/schemastery.ts`.
*/
{
code: 'stale-schemastery';
version: string | null;
path: string;
nodeModulesDir: string | null;
};
interface ChannelRelease {
channel: ReleaseChannel;
Expand Down Expand Up @@ -98,7 +110,8 @@ interface UpdateStatusConfig {
* The explicit two-argument annotation is load-bearing: a `.volatile()` field's
* output is a stable reference (`Volatile<T>`) rather than the bare value the
* input side takes, so the inferred schema type cannot be named by the emitted
* `.d.ts` (TS2883) without stating the input side here.
* `.d.ts` (TS2883) without stating the input side here. The builder preserves
* that contract even on the degraded path, where no field is volatile.
*/
export declare const Config: z<UpdateStatusConfig, Record<string, unknown>>;
//#endregion
Expand All @@ -123,13 +136,19 @@ interface UpdateStatusServiceOptions {
now?: () => number;
ttlMs?: number;
releaseUrl?: string;
/**
* Facts about this process's own runtime, appended to every status. The schema
* resolution is the only producer today; the registry read never sets these.
*/
runtimeWarnings?: readonly UpdateWarning[];
}
export declare class UpdateStatusService {
private readonly installation;
private readonly fetchLatest;
private readonly now;
private readonly ttlMs;
private readonly releaseUrl;
private readonly runtimeWarnings;
private cache;
private inFlight;
constructor(options: UpdateStatusServiceOptions);
Expand All @@ -142,11 +161,64 @@ export declare class UpdateStatusService {
private statusWithoutRemoteRelease;
}
//#endregion
//#region src/host/schemastery.d.ts
/** Where a candidate copy of schemastery was looked for. */
type SchemaCandidateSource = 'dsh-install' | 'profile-peers' | 'profile-local' | 'plugin-local';
interface SchemaCandidate {
source: SchemaCandidateSource;
/** Absolute file path `createRequire` anchors on; the file need not exist. */
anchor: string;
}
interface SchemaCandidateReport {
source: SchemaCandidateSource;
anchor: string;
/** Absolute path of the module that loaded, or null when it did not load. */
path: string | null;
version: string | null;
/** The loaded module exposes `volatile()`. */
volatile: boolean;
/** Why this candidate was skipped, or null when it loaded. */
error: string | null;
}
interface SchemaRuntime {
/** The loaded schemastery factory — structurally the package's default export. */
z: unknown;
volatile: boolean;
source: SchemaCandidateSource;
path: string | null;
version: string | null;
/** Every candidate that was tried, in order, for diagnostics. */
candidates: SchemaCandidateReport[];
/** Set when the resolved copy cannot project volatile preferences. */
warning: UpdateWarning | null;
}
interface ResolveSchemaRuntimeOptions {
/** Override the candidate order; used by tests and by an embedding host. */
candidates?: readonly SchemaCandidate[];
}
/**
* Resolve schemastery once, preferring the copy that can actually do the job.
*
* Only a total miss throws, and only with the per-candidate reasons attached:
* that state means the plugin is not running inside a working DSH installation,
* so there is no schema system to build a settings form with. Every other
* outcome loads.
*/
export declare function resolveSchemaRuntime(options?: ResolveSchemaRuntimeOptions): SchemaRuntime;
/**
* The process-wide resolution, computed on first use.
*
* The settings schema is built at module scope — the Loader reads the exported
* `Config` before `apply` runs — so the resolution cannot wait for a Host
* context. Every input here comes from the process environment or the filesystem.
*/
export declare function schemaRuntime(): SchemaRuntime;
//#endregion
//#region src/index.d.ts
export declare const name = "dsh-update-status";
/** Connection supplies the authenticated transport; settings remains optional. */
export declare const inject: string[];
export type Config = UpdateStatusConfig;
export declare function apply(ctx: Context, config?: Config): void;
//#endregion
export type { InstallKind, UpdateStatus };
export type { InstallKind, SchemaRuntime, UpdateStatus };
Loading
Loading