@morehao/dsh-plugin-kit 0.0.0-stage → 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 morehao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,128 @@
1
- # Temporary Holding Version
1
+ # @morehao/dsh-plugin-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness(dsh)插件共用的工具包。当前提供三件事:
4
+
5
+ - **插件自更新引擎**(`./update`):查 npm 最新版 → 判断能不能一键更新 → 经宿主 `pluginManager` 重装自己。
6
+ - **headless 客户端控制器**(`./update/client`):检查/更新的状态机与单飞,UI 由各插件自己渲染。
7
+ - **HTTP 小工具**(`./http`):自定义头鉴权、`no-store` JSON、有上限的 JSON body。
8
+
9
+ 设计约束:**零 `@deepseek-ai/*` 运行时依赖**,宿主能力(`pluginManager` / `profileContext`)全部由调用方注入;进程相关的策略(比如用哪个包管理器查版本)也由插件自己决定,本包不替你 spawn。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ npm install @morehao/dsh-plugin-kit
15
+ ```
16
+
17
+ ## 能力边界:哪些复用宿主、哪些必须自建
18
+
19
+ 自更新这件事在宿主(`@deepseek-ai/dsh-plugin-manager`)里已有大半能力,本包的原则是**能复用就复用**:
20
+
21
+ | 能力 | 来源 | 说明 |
22
+ |---|---|---|
23
+ | 安装 / 卸载 | **复用宿主** | apply 全程走 `pluginManager.installBundle` / `removeBundle`;`already-installed → remove → install` 就是宿主既定的更新流程,`applied/restart-required/overridden` 也直接来自宿主结果。 |
24
+ | registry 列表(镜像 / 私源 / token) | **复用宿主** | `resolved` 由宿主跑 `pnpm config get registry` 解析,本包不自己读 `.npmrc`。 |
25
+ | registry **排序 / 回退** | **复用宿主(注入)** | 优先用调用方注入的宿主原生 `registryPlan`(`@deepseek-ai/dsh-plugin-manager/registry`),与 `installBundle`/`inspect` 内部同一份源码、含私有源「只问它自己、不回退公有源」语义。本包不直接 import(保持零 `@deepseek-ai/*` 运行时依赖),由插件侧注入。未注入时用内置 `resolveRegistryPlan` 兜底(`resolved → registry → fallback` 宽松顺序,fail-open)。 |
26
+ | 查最新版 | **必须自建** | 宿主 `inspect()` 对「已装包」直接返回 `already-installed`,不会去问 registry;插件查的正是「我自己这个已装包」,必然被拦截。故只能走 `pnpm view`(`runPnpmView`,复用 profile 的包管理器)或 registry `/latest`。 |
27
+ | 安装形态判定 | **必须自建** | 宿主未暴露「本包以什么 spec 声明」;本包读 `<profile.dir>/package.json` 识别 `link:/file:/workspace:` 以拒绝把开发链接覆盖成 npm 版。 |
28
+
29
+ 一句话:**装/卸、registry 取数与排序都复用宿主(排序原生能力通过注入复用);查版本、判形态因宿主 API 缺口而自建;不注入原生 `registryPlan` 时才回落到内置兜底顺序。**
30
+
31
+ ## 一键更新(host)
32
+
33
+ ```ts
34
+ import { createPluginUpdater } from '@morehao/dsh-plugin-kit/update';
35
+ // 宿主原生 registry 排序(与 installBundle/inspect 同一份),由插件侧注入、本包不直接依赖
36
+ import { registryPlan as hostRegistryPlan } from '@deepseek-ai/dsh-plugin-manager/registry';
37
+
38
+ const updater = createPluginUpdater({
39
+ meta: { name: pkg.name, version: pkg.version, ...(pkg.repository ? { repository: repoUrl(pkg.repository) } : {}) },
40
+ getPluginManager: () => ctx.get('pluginManager'),
41
+ getProfile: () => ctx.get('profileContext'),
42
+ getProfileName: () => ctx.get('profileContext')?.name,
43
+ // 可选但推荐:注入宿主原生 registryPlan,使「查版本的 registry 顺序」与宿主安装完全一致
44
+ registryPlan: hostRegistryPlan,
45
+ // 可选:用 profile 的包管理器查版本(继承 .npmrc 的镜像 / 私源 / token)
46
+ runPnpmView: async (spec) => {
47
+ const profile = ctx.get('profileContext');
48
+ const result = await execCapture(profile.packageManager?.command ?? 'pnpm', [...(profile.packageManager?.args ?? []), 'view', spec, 'version', '--json'], {
49
+ cwd: profile.dir,
50
+ env: sanitizedEnv(process.env),
51
+ timeoutMs: 8_000,
52
+ });
53
+ if (result.code !== 0) return null;
54
+ const value = JSON.parse(result.stdout.trim() || 'null');
55
+ return typeof (Array.isArray(value) ? value.at(-1) : value) === 'string' ? (Array.isArray(value) ? value.at(-1) : value) : null;
56
+ },
57
+ logger: (event, detail) => log(event, detail),
58
+ });
59
+
60
+ const view = await updater.check({ fresh: true });
61
+ // { packageName, currentVersion, latestVersion, updateAvailable, checkFailed,
62
+ // canApply, installKind, installSpec?, manualCommand, applying, checkedAt }
63
+
64
+ const result = await updater.apply(); // 需要先 check();只接受刚查到的版本
65
+ // { application: 'applied' | 'restart-required' | 'overridden', restartRequired, updatedVersion, installKind, steps }
66
+ ```
67
+
68
+ `apply()` 的行为与边界:
69
+
70
+ - 只安装 **registry 上刚查到的 latest**,不接受调用方随手给的 spec(防注入)。
71
+ - `link:` / `file:` / `workspace:` 安装一律拒绝(`LINKED_INSTALL`),只给 `manualCommand`——避免把开发链接换成 npm 版。
72
+ - `installBundle` 返回 `already-installed` 时走 `removeBundle → installBundle`;remove 之后装失败会尝试装回原版本,仍失败报 `ROLLBACK_FAILED`。
73
+ - 单飞:同一实例同时只允许一次 `apply()`。
74
+ - 没有 `pluginManager.installBundle` 时报 `NO_MANAGER`,界面应展示 `manualCommand`。
75
+
76
+ 错误码(`PluginUpdateError.code`):`NO_MANAGER` `LINKED_INSTALL` `NOT_CHECKED` `NO_UPDATE` `INVALID_VERSION` `APPLY_IN_PROGRESS` `INSTALL_FAILED` `REMOVE_FAILED` `ROLLBACK_FAILED` `UNSUPPORTED_RESULT`。
77
+
78
+ 固定事件名(便于更新把插件自己卸载后对账):
79
+
80
+ | 事件 | 时机 |
81
+ |---|---|
82
+ | `plugin.update.latest` / `plugin.update.latest.failed` | 查到(或查不到)最新版 |
83
+ | `plugin.update.check` | 一次检查完成 |
84
+ | `plugin.update.apply.start` | 开始安装 |
85
+ | `plugin.update.apply` | 安装成功(带 `application` / `restartRequired`) |
86
+ | `plugin.update.apply.failed` | 安装失败(带 `code` / `steps`) |
87
+
88
+ ## 客户端控制器(headless)
89
+
90
+ ```ts
91
+ import { createUpdateController } from '@morehao/dsh-plugin-kit/update/client';
92
+
93
+ const controller = createUpdateController({
94
+ request: {
95
+ check: ({ fresh }) => api.getUpdate(fresh), // 返回 UpdateClientView
96
+ apply: () => api.applyUpdate(), // 返回 { application, restartRequired? }
97
+ },
98
+ });
99
+ controller.subscribe((state) => render(state));
100
+ await controller.check();
101
+ await controller.apply();
102
+ ```
103
+
104
+ 状态:`idle → checking → available | current | failed`;`available → applying → applied | restart-required | failed`。
105
+ 控制器不做 i18n、不引 React:错误码(`CHECK_FAILED` / `NO_UPDATE` / `NO_APPLY` 或宿主抛的 code)交给插件翻译。
106
+
107
+ ## HTTP 小工具
108
+
109
+ ```ts
110
+ import { hasHeader, readJsonBody, sendJson } from '@morehao/dsh-plugin-kit/http';
111
+
112
+ if (!hasHeader(request, 'x-my-plugin')) return sendJson(response, 403, { ok: false, code: 'FORBIDDEN' });
113
+ const body = await readJsonBody(request, { maxBytes: 512 * 1024 });
114
+ sendJson(response, 200, { ok: true });
115
+ ```
116
+
117
+ ## 版本与兼容
118
+
119
+ - Node `^22.20.0 || >=24.0.0`,纯 ESM。
120
+ - `./update` 与 `./http` 走 host 侧;`./update/client` 只含状态机,不 import node 内置模块(客户端产物只允许 react)。
121
+ - 宿主服务形状按结构化类型处理(`PluginManagerLike` / `ProfileLike`),宿主版本变化不会把插件一起拖下去。
122
+
123
+ ## 开发
124
+
125
+ ```bash
126
+ npm install
127
+ npm run check # typecheck + build + 26 条离线测试(假 fetch / 假 pluginManager,不联网、不真装)
128
+ ```
package/dist/http.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * HTTP 小工具:三个 web 插件共用的「自定义头鉴权 + no-store JSON + 有上限的 JSON body」。
3
+ *
4
+ * 只依赖结构化形状(不 import node:http 类型),宿主的 request/response 直接可用;
5
+ * 鉴权头名称由调用方给,避免把某一个插件的约定强加给其他插件。
6
+ */
7
+ interface RequestLike {
8
+ headers?: Record<string, string | string[] | undefined>;
9
+ }
10
+ interface ResponseLike {
11
+ writeHead(status: number, headers: Record<string, string | number>): unknown;
12
+ end(body?: string): unknown;
13
+ }
14
+ type HttpErrorCode = 'PAYLOAD_TOO_LARGE' | 'INVALID_JSON';
15
+ declare class HttpError extends Error {
16
+ readonly code: HttpErrorCode;
17
+ constructor(code: HttpErrorCode, message: string);
18
+ }
19
+ /** 请求头精确等于 `value`(默认 `1`)才算通过:跨源请求要先过预检,顺带挡 CSRF。 */
20
+ declare function hasHeader(request: RequestLike, header: string, value?: string): boolean;
21
+ declare function sendJson(response: ResponseLike, status: number, payload: unknown): void;
22
+ interface ReadJsonOptions {
23
+ maxBytes?: number;
24
+ }
25
+ /** 读一个 JSON 对象 body;超过上限抛 PAYLOAD_TOO_LARGE,非对象/非法 JSON 抛 INVALID_JSON。 */
26
+ declare function readJsonBody(request: AsyncIterable<Uint8Array | string>, options?: ReadJsonOptions): Promise<Record<string, unknown>>;
27
+
28
+ export { HttpError, type HttpErrorCode, type ReadJsonOptions, type RequestLike, type ResponseLike, hasHeader, readJsonBody, sendJson };
package/dist/http.js ADDED
@@ -0,0 +1,51 @@
1
+ // src/http.ts
2
+ var HttpError = class extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "HttpError";
7
+ this.code = code;
8
+ }
9
+ };
10
+ function hasHeader(request, header, value = "1") {
11
+ return request.headers?.[header.toLowerCase()] === value;
12
+ }
13
+ function sendJson(response, status, payload) {
14
+ const body = JSON.stringify(payload);
15
+ response.writeHead(status, {
16
+ "content-type": "application/json; charset=utf-8",
17
+ "cache-control": "no-store",
18
+ "content-length": Buffer.byteLength(body)
19
+ });
20
+ response.end(body);
21
+ }
22
+ async function readJsonBody(request, options = {}) {
23
+ const maxBytes = options.maxBytes ?? 512 * 1024;
24
+ const chunks = [];
25
+ let size = 0;
26
+ for await (const chunk of request) {
27
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
28
+ size += buffer.length;
29
+ if (size > maxBytes) throw new HttpError("PAYLOAD_TOO_LARGE", "\u8BF7\u6C42\u4F53\u8FC7\u5927");
30
+ chunks.push(buffer);
31
+ }
32
+ if (chunks.length === 0) return {};
33
+ const text = Buffer.concat(chunks).toString("utf8").trim();
34
+ if (text === "") return {};
35
+ let value;
36
+ try {
37
+ value = JSON.parse(text);
38
+ } catch (error) {
39
+ throw new HttpError("INVALID_JSON", `\u8BF7\u6C42\u4F53\u4E0D\u662F JSON \u5BF9\u8C61\uFF1A${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
42
+ throw new HttpError("INVALID_JSON", "\u8BF7\u6C42\u4F53\u4E0D\u662F JSON \u5BF9\u8C61");
43
+ }
44
+ return value;
45
+ }
46
+ export {
47
+ HttpError,
48
+ hasHeader,
49
+ readJsonBody,
50
+ sendJson
51
+ };
@@ -0,0 +1,15 @@
1
+ export { D as DEFAULT_CACHE_TTL_MS, a as DEFAULT_TIMEOUT_MS, b as DetectDeps, H as HostRegistryPlan, I as InstallKind, c as InstallKindResult, O as OFFICIAL_REGISTRY, P as ProfileLike, R as RegistriesLike, d as classifyInstallSpec, e as detectInstallKind, f as encodePackageName, m as manualCommand, n as normalizeProfile, g as normalizeRegistry, r as readDeclaredSpec, h as registryPlan, i as resolveRegistryPlan } from './profile-RKpxKDlK.js';
2
+
3
+ /** 保留旧接口形状:核心三段 + 预发布标识(字符串数组)。 */
4
+ interface Semver {
5
+ core: [number, number, number];
6
+ prerelease: string[];
7
+ }
8
+ /** 解析成 `Semver`;非法(含空串 / `1.2` / `1.2.3.4` / 非版本串)返回 undefined。 */
9
+ declare function parseSemver(value: unknown): Semver | undefined;
10
+ /** `candidate` 是否严格新于 `current`;任一方不是合法 semver 都返回 false。 */
11
+ declare function isNewerVersion(current: unknown, candidate: unknown): boolean;
12
+ /** 比较两个版本:负数 / 0 / 正数;任一方非法返回 undefined。 */
13
+ declare function compareVersions(left: unknown, right: unknown): number | undefined;
14
+
15
+ export { type Semver, compareVersions, isNewerVersion, parseSemver };
package/dist/index.js ADDED
@@ -0,0 +1,180 @@
1
+ // src/semver.ts
2
+ import semver from "semver";
3
+ function parseSemver(value) {
4
+ const text = String(value ?? "").trim();
5
+ if (text === "") return void 0;
6
+ const parsed = semver.parse(text);
7
+ if (parsed === null) return void 0;
8
+ return {
9
+ core: [parsed.major, parsed.minor, parsed.patch],
10
+ // semver 会把纯数字预发布段解析为 number,这里统一转回字符串以保持旧契约。
11
+ prerelease: parsed.prerelease.map((part) => String(part))
12
+ };
13
+ }
14
+ function isNewerVersion(current, candidate) {
15
+ const a = parseSemver(current);
16
+ const b = parseSemver(candidate);
17
+ if (a === void 0 || b === void 0) return false;
18
+ const left = String(current).trim();
19
+ const right = String(candidate).trim();
20
+ return semver.gt(right, left);
21
+ }
22
+ function compareVersions(left, right) {
23
+ const a = parseSemver(left);
24
+ const b = parseSemver(right);
25
+ if (a === void 0 || b === void 0) return void 0;
26
+ return semver.compare(String(left).trim(), String(right).trim());
27
+ }
28
+
29
+ // src/registry.ts
30
+ var OFFICIAL_REGISTRY = "https://registry.npmjs.org/";
31
+ var DEFAULT_TIMEOUT_MS = 8e3;
32
+ var DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
33
+ function encodePackageName(name) {
34
+ const text = name.trim();
35
+ if (text.startsWith("@")) {
36
+ const slash = text.indexOf("/");
37
+ if (slash < 0) return `@${encodeURIComponent(text.slice(1))}`;
38
+ return `@${encodeURIComponent(text.slice(1, slash))}%2F${encodeURIComponent(text.slice(slash + 1))}`;
39
+ }
40
+ return encodeURIComponent(text);
41
+ }
42
+ function normalizeRegistry(value) {
43
+ if (typeof value !== "string" || value.trim() === "") return null;
44
+ const text = value.trim().replace(/\/+$/, "");
45
+ try {
46
+ const url = new URL(`${text}/`);
47
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
48
+ return url.href;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ function registryPlan(registries) {
54
+ const ordered = [];
55
+ const push = (value) => {
56
+ const normalized = normalizeRegistry(value);
57
+ if (normalized !== null && !ordered.includes(normalized)) ordered.push(normalized);
58
+ };
59
+ if (registries !== null && typeof registries === "object") {
60
+ const plan = registries;
61
+ push(plan.resolved);
62
+ push(plan.registry);
63
+ const fallback = plan.fallbackRegistries;
64
+ if (Array.isArray(fallback)) for (const item of fallback) push(item);
65
+ }
66
+ if (ordered.length === 0) ordered.push(OFFICIAL_REGISTRY);
67
+ return ordered;
68
+ }
69
+ function planFromHost(hostPlan, registries) {
70
+ if (registries === null || typeof registries !== "object") return null;
71
+ const ordered = [];
72
+ const push = (value) => {
73
+ const normalized = normalizeRegistry(value);
74
+ if (normalized !== null && !ordered.includes(normalized)) ordered.push(normalized);
75
+ };
76
+ let result;
77
+ try {
78
+ result = hostPlan(void 0, registries);
79
+ } catch {
80
+ return null;
81
+ }
82
+ if (!Array.isArray(result)) return null;
83
+ for (const entry of result) {
84
+ if (entry === null) {
85
+ push(registries.resolved);
86
+ continue;
87
+ }
88
+ push(entry);
89
+ }
90
+ return ordered.length === 0 ? null : ordered;
91
+ }
92
+ function resolveRegistryPlan(registries, hostPlan) {
93
+ if (typeof hostPlan === "function") {
94
+ const fromHost = planFromHost(hostPlan, registries);
95
+ if (fromHost !== null) return fromHost;
96
+ }
97
+ return registryPlan(registries);
98
+ }
99
+ function manualCommand(packageName, profileName) {
100
+ const spec = `${packageName}@latest`;
101
+ return typeof profileName === "string" && profileName !== "" ? `dsh plugin --profile ${profileName} add ${spec}` : `dsh plugin add ${spec}`;
102
+ }
103
+
104
+ // src/profile.ts
105
+ function normalizeProfile(value) {
106
+ if (value === null || typeof value !== "object") return null;
107
+ const raw = value;
108
+ const profile = {};
109
+ if (typeof raw.name === "string" && raw.name !== "") profile.name = raw.name;
110
+ if (typeof raw.dir === "string" && raw.dir !== "") profile.dir = raw.dir;
111
+ if (typeof raw.home === "string" && raw.home !== "") profile.home = raw.home;
112
+ if (raw.packageManager !== null && typeof raw.packageManager === "object") {
113
+ const pm = raw.packageManager;
114
+ const command = typeof pm.command === "string" && pm.command !== "" ? pm.command : void 0;
115
+ const args = Array.isArray(pm.args) ? pm.args.filter((item) => typeof item === "string") : void 0;
116
+ if (command !== void 0 || args !== void 0) {
117
+ profile.packageManager = { ...command === void 0 ? {} : { command }, ...args === void 0 ? {} : { args } };
118
+ }
119
+ }
120
+ return profile;
121
+ }
122
+ function classifyInstallSpec(spec) {
123
+ if (typeof spec !== "string") return "unknown";
124
+ const text = spec.trim();
125
+ if (text === "") return "unknown";
126
+ if (text.startsWith("link:")) return "link";
127
+ if (text.startsWith("file:")) return "file";
128
+ if (text.startsWith("workspace:")) return "workspace";
129
+ return "registry";
130
+ }
131
+ async function defaultReadFile(path) {
132
+ const { readFile } = await import("fs/promises");
133
+ return readFile(path, "utf8");
134
+ }
135
+ async function readDeclaredSpec(deps) {
136
+ const { profile, packageName } = deps;
137
+ if (profile === null || typeof profile.dir !== "string" || profile.dir === "") return {};
138
+ const path = `${profile.dir.replace(/\/+$/, "")}/package.json`;
139
+ const readFile = deps.readFile ?? defaultReadFile;
140
+ const readJson = deps.readJson ?? (async (target) => JSON.parse(await readFile(target)));
141
+ let parsed;
142
+ try {
143
+ parsed = await readJson(path);
144
+ } catch {
145
+ return { source: "profile-package-json-unreadable" };
146
+ }
147
+ if (parsed === null || typeof parsed !== "object") return { source: "profile-package-json-invalid" };
148
+ const pkg = parsed;
149
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
150
+ const block = pkg[field];
151
+ if (block === null || typeof block !== "object") continue;
152
+ const spec = block[packageName];
153
+ if (typeof spec === "string") return { spec, source: `${path}#${field}` };
154
+ }
155
+ return { source: `${path}#no-entry` };
156
+ }
157
+ async function detectInstallKind(deps) {
158
+ const declared = await readDeclaredSpec(deps);
159
+ const result = { kind: classifyInstallSpec(declared.spec) };
160
+ if (declared.spec !== void 0) result.spec = declared.spec;
161
+ if (declared.source !== void 0) result.source = declared.source;
162
+ return result;
163
+ }
164
+ export {
165
+ DEFAULT_CACHE_TTL_MS,
166
+ DEFAULT_TIMEOUT_MS,
167
+ OFFICIAL_REGISTRY,
168
+ classifyInstallSpec,
169
+ compareVersions,
170
+ detectInstallKind,
171
+ encodePackageName,
172
+ isNewerVersion,
173
+ manualCommand,
174
+ normalizeProfile,
175
+ normalizeRegistry,
176
+ parseSemver,
177
+ readDeclaredSpec,
178
+ registryPlan,
179
+ resolveRegistryPlan
180
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * npm registry 约定:把包名拼成 `/latest` 端点,并按宿主给出的 registry 优先级发请求。
3
+ *
4
+ * **registry 列表与排序都优先复用宿主原生能力**(`@deepseek-ai/dsh-plugin-manager/registry` 的
5
+ * `registryPlan(requested, configured)`,与 `installBundle` / `inspect` 内部用的是同一份源码)。
6
+ * 但本包坚持**零 `@deepseek-ai/*` 运行时依赖**(见 package.json / update.ts),所以不直接 import,
7
+ * 而是让调用方把原生函数**注入**进来(见 `HostRegistryPlan` / `LatestDeps.registryPlan`):
8
+ *
9
+ * - 注入了 → 用宿主原生排序(含私有源「只问它自己、不回退公有源」的语义,与安装完全一致);
10
+ * - 没注入 / 调用抛错 / 结果不可用 → 退回本模块内置的 `registryPlan` 兜底(见下)。
11
+ *
12
+ * 为什么查最新版不复用 `pluginManager.inspect()`:`inspect(spec)` 在 registry 分支里先判
13
+ * 「该包是否已在 profile 依赖中」,命中即返回 `already-installed`,**根本不会去问 registry**。
14
+ * 而插件自更新要查的恰恰是「我自己这个已装包有没有新版」,必然命中该拦截。所以查 latest
15
+ * 只能另走 `pnpm view`(见 `latest.ts` 的 `runPnpmView`)或 registry `/latest`,`inspect` 不可替代。
16
+ */
17
+ declare const OFFICIAL_REGISTRY = "https://registry.npmjs.org/";
18
+ declare const DEFAULT_TIMEOUT_MS = 8000;
19
+ declare const DEFAULT_CACHE_TTL_MS: number;
20
+ /**
21
+ * 宿主 `pluginManager.registries()` 的返回形状(对齐 @deepseek-ai/dsh-plugin-manager)。
22
+ *
23
+ * 设计取舍:字段都按可选 + unknown 收。宿主版本差异或字段改名时,内置兜底 `registryPlan` 用
24
+ * `normalizeRegistry` 逐个校验,非法项直接跳过并最终退到官方源,**绝不因形状不符而抛错**
25
+ * (fail-open:宁可退化到官方源,也不让自更新检查因宿主形状变化而崩)。
26
+ */
27
+ interface RegistriesLike {
28
+ /** pnpm 在 profile 内解析出的实际 registry(继承 .npmrc),非空时最优先。 */
29
+ resolved?: unknown;
30
+ /** 配置里显式的 registry(可能为 null,表示「pnpm 自身配置的源」)。 */
31
+ registry?: unknown;
32
+ /** 回退源;宿主默认含 npmmirror。 */
33
+ fallbackRegistries?: unknown;
34
+ }
35
+ /**
36
+ * 宿主原生 `registryPlan` 的注入形状,对齐 `@deepseek-ai/dsh-plugin-manager/registry`。
37
+ *
38
+ * 调用方(各插件)自己 `import { registryPlan } from '@deepseek-ai/dsh-plugin-manager/registry'`
39
+ * 后注入本包,即可让「查最新版的 registry 顺序」与宿主安装使用**完全一致**的排序与私有源语义。
40
+ *
41
+ * 入参/返回:
42
+ * - `requested`:首位要问的 registry;`undefined` 表示交给宿主按 configured 首项决定。
43
+ * - `configured`:`registries()` 的返回值(resolved / registry / fallbackRegistries)。
44
+ * - 返回:依次要问的 registry 数组,**元素可能为 `null`**(表示 pnpm 自身配置的源);宿主实现
45
+ * 在入参非法时会**抛错**,所以本包调用处已包 try/catch 兜底。
46
+ */
47
+ type HostRegistryPlan = (requested: string | undefined, configured: RegistriesLike) => Array<string | null>;
48
+ /** npm 包名转 registry 路径:`@scope/name` → `@scope%2Fname`(保留 @,斜杠编码)。 */
49
+ declare function encodePackageName(name: string): string;
50
+ /** registry 归一成 `https://host/` 形式;非法(非 http(s) / 解析失败)返回 null。 */
51
+ declare function normalizeRegistry(value: unknown): string | null;
52
+ /**
53
+ * 内置兜底:把 `registries()` 的结果收敛成「查 latest 时要依次试的 registry 顺序」:
54
+ * `resolved → registry → fallback`,去重;全非法/缺失时退到官方 registry,**绝不抛**。
55
+ *
56
+ * 这是**注入不到宿主原生 `registryPlan` 时的简化兜底**,不是宿主安装计划的等价物:它不区分
57
+ * 公有/私有源。因此若 `resolved` 是私有源而其上查不到本包(镜像未同步),兜底会继续去问
58
+ * fallback / 官方源拿 latest;而宿主真正安装时可能只问私有源并失败。差异只影响「能否查到版本」,
59
+ * 不影响实际安装走哪条路。想与宿主机安装完全对齐,请注入原生 `registryPlan`(见 `HostRegistryPlan`)。
60
+ */
61
+ declare function registryPlan(registries: unknown): string[];
62
+ /**
63
+ * 解析「查 latest 时要依次试的 registry 顺序」:
64
+ * **优先用宿主原生 `registryPlan`**(注入且成功时,排序/私有源语义与宿主机安装完全一致),
65
+ * 否则退回内置 `registryPlan` 兜底(fail-open,保证查询永远有一条可走的路)。
66
+ *
67
+ * @param registries 宿主 `registries()` 的返回值(任意形状,内部按 unknown 校验)。
68
+ * @param hostPlan 可选的宿主原生 `registryPlan`(由调用方从 dsh-plugin-manager/registry 注入)。
69
+ */
70
+ declare function resolveRegistryPlan(registries: unknown, hostPlan?: HostRegistryPlan): string[];
71
+ /** 手动更新命令:`dsh plugin --profile <name> add <pkg>@latest`。 */
72
+ declare function manualCommand(packageName: string, profileName?: string | null): string;
73
+
74
+ /**
75
+ * profile 读取:判断本插件是以什么形态装进当前 profile。
76
+ *
77
+ * 一键更新最危险的场景是「开发用的本地链接」:profile 里写的是 `link:/path/to/repo`,
78
+ * 一旦用 registry 重装,本地的 link 会被换成 npm 版,开发流就被悄悄改掉了。所以
79
+ * 更新前先读 `<profile.dir>/package.json` 的依赖声明,link: / file: / workspace: 一律
80
+ * 关闭一键更新,只给手动命令。
81
+ */
82
+ type InstallKind = 'registry' | 'link' | 'file' | 'workspace' | 'unknown';
83
+ interface ProfileLike {
84
+ name?: string;
85
+ dir?: string;
86
+ home?: string;
87
+ packageManager?: {
88
+ command?: string;
89
+ args?: string[];
90
+ };
91
+ }
92
+ interface InstallKindResult {
93
+ kind: InstallKind;
94
+ /** profile package.json 里声明的原始 spec(读不到就是 undefined)。 */
95
+ spec?: string;
96
+ /** 说明来源,便于日志与界面提示。 */
97
+ source?: string;
98
+ }
99
+ /** 把宿主 `profileContext`(任意形状)收敛成只含我们关心的字段;拿不到返回 null。 */
100
+ declare function normalizeProfile(value: unknown): ProfileLike | null;
101
+ /** `dependencies` 里声明的 spec 决定安装形态;`npm:` 别名仍按 registry 处理。 */
102
+ declare function classifyInstallSpec(spec: unknown): InstallKind;
103
+ interface DetectDeps {
104
+ profile: ProfileLike | null;
105
+ packageName: string;
106
+ /** 测试注入;默认读真实文件。 */
107
+ readFile?: (path: string) => Promise<string>;
108
+ readJson?: (path: string) => Promise<unknown>;
109
+ }
110
+ /** 读 profile 的 package.json,返回本插件声明的 spec(找不到返回 undefined)。 */
111
+ declare function readDeclaredSpec(deps: DetectDeps): Promise<{
112
+ spec?: string;
113
+ source?: string;
114
+ }>;
115
+ declare function detectInstallKind(deps: DetectDeps): Promise<InstallKindResult>;
116
+
117
+ export { DEFAULT_CACHE_TTL_MS as D, type HostRegistryPlan as H, type InstallKind as I, OFFICIAL_REGISTRY as O, type ProfileLike as P, type RegistriesLike as R, DEFAULT_TIMEOUT_MS as a, type DetectDeps as b, type InstallKindResult as c, classifyInstallSpec as d, detectInstallKind as e, encodePackageName as f, normalizeRegistry as g, registryPlan as h, resolveRegistryPlan as i, manualCommand as m, normalizeProfile as n, readDeclaredSpec as r };
@@ -0,0 +1,57 @@
1
+ import { UpdateView, ApplyResult } from './update.js';
2
+ import './profile-RKpxKDlK.js';
3
+
4
+ /**
5
+ * 客户端侧 headless 控制器(不依赖 React、不 import node 内置模块)。
6
+ *
7
+ * 各插件的 UI 长得不一样,共享「状态机 + 单飞 + 错误收敛」就够了:
8
+ * 插件用自己的 React / h() 订阅 `subscribe()`,按 `status` 渲染按钮与进度,
9
+ * 文案与样式留在各插件里(错误码由插件翻译,不在这里塞 i18n)。
10
+ *
11
+ * 状态:idle → checking → available | current | failed
12
+ * available → applying → applied | restart-required | failed
13
+ */
14
+
15
+ type UpdateClientStatus = 'idle' | 'checking' | 'available' | 'current' | 'failed' | 'applying' | 'applied' | 'restart-required';
16
+ /** 只保留界面要用的字段,便于测试与跨版本兼容。 */
17
+ type UpdateClientView = Pick<UpdateView, 'packageName' | 'currentVersion' | 'latestVersion' | 'updateAvailable' | 'checkFailed' | 'canApply' | 'installKind' | 'manualCommand'> & {
18
+ applying?: boolean;
19
+ installSpec?: string;
20
+ };
21
+ interface UpdateClientError {
22
+ code?: string;
23
+ message: string;
24
+ }
25
+ interface UpdateClientState {
26
+ status: UpdateClientStatus;
27
+ view: UpdateClientView | null;
28
+ error: UpdateClientError | null;
29
+ /** 最近一次检查完成时间(ISO);没检查过就是 undefined。 */
30
+ checkedAt?: string;
31
+ }
32
+ interface UpdateRequest {
33
+ check(options: {
34
+ fresh?: boolean;
35
+ signal?: AbortSignal;
36
+ }): Promise<UpdateClientView>;
37
+ apply(): Promise<Pick<ApplyResult, 'application' | 'updatedVersion'> & {
38
+ restartRequired?: boolean;
39
+ }>;
40
+ }
41
+ interface UpdateControllerOptions {
42
+ request: UpdateRequest;
43
+ onChange?: (state: UpdateClientState) => void;
44
+ now?: () => number;
45
+ }
46
+ interface UpdateController {
47
+ getState(): UpdateClientState;
48
+ subscribe(listener: (state: UpdateClientState) => void): () => void;
49
+ check(options?: {
50
+ fresh?: boolean;
51
+ }): Promise<void>;
52
+ apply(): Promise<void>;
53
+ reset(): void;
54
+ }
55
+ declare function createUpdateController(options: UpdateControllerOptions): UpdateController;
56
+
57
+ export { type UpdateClientError, type UpdateClientState, type UpdateClientStatus, type UpdateClientView, type UpdateController, type UpdateControllerOptions, type UpdateRequest, createUpdateController };
@@ -0,0 +1,86 @@
1
+ // src/update-client.ts
2
+ var IDLE = { status: "idle", view: null, error: null };
3
+ function errorOf(error) {
4
+ if (error !== null && typeof error === "object") {
5
+ const value = error;
6
+ const message = typeof value.message === "string" && value.message !== "" ? value.message : "\u64CD\u4F5C\u5931\u8D25";
7
+ return typeof value.code === "string" ? { code: value.code, message } : { message };
8
+ }
9
+ return { message: String(error ?? "\u64CD\u4F5C\u5931\u8D25") };
10
+ }
11
+ function createUpdateController(options) {
12
+ const now = options.now ?? Date.now;
13
+ let state = { ...IDLE };
14
+ const listeners = /* @__PURE__ */ new Set();
15
+ let inFlight = null;
16
+ function emit(next) {
17
+ state = next;
18
+ options.onChange?.(state);
19
+ for (const listener of listeners) listener(state);
20
+ }
21
+ async function check(checkOptions = {}) {
22
+ if (inFlight !== null) return;
23
+ inFlight = "check";
24
+ emit({ ...state, status: "checking", error: null });
25
+ try {
26
+ const view = await options.request.check(checkOptions);
27
+ const checkedAt = new Date(now()).toISOString();
28
+ if (view.checkFailed) {
29
+ emit({
30
+ status: "failed",
31
+ view,
32
+ error: { code: "CHECK_FAILED", message: "\u68C0\u67E5\u66F4\u65B0\u5931\u8D25\uFF1A\u62FF\u4E0D\u5230 registry \u4E0A\u7684\u6700\u65B0\u7248\u672C" },
33
+ checkedAt
34
+ });
35
+ } else {
36
+ emit({ status: view.updateAvailable ? "available" : "current", view, error: null, checkedAt });
37
+ }
38
+ } catch (error) {
39
+ emit({ ...state, status: "failed", error: errorOf(error) });
40
+ } finally {
41
+ inFlight = null;
42
+ }
43
+ }
44
+ async function apply() {
45
+ if (inFlight !== null) return;
46
+ const view = state.view;
47
+ if (view === null || !view.updateAvailable) {
48
+ emit({ ...state, status: "failed", error: { code: "NO_UPDATE", message: "\u6CA1\u6709\u53EF\u66F4\u65B0\u7684\u7248\u672C\uFF0C\u8BF7\u5148\u68C0\u67E5\u66F4\u65B0" } });
49
+ return;
50
+ }
51
+ if (!view.canApply) {
52
+ emit({
53
+ ...state,
54
+ status: "failed",
55
+ error: { code: "NO_APPLY", message: `\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u4E00\u952E\u66F4\u65B0\uFF0C\u8BF7\u6267\u884C\uFF1A${view.manualCommand}` }
56
+ });
57
+ return;
58
+ }
59
+ inFlight = "apply";
60
+ emit({ ...state, status: "applying", error: null });
61
+ try {
62
+ const result = await options.request.apply();
63
+ const restartRequired = result.restartRequired ?? result.application !== "applied";
64
+ emit({ ...state, status: restartRequired ? "restart-required" : "applied", error: null });
65
+ } catch (error) {
66
+ emit({ ...state, status: "failed", error: errorOf(error) });
67
+ } finally {
68
+ inFlight = null;
69
+ }
70
+ }
71
+ return {
72
+ getState: () => state,
73
+ subscribe: (listener) => {
74
+ listeners.add(listener);
75
+ return () => {
76
+ listeners.delete(listener);
77
+ };
78
+ },
79
+ check,
80
+ apply,
81
+ reset: () => emit({ ...IDLE })
82
+ };
83
+ }
84
+ export {
85
+ createUpdateController
86
+ };
@@ -0,0 +1,113 @@
1
+ import { I as InstallKind, H as HostRegistryPlan } from './profile-RKpxKDlK.js';
2
+ export { P as ProfileLike } from './profile-RKpxKDlK.js';
3
+
4
+ /**
5
+ * 查 npm 上某个包的最新版本。
6
+ *
7
+ * 顺序(可配置):
8
+ * 1. 调用方注入的 `runPnpmView`(让插件用自己 profile 的包管理器查,继承 .npmrc 的
9
+ * 镜像 / 私源 / token;宿主内裸 fetch 读不到这些配置)。**这是复用宿主包管理器**:
10
+ * 查询在 profile 目录、profile 的 packageManager 环境下执行,与宿主安装同源。
11
+ * 2. 宿主 `pluginManager.registries()` 给出的 registry 列表,依次问 `<base><pkg>/latest`;
12
+ * 排序**优先用调用方注入的宿主原生 `registryPlan`**(与安装完全一致),未注入则内置兜底
13
+ * (见 `registry.ts` 的 `resolveRegistryPlan`);
14
+ * 3. 都没有就用官方 registry。
15
+ *
16
+ * **为什么不用宿主的 `pluginManager.inspect()` 查最新版**:`inspect()` 在 registry 分支会先判
17
+ * 「该包是否已在 profile 依赖中」,命中即返回 `already-installed`,不会真正去问 registry。
18
+ * 插件自更新要查的正是「我自己这个已装包」,必然命中该拦截,所以此处只能走 `pnpm view` / `/latest`。
19
+ *
20
+ * 全部失败返回 null(不抛):调用方用它标记 `checkFailed`,界面照常可用。注意「查不到」≠「宿主缺失」,
21
+ * 而是网络 / registry / 包管理器任一环节不可用时的运行时降级。
22
+ */
23
+
24
+ type Logger = (event: string, detail?: Record<string, unknown>) => void;
25
+ interface PluginMeta {
26
+ name: string;
27
+ version: string;
28
+ repository?: string;
29
+ }
30
+
31
+ /**
32
+ * 插件自更新的 host 引擎:查最新版 → 判定能否一键更新 → 经宿主 `pluginManager` 重装。
33
+ *
34
+ * 设计约束(三个 DSH 插件共用这份逻辑):
35
+ * - **零 `@deepseek-ai/*` 运行时依赖**:pluginManager / profileContext 都由调用方用
36
+ * `ctx.get(...)` 惰性取。本包定位就是 DSH 插件,宿主必然在;但 `installBundle` 是
37
+ * 可选/远程能力,取不到只是「当前部署未暴露该能力」,退化成「只检查 + 手动命令」。
38
+ * - **复用优先**:registry 顺序优先用调用方注入的宿主原生 `registryPlan`(与宿主安装完全一致,
39
+ * 含私有源语义),未注入则内置兜底(见 `registry.ts` 的 `resolveRegistryPlan`);查最新版走
40
+ * `pnpm view` / registry `/latest`(`inspect()` 对已装包返回 already-installed,不可替代)。
41
+ * - **不自作主张升级**:只有 registry 明确给出合法且严格更新的版本才允许安装;
42
+ * `link:` / `file:` / `workspace:` 安装一律禁止一键更新(会把开发链接换成 npm 版)。
43
+ * - **装不上要能回退**:宿主 `installBundle` 对已装包会返回 `already-installed`,按官方
44
+ * 更新路径先 remove 再 install;remove 之后 install 失败则尝试装回原版本。
45
+ * - **不重写宿主进程策略**:需要 `pnpm view` 时由调用方注入 `runPnpmView`(进程/环境
46
+ * 白名单是各插件自己的事),本包只做 registry 兜底。
47
+ * - 更新会把运行中的插件自己卸载,界面会一起消失:全过程落固定事件名,便于事后对账。
48
+ */
49
+
50
+ type UpdateErrorCode = 'NO_MANAGER' | 'LINKED_INSTALL' | 'NOT_CHECKED' | 'NO_UPDATE' | 'INVALID_VERSION' | 'APPLY_IN_PROGRESS' | 'INSTALL_FAILED' | 'REMOVE_FAILED' | 'ROLLBACK_FAILED' | 'UNSUPPORTED_RESULT';
51
+ declare class PluginUpdateError extends Error {
52
+ readonly code: UpdateErrorCode;
53
+ readonly detail: Record<string, unknown>;
54
+ constructor(code: UpdateErrorCode, message: string, detail?: Record<string, unknown>);
55
+ }
56
+ interface UpdateView {
57
+ packageName: string;
58
+ currentVersion: string;
59
+ latestVersion: string | null;
60
+ updateAvailable: boolean;
61
+ /** 查询失败(网络 / registry 都不可用),不等于「已是最新」。 */
62
+ checkFailed: boolean;
63
+ canApply: boolean;
64
+ installKind: InstallKind;
65
+ installSpec?: string;
66
+ manualCommand: string;
67
+ applying: boolean;
68
+ checkedAt?: string;
69
+ }
70
+ interface ApplyResult {
71
+ application: 'applied' | 'restart-required' | 'overridden';
72
+ restartRequired: boolean;
73
+ updatedVersion: string;
74
+ installKind: InstallKind;
75
+ /** 实际走过的步骤,给界面/日志做进度展示。 */
76
+ steps: string[];
77
+ }
78
+ interface PluginManagerLike {
79
+ installBundle?: (spec: string) => Promise<unknown>;
80
+ removeBundle?: (name: string) => Promise<unknown>;
81
+ registries?: () => unknown;
82
+ }
83
+ interface UpdaterDeps {
84
+ meta: PluginMeta;
85
+ getPluginManager?: () => unknown;
86
+ getProfile?: () => unknown;
87
+ getProfileName?: () => string | null;
88
+ fetchImpl?: typeof fetch;
89
+ runPnpmView?: (spec: string) => Promise<string | null>;
90
+ /** 可选:注入宿主原生 `registryPlan`(dsh-plugin-manager/registry),让查版本的 registry 顺序与安装一致。 */
91
+ registryPlan?: HostRegistryPlan;
92
+ readFile?: (path: string) => Promise<string>;
93
+ logger?: Logger;
94
+ now?: () => number;
95
+ cacheTtlMs?: number;
96
+ timeoutMs?: number;
97
+ }
98
+ interface Updater {
99
+ check(options?: {
100
+ fresh?: boolean;
101
+ }): Promise<UpdateView>;
102
+ view(): UpdateView | null;
103
+ latestVersion(options?: {
104
+ fresh?: boolean;
105
+ }): Promise<string | null>;
106
+ apply(options?: {
107
+ version?: string;
108
+ }): Promise<ApplyResult>;
109
+ isApplying(): boolean;
110
+ }
111
+ declare function createPluginUpdater(deps: UpdaterDeps): Updater;
112
+
113
+ export { type ApplyResult, HostRegistryPlan, InstallKind, type Logger, type PluginManagerLike, type PluginMeta, PluginUpdateError, type UpdateErrorCode, type UpdateView, type Updater, type UpdaterDeps, createPluginUpdater };
package/dist/update.js ADDED
@@ -0,0 +1,465 @@
1
+ // src/registry.ts
2
+ var OFFICIAL_REGISTRY = "https://registry.npmjs.org/";
3
+ var DEFAULT_TIMEOUT_MS = 8e3;
4
+ var DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
5
+ function encodePackageName(name) {
6
+ const text = name.trim();
7
+ if (text.startsWith("@")) {
8
+ const slash = text.indexOf("/");
9
+ if (slash < 0) return `@${encodeURIComponent(text.slice(1))}`;
10
+ return `@${encodeURIComponent(text.slice(1, slash))}%2F${encodeURIComponent(text.slice(slash + 1))}`;
11
+ }
12
+ return encodeURIComponent(text);
13
+ }
14
+ function normalizeRegistry(value) {
15
+ if (typeof value !== "string" || value.trim() === "") return null;
16
+ const text = value.trim().replace(/\/+$/, "");
17
+ try {
18
+ const url = new URL(`${text}/`);
19
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
20
+ return url.href;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function registryPlan(registries) {
26
+ const ordered = [];
27
+ const push = (value) => {
28
+ const normalized = normalizeRegistry(value);
29
+ if (normalized !== null && !ordered.includes(normalized)) ordered.push(normalized);
30
+ };
31
+ if (registries !== null && typeof registries === "object") {
32
+ const plan = registries;
33
+ push(plan.resolved);
34
+ push(plan.registry);
35
+ const fallback = plan.fallbackRegistries;
36
+ if (Array.isArray(fallback)) for (const item of fallback) push(item);
37
+ }
38
+ if (ordered.length === 0) ordered.push(OFFICIAL_REGISTRY);
39
+ return ordered;
40
+ }
41
+ function planFromHost(hostPlan, registries) {
42
+ if (registries === null || typeof registries !== "object") return null;
43
+ const ordered = [];
44
+ const push = (value) => {
45
+ const normalized = normalizeRegistry(value);
46
+ if (normalized !== null && !ordered.includes(normalized)) ordered.push(normalized);
47
+ };
48
+ let result;
49
+ try {
50
+ result = hostPlan(void 0, registries);
51
+ } catch {
52
+ return null;
53
+ }
54
+ if (!Array.isArray(result)) return null;
55
+ for (const entry of result) {
56
+ if (entry === null) {
57
+ push(registries.resolved);
58
+ continue;
59
+ }
60
+ push(entry);
61
+ }
62
+ return ordered.length === 0 ? null : ordered;
63
+ }
64
+ function resolveRegistryPlan(registries, hostPlan) {
65
+ if (typeof hostPlan === "function") {
66
+ const fromHost = planFromHost(hostPlan, registries);
67
+ if (fromHost !== null) return fromHost;
68
+ }
69
+ return registryPlan(registries);
70
+ }
71
+ function manualCommand(packageName, profileName) {
72
+ const spec = `${packageName}@latest`;
73
+ return typeof profileName === "string" && profileName !== "" ? `dsh plugin --profile ${profileName} add ${spec}` : `dsh plugin add ${spec}`;
74
+ }
75
+
76
+ // src/semver.ts
77
+ import semver from "semver";
78
+ function parseSemver(value) {
79
+ const text = String(value ?? "").trim();
80
+ if (text === "") return void 0;
81
+ const parsed = semver.parse(text);
82
+ if (parsed === null) return void 0;
83
+ return {
84
+ core: [parsed.major, parsed.minor, parsed.patch],
85
+ // semver 会把纯数字预发布段解析为 number,这里统一转回字符串以保持旧契约。
86
+ prerelease: parsed.prerelease.map((part) => String(part))
87
+ };
88
+ }
89
+ function isNewerVersion(current, candidate) {
90
+ const a = parseSemver(current);
91
+ const b = parseSemver(candidate);
92
+ if (a === void 0 || b === void 0) return false;
93
+ const left = String(current).trim();
94
+ const right = String(candidate).trim();
95
+ return semver.gt(right, left);
96
+ }
97
+
98
+ // src/latest.ts
99
+ function createLatestResolver(deps) {
100
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
101
+ const cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
102
+ const now = deps.now ?? Date.now;
103
+ const logger = deps.logger ?? (() => {
104
+ });
105
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
106
+ let cache = null;
107
+ function manager() {
108
+ try {
109
+ const value = deps.getPluginManager?.();
110
+ return value !== null && typeof value === "object" ? value : null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+ async function readRegistries() {
116
+ const service = manager();
117
+ if (service === null || typeof service.registries !== "function") return null;
118
+ try {
119
+ return await service.registries();
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ function remember(version, source) {
125
+ cache = { version, expiresAt: now() + cacheTtlMs, source };
126
+ return version;
127
+ }
128
+ async function viaPnpm() {
129
+ if (typeof deps.runPnpmView !== "function") return null;
130
+ try {
131
+ const value = await deps.runPnpmView(`${deps.meta.name}@latest`);
132
+ const version = typeof value === "string" ? value.trim() : "";
133
+ return parseSemver(version) === void 0 ? null : version;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+ async function viaRegistries() {
139
+ const plan = resolveRegistryPlan(await readRegistries(), deps.registryPlan);
140
+ for (const base of plan) {
141
+ try {
142
+ const response = await fetchImpl(`${base}${encodePackageName(deps.meta.name)}/latest`, {
143
+ headers: { accept: "application/json" },
144
+ redirect: "error",
145
+ signal: AbortSignal.timeout(timeoutMs)
146
+ });
147
+ if (!response.ok) continue;
148
+ const value = await response.json();
149
+ const version = typeof value.version === "string" ? value.version.trim() : "";
150
+ if (parseSemver(version) !== void 0) return { version, source: base };
151
+ } catch {
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+ async function latest(options = {}) {
157
+ if (deps.meta.name.trim() === "") return null;
158
+ if (options.fresh === true) cache = null;
159
+ if (cache !== null && now() < cache.expiresAt) return cache.version;
160
+ const pnpm = await viaPnpm();
161
+ if (pnpm !== null) {
162
+ logger("plugin.update.latest", { source: "pnpm", version: pnpm });
163
+ return remember(pnpm, "pnpm");
164
+ }
165
+ const registry = await viaRegistries();
166
+ if (registry !== null) {
167
+ logger("plugin.update.latest", { source: registry.source, version: registry.version });
168
+ return remember(registry.version, registry.source);
169
+ }
170
+ logger("plugin.update.latest.failed", { package: deps.meta.name });
171
+ return null;
172
+ }
173
+ return { latest, peek: () => cache === null ? null : { ...cache } };
174
+ }
175
+
176
+ // src/profile.ts
177
+ function normalizeProfile(value) {
178
+ if (value === null || typeof value !== "object") return null;
179
+ const raw = value;
180
+ const profile = {};
181
+ if (typeof raw.name === "string" && raw.name !== "") profile.name = raw.name;
182
+ if (typeof raw.dir === "string" && raw.dir !== "") profile.dir = raw.dir;
183
+ if (typeof raw.home === "string" && raw.home !== "") profile.home = raw.home;
184
+ if (raw.packageManager !== null && typeof raw.packageManager === "object") {
185
+ const pm = raw.packageManager;
186
+ const command = typeof pm.command === "string" && pm.command !== "" ? pm.command : void 0;
187
+ const args = Array.isArray(pm.args) ? pm.args.filter((item) => typeof item === "string") : void 0;
188
+ if (command !== void 0 || args !== void 0) {
189
+ profile.packageManager = { ...command === void 0 ? {} : { command }, ...args === void 0 ? {} : { args } };
190
+ }
191
+ }
192
+ return profile;
193
+ }
194
+ function classifyInstallSpec(spec) {
195
+ if (typeof spec !== "string") return "unknown";
196
+ const text = spec.trim();
197
+ if (text === "") return "unknown";
198
+ if (text.startsWith("link:")) return "link";
199
+ if (text.startsWith("file:")) return "file";
200
+ if (text.startsWith("workspace:")) return "workspace";
201
+ return "registry";
202
+ }
203
+ async function defaultReadFile(path) {
204
+ const { readFile } = await import("fs/promises");
205
+ return readFile(path, "utf8");
206
+ }
207
+ async function readDeclaredSpec(deps) {
208
+ const { profile, packageName } = deps;
209
+ if (profile === null || typeof profile.dir !== "string" || profile.dir === "") return {};
210
+ const path = `${profile.dir.replace(/\/+$/, "")}/package.json`;
211
+ const readFile = deps.readFile ?? defaultReadFile;
212
+ const readJson = deps.readJson ?? (async (target) => JSON.parse(await readFile(target)));
213
+ let parsed;
214
+ try {
215
+ parsed = await readJson(path);
216
+ } catch {
217
+ return { source: "profile-package-json-unreadable" };
218
+ }
219
+ if (parsed === null || typeof parsed !== "object") return { source: "profile-package-json-invalid" };
220
+ const pkg = parsed;
221
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
222
+ const block = pkg[field];
223
+ if (block === null || typeof block !== "object") continue;
224
+ const spec = block[packageName];
225
+ if (typeof spec === "string") return { spec, source: `${path}#${field}` };
226
+ }
227
+ return { source: `${path}#no-entry` };
228
+ }
229
+ async function detectInstallKind(deps) {
230
+ const declared = await readDeclaredSpec(deps);
231
+ const result = { kind: classifyInstallSpec(declared.spec) };
232
+ if (declared.spec !== void 0) result.spec = declared.spec;
233
+ if (declared.source !== void 0) result.source = declared.source;
234
+ return result;
235
+ }
236
+
237
+ // src/update.ts
238
+ var PluginUpdateError = class extends Error {
239
+ code;
240
+ detail;
241
+ constructor(code, message, detail = {}) {
242
+ super(message);
243
+ this.name = "PluginUpdateError";
244
+ this.code = code;
245
+ this.detail = detail;
246
+ }
247
+ };
248
+ function unwrapResult(result) {
249
+ if (result === null || typeof result !== "object") return {};
250
+ const value = result;
251
+ if (typeof value.ok === "boolean" && !("application" in value) && ("value" in value || "error" in value)) {
252
+ return value.ok ? unwrapResult(value.value) : { error: asError(value.error) };
253
+ }
254
+ const unwrapped = {};
255
+ if ("application" in value) unwrapped.application = value.application;
256
+ if ("error" in value && value.error !== void 0 && value.error !== null) unwrapped.error = asError(value.error);
257
+ return unwrapped;
258
+ }
259
+ function asError(value) {
260
+ if (value !== null && typeof value === "object") return value;
261
+ return { message: String(value ?? "") };
262
+ }
263
+ function failureMessage(error) {
264
+ if (error === void 0) return "";
265
+ for (const key of ["diagnostic", "message", "code"]) {
266
+ const value = error[key];
267
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
268
+ }
269
+ return "";
270
+ }
271
+ var APPLICATIONS = /* @__PURE__ */ new Set(["applied", "restart-required", "overridden"]);
272
+ function createPluginUpdater(deps) {
273
+ const logger = deps.logger ?? (() => {
274
+ });
275
+ const now = deps.now ?? Date.now;
276
+ const resolver = createLatestResolver({
277
+ meta: deps.meta,
278
+ ...deps.getPluginManager === void 0 ? {} : { getPluginManager: deps.getPluginManager },
279
+ ...deps.fetchImpl === void 0 ? {} : { fetchImpl: deps.fetchImpl },
280
+ ...deps.runPnpmView === void 0 ? {} : { runPnpmView: deps.runPnpmView },
281
+ ...deps.registryPlan === void 0 ? {} : { registryPlan: deps.registryPlan },
282
+ ...deps.now === void 0 ? {} : { now: deps.now },
283
+ ...deps.cacheTtlMs === void 0 ? {} : { cacheTtlMs: deps.cacheTtlMs },
284
+ ...deps.timeoutMs === void 0 ? {} : { timeoutMs: deps.timeoutMs },
285
+ logger
286
+ });
287
+ let lastView = null;
288
+ let applying = false;
289
+ function manager() {
290
+ try {
291
+ const value = deps.getPluginManager?.();
292
+ return value !== null && typeof value === "object" ? value : null;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+ function profile() {
298
+ try {
299
+ return normalizeProfile(deps.getProfile?.());
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+ function profileName() {
305
+ try {
306
+ const fromDep = deps.getProfileName?.();
307
+ if (typeof fromDep === "string" && fromDep !== "") return fromDep;
308
+ return profile()?.name ?? null;
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+ function canApplyWith(kind) {
314
+ const service = manager();
315
+ if (service === null || typeof service.installBundle !== "function") return false;
316
+ return kind === "registry" || kind === "unknown";
317
+ }
318
+ async function check(options = {}) {
319
+ const install = await detectInstallKind({
320
+ profile: profile(),
321
+ packageName: deps.meta.name,
322
+ ...deps.readFile === void 0 ? {} : { readFile: deps.readFile }
323
+ });
324
+ const latest = await resolver.latest(options.fresh === true ? { fresh: true } : {});
325
+ const view = {
326
+ packageName: deps.meta.name,
327
+ currentVersion: deps.meta.version,
328
+ latestVersion: latest,
329
+ updateAvailable: latest !== null && isNewerVersion(deps.meta.version, latest),
330
+ checkFailed: latest === null,
331
+ canApply: canApplyWith(install.kind),
332
+ installKind: install.kind,
333
+ ...install.spec === void 0 ? {} : { installSpec: install.spec },
334
+ manualCommand: manualCommand(deps.meta.name, profileName()),
335
+ applying,
336
+ checkedAt: new Date(now()).toISOString()
337
+ };
338
+ lastView = view;
339
+ logger("plugin.update.check", {
340
+ current: view.currentVersion,
341
+ latest: view.latestVersion,
342
+ updateAvailable: view.updateAvailable,
343
+ checkFailed: view.checkFailed,
344
+ canApply: view.canApply,
345
+ installKind: view.installKind
346
+ });
347
+ return view;
348
+ }
349
+ async function apply(options = {}) {
350
+ if (applying) throw new PluginUpdateError("APPLY_IN_PROGRESS", "\u5DF2\u6709\u4E00\u6B21\u66F4\u65B0\u5728\u8FDB\u884C\u4E2D");
351
+ const view = lastView;
352
+ if (view === null) {
353
+ throw new PluginUpdateError("NOT_CHECKED", "\u8FD8\u6CA1\u6709\u68C0\u67E5\u8FC7\u66F4\u65B0\uFF0C\u8BF7\u5148\u68C0\u67E5\u518D\u66F4\u65B0");
354
+ }
355
+ if (view.checkFailed || view.latestVersion === null) {
356
+ throw new PluginUpdateError("NOT_CHECKED", "\u8FD8\u6CA1\u6709\u62FF\u5230\u53EF\u7528\u7684\u6700\u65B0\u7248\u672C\uFF0C\u8BF7\u5148\u68C0\u67E5\u66F4\u65B0", { checkFailed: view.checkFailed });
357
+ }
358
+ const target = view.latestVersion;
359
+ if (options.version !== void 0 && options.version !== target) {
360
+ throw new PluginUpdateError("INVALID_VERSION", `\u53EA\u80FD\u66F4\u65B0\u5230\u521A\u67E5\u5230\u7684 ${target}\uFF0C\u4E0D\u63A5\u53D7 ${options.version}`, {
361
+ requested: options.version,
362
+ target
363
+ });
364
+ }
365
+ if (!view.updateAvailable || !isNewerVersion(deps.meta.version, target)) {
366
+ throw new PluginUpdateError("NO_UPDATE", `\u5F53\u524D v${deps.meta.version} \u5DF2\u662F\u6700\u65B0\uFF08registry: ${target}\uFF09`);
367
+ }
368
+ if (parseSemver(target) === void 0) {
369
+ throw new PluginUpdateError("INVALID_VERSION", `registry \u8FD4\u56DE\u7684\u7248\u672C\u4E0D\u662F\u5408\u6CD5 semver\uFF1A${target}`);
370
+ }
371
+ const install = await detectInstallKind({
372
+ profile: profile(),
373
+ packageName: deps.meta.name,
374
+ ...deps.readFile === void 0 ? {} : { readFile: deps.readFile }
375
+ });
376
+ if (install.kind === "link" || install.kind === "file" || install.kind === "workspace") {
377
+ throw new PluginUpdateError(
378
+ "LINKED_INSTALL",
379
+ `\u5F53\u524D\u662F\u672C\u5730 ${install.kind} \u5B89\u88C5\uFF08${install.spec ?? "\u672A\u77E5 spec"}\uFF09\uFF0C\u4E0D\u80FD\u7528 registry \u7248\u672C\u8986\u76D6\uFF1B\u8BF7\u624B\u52A8\u6267\u884C ${view.manualCommand}`,
380
+ { installKind: install.kind, spec: install.spec }
381
+ );
382
+ }
383
+ const service = manager();
384
+ if (service === null || typeof service.installBundle !== "function") {
385
+ throw new PluginUpdateError("NO_MANAGER", "\u5F53\u524D profile \u672A\u63D0\u4F9B\u4E00\u952E\u5B89\u88C5\u80FD\u529B\uFF08pluginManager.installBundle \u4E0D\u53EF\u7528\uFF09\uFF0C\u8BF7\u624B\u52A8\u66F4\u65B0", {
386
+ manualCommand: view.manualCommand
387
+ });
388
+ }
389
+ const installBundle = service.installBundle.bind(service);
390
+ const removeBundle = typeof service.removeBundle === "function" ? service.removeBundle.bind(service) : null;
391
+ const spec = `${deps.meta.name}@${target}`;
392
+ const steps = [];
393
+ applying = true;
394
+ lastView = { ...view, applying: true };
395
+ logger("plugin.update.apply.start", { from: deps.meta.version, target, installKind: install.kind });
396
+ try {
397
+ steps.push(`install ${spec}`);
398
+ let installed = unwrapResult(await installBundle(spec));
399
+ let removed = false;
400
+ if (installed.error?.code === "already-installed" && removeBundle !== null) {
401
+ steps.push(`remove ${deps.meta.name}`);
402
+ const removal = unwrapResult(await removeBundle(deps.meta.name));
403
+ if (removal.error !== void 0 || removal.application === "failed") {
404
+ throw new PluginUpdateError("REMOVE_FAILED", `\u79FB\u9664\u65E7\u7248\u672C\u5931\u8D25\uFF1A${failureMessage(removal.error)}`, { steps });
405
+ }
406
+ removed = true;
407
+ steps.push(`install ${spec}`);
408
+ installed = unwrapResult(await installBundle(spec));
409
+ }
410
+ if (installed.error !== void 0) {
411
+ const message = failureMessage(installed.error) || "\u5B89\u88C5\u65B0\u7248\u672C\u5931\u8D25";
412
+ if (removed) {
413
+ steps.push(`rollback ${deps.meta.name}@${deps.meta.version}`);
414
+ let rollback;
415
+ try {
416
+ rollback = unwrapResult(await installBundle(`${deps.meta.name}@${deps.meta.version}`));
417
+ } catch (error) {
418
+ throw new PluginUpdateError("ROLLBACK_FAILED", `\u66F4\u65B0\u5931\u8D25\u4E14\u56DE\u6EDA\u5931\u8D25\uFF1A${message}`, {
419
+ steps,
420
+ cause: error instanceof Error ? error.message : String(error)
421
+ });
422
+ }
423
+ if (rollback.error !== void 0) {
424
+ throw new PluginUpdateError("ROLLBACK_FAILED", `\u66F4\u65B0\u5931\u8D25\u4E14\u56DE\u6EDA\u5931\u8D25\uFF1A${message}`, {
425
+ steps,
426
+ rollback: failureMessage(rollback.error)
427
+ });
428
+ }
429
+ }
430
+ throw new PluginUpdateError("INSTALL_FAILED", `\u5B89\u88C5 v${target} \u5931\u8D25\uFF1A${message}`, { steps });
431
+ }
432
+ const application = typeof installed.application === "string" ? installed.application : "unknown";
433
+ if (!APPLICATIONS.has(application)) {
434
+ throw new PluginUpdateError("UNSUPPORTED_RESULT", `\u66F4\u65B0\u672A\u751F\u6548\uFF08application=${application}\uFF09`, { steps });
435
+ }
436
+ const result = {
437
+ application,
438
+ restartRequired: application !== "applied",
439
+ updatedVersion: target,
440
+ installKind: install.kind,
441
+ steps
442
+ };
443
+ logger("plugin.update.apply", { ...result });
444
+ return result;
445
+ } catch (error) {
446
+ const normalized = error instanceof PluginUpdateError ? error : new PluginUpdateError("INSTALL_FAILED", error instanceof Error ? error.message : String(error), { steps });
447
+ logger("plugin.update.apply.failed", { code: normalized.code, message: normalized.message, steps });
448
+ throw normalized;
449
+ } finally {
450
+ applying = false;
451
+ if (lastView !== null) lastView = { ...lastView, applying: false };
452
+ }
453
+ }
454
+ return {
455
+ check,
456
+ view: () => lastView,
457
+ latestVersion: (options = {}) => resolver.latest(options),
458
+ apply,
459
+ isApplying: () => applying
460
+ };
461
+ }
462
+ export {
463
+ PluginUpdateError,
464
+ createPluginUpdater
465
+ };
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@morehao/dsh-plugin-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "DeepSeek Harness 插件共用工具包:插件自更新引擎(查 npm 最新版 + 经宿主 pluginManager 重装)、headless 客户端控制器与 HTTP 小工具。零 @deepseek-ai 运行时依赖,宿主能力全部注入。",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./update": {
14
+ "types": "./dist/update.d.ts",
15
+ "default": "./dist/update.js"
16
+ },
17
+ "./update/client": {
18
+ "types": "./dist/update-client.d.ts",
19
+ "default": "./dist/update-client.js"
20
+ },
21
+ "./http": {
22
+ "types": "./dist/http.d.ts",
23
+ "default": "./dist/http.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE",
31
+ "package.json"
32
+ ],
33
+ "engines": {
34
+ "node": "^22.20.0 || >=24.0.0"
35
+ },
36
+ "keywords": [
37
+ "dsh",
38
+ "dsh-plugin",
39
+ "deepseek-harness",
40
+ "plugin-update",
41
+ "self-update"
42
+ ],
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "node --test \"test/*.test.mjs\"",
47
+ "pretest": "npm run build",
48
+ "check": "npm run typecheck && npm run build && npm test --ignore-scripts"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "22.20.5",
52
+ "@types/semver": "^7.8.0",
53
+ "tsup": "8.5.1",
54
+ "typescript": "5.9.3"
55
+ },
56
+ "license": "MIT",
57
+ "author": "morehao <morehao@qq.com>",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/morehao/dsh-plugin-kit.git"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/morehao/dsh-plugin-kit/issues"
64
+ },
65
+ "homepage": "https://github.com/morehao/dsh-plugin-kit",
66
+ "publishConfig": {
67
+ "access": "public",
68
+ "registry": "https://registry.npmjs.org/"
69
+ },
70
+ "dependencies": {
71
+ "semver": "^7.8.5"
72
+ }
73
+ }