@morehao/dsh-plugin-kit 0.2.1 → 0.3.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/README.md CHANGED
@@ -1,12 +1,17 @@
1
1
  # @morehao/dsh-plugin-kit
2
2
 
3
- DeepSeek Harness(dsh)插件共用的工具包。当前提供三件事:
3
+ DeepSeek Harness(dsh)插件共用的工具包。当前提供这些能力:
4
4
 
5
5
  - **插件自更新引擎**(`./update`):查 npm 最新版 → 判断能不能一键更新 → 经宿主 `pluginManager` 重装自己。
6
6
  - **headless 客户端控制器**(`./update/client`):检查/更新的状态机与单飞,UI 由各插件自己渲染。
7
- - **HTTP 小工具**(`./http`):自定义头鉴权、`no-store` JSON、有上限的 JSON body。
7
+ - **HTTP 小工具**(`./http`):自定义头鉴权、`no-store` JSON、有上限的 JSON body,以及薄路由 dispatcher(`createRouteHandler`)。
8
+ - **子进程执行**(`./exec`):argv 直传(不经 shell)、环境白名单、超时/取消、输出上限,外加 `pnpm view` 版本解析。
9
+ - **原子写**(`./fs`):`writeFileAtomicSync` / `writeFileAtomic`,杜绝「半个文件」。
10
+ - **JSON 状态持久化**(`./state`):`$DSH_HOME` 解析 + `<home>/<plugin>/*.json` 读写(读 fail-open、写抛错)。
11
+ - **GitHub 工具**(`./github`):仓库地址规范化 + Release 正文读取(带 TTL 缓存)。
12
+ - **插件身份**(`./meta`):从自家 `package.json` 读 name / version / repository。
8
13
 
9
- 设计约束:**零 `@deepseek-ai/*` 运行时依赖**,宿主能力(`pluginManager` / `profileContext`)全部由调用方注入;进程相关的策略(比如用哪个包管理器查版本)也由插件自己决定,本包不替你 spawn。
14
+ 设计约束:**零 `@deepseek-ai/*` 运行时依赖**,宿主能力(`pluginManager` / `profileContext`)全部由调用方注入。进程策略由调用方把握:`./update` 常驻能力不主动 spawn,需要 `pnpm view` 时由调用方注入 `runPnpmView`;也可直接用 `./exec`(或 `createPnpmViewRunner`,默认走 `./exec`、可注入 `exec` 替换进程策略)。
10
15
 
11
16
  ## 安装
12
17
 
@@ -107,22 +112,131 @@ await controller.apply();
107
112
  ## HTTP 小工具
108
113
 
109
114
  ```ts
110
- import { hasHeader, readJsonBody, sendJson } from '@morehao/dsh-plugin-kit/http';
115
+ import { hasHeader, readJsonBody, sendJson, createRouteHandler } from '@morehao/dsh-plugin-kit/http';
111
116
 
112
117
  if (!hasHeader(request, 'x-my-plugin')) return sendJson(response, 403, { ok: false, code: 'FORBIDDEN' });
113
118
  const body = await readJsonBody(request, { maxBytes: 512 * 1024 });
114
119
  sendJson(response, 200, { ok: true });
120
+
121
+ // 薄路由 dispatcher:统一 鉴权 → 剥前缀 → 读 body → 包 {ok:true} → 错误兜底。
122
+ // 不做路由表 DSL / 错误码翻译——matchRoute 与错误码映射留在你的 dispatch / onError 里。
123
+ const handler = createRouteHandler({
124
+ prefix: '/api/morehao/my-plugin',
125
+ header: 'x-dsh-my-plugin',
126
+ dispatch: async ({ method, pathname, body }) => {
127
+ if (method === 'GET' && pathname === '/state') return { state: getState() };
128
+ throw Object.assign(new Error('not found'), { code: 'NOT_FOUND' });
129
+ },
130
+ onError: (error) => ({ status: error.code === 'NOT_FOUND' ? 404 : 500, code: error.code, message: error.message }),
131
+ });
132
+ ```
133
+
134
+ ## 子进程执行(`./exec`)
135
+
136
+ argv 直传、不经 shell;超时/取消先 SIGTERM、宽限后 SIGKILL;输出有上限且保留尾部;`spawn` 失败**不 reject**,落到 `error` 字段。
137
+
138
+ ```ts
139
+ import { spawnCollect, sanitizedEnv, collectVersionView } from '@morehao/dsh-plugin-kit/exec';
140
+
141
+ const result = await spawnCollect('git', ['rev-parse', 'HEAD'], {
142
+ cwd: repo,
143
+ env: sanitizedEnv(process.env), // 只放行 PATH/HOME/代理/证书/locale/git 身份等白名单
144
+ timeoutMs: 8_000,
145
+ });
146
+ // { code, signal, stdout, stderr, output, timedOut, aborted, durationMs, error? }
147
+
148
+ // 从 `pnpm view <spec> version --json` 的输出里取版本串(单值或数组取尾;非法 → null)
149
+ const version = collectVersionView({ code: result.code, stdout: result.stdout });
150
+ ```
151
+
152
+ ## 原子写(`./fs`)
153
+
154
+ 同目录临时文件 → fsync → rename → fsync(父目录);Windows rename 有界重试;失败清理临时文件,原文件在 rename 前不被截断。
155
+
156
+ ```ts
157
+ import { writeFileAtomicSync, writeFileAtomic } from '@morehao/dsh-plugin-kit/fs';
158
+
159
+ writeFileAtomicSync('/path/state.json', `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
160
+ await writeFileAtomic('/path/state.json', content); // 同语义异步版
161
+ ```
162
+
163
+ `mode` 省略时沿用已有文件权限,新文件走 `defaultMode`(默认 `0o644`)。
164
+
165
+ ## JSON 状态持久化(`./state`)
166
+
167
+ `$DSH_HOME` 解析 + `<home>/<plugin>/*.json` 读写。**读 fail-open**(缺失/坏 JSON/非对象 → `fallback()`,永不抛);**写抛错**(写通常是一次事务,静默吞掉会掩盖数据丢失,需要「尽力而为」的调用方自行 try/catch)。
168
+
169
+ ```ts
170
+ import { resolveDshHome, createJsonStore } from '@morehao/dsh-plugin-kit/state';
171
+
172
+ const store = createJsonStore({
173
+ dir: `${resolveDshHome()}/my-plugin`, // $DSH_HOME → ~/.dsh
174
+ file: 'state.json',
175
+ fallback: () => ({ lastSeenVersion: '' }), // 工厂函数,避免共享可变引用
176
+ });
177
+ const state = store.read(); // 坏文件也返回 fallback
178
+ store.write({ lastSeenVersion: '0.3.0' }); // 原子写
115
179
  ```
116
180
 
181
+ ## 复用 `pnpm view` 查版本(`./update`)
182
+
183
+ 三仓此前各手写一遍的「读 profile → 拼 `pnpm view` → 解析 → 校验」,收敛为一个工厂:
184
+
185
+ ```ts
186
+ import { createPnpmViewRunner, createPluginUpdater } from '@morehao/dsh-plugin-kit/update';
187
+
188
+ const runPnpmView = createPnpmViewRunner({
189
+ getProfile: () => ctx.get('profileContext'),
190
+ // 可选:注入自定义 exec 以沿用本插件更细的进程策略;默认走 ./exec
191
+ // exec: myExecCapture,
192
+ });
193
+ const updater = createPluginUpdater({ meta, getPluginManager: () => ctx.get('pluginManager'), runPnpmView });
194
+ ```
195
+
196
+ 拿不到 `profile.dir` / 命令失败 / 输出非法 / 版本不是合法 semver → 返回 `null`(交由 registry 兜底)。
197
+
198
+ ## GitHub 工具(`./github`)
199
+
200
+ 仓库地址规范化 + Release 正文读取(收敛各插件重复的正则与拉取逻辑):
201
+
202
+ ```ts
203
+ import { normalizeRepositoryUrl, repoSlug, createReleaseReader } from '@morehao/dsh-plugin-kit/github';
204
+
205
+ normalizeRepositoryUrl('git@github.com:o/r.git'); // → 'https://github.com/o/r'
206
+ repoSlug('https://github.com/o/r'); // → 'o/r'
207
+
208
+ const reader = createReleaseReader({ repo: 'o/r' }); // ttlMs / failureTtlMs / timeoutMs / fetchImpl 可注入
209
+ const info = await reader.release('1.2.3'); // { version, body, htmlUrl, name } | null
210
+ // 拉 https://api.github.com/repos/o/r/releases/tags/v1.2.3;成功与失败都做 TTL 缓存,失败 → null(不抛)
211
+ ```
212
+
213
+ 本模块只依赖 `./semver`,**不 import 任何 node 内置**。
214
+
215
+ ## 插件身份(`./meta`)
216
+
217
+ 从自家 `package.json` 读出 name / version / repository(收敛三仓的 `PLUGIN_META` / `readPluginInfo`):
218
+
219
+ ```ts
220
+ import { createPluginMeta, readInstalledVersion } from '@morehao/dsh-plugin-kit/meta';
221
+
222
+ const pkgUrl = new URL('../package.json', import.meta.url); // 层级由插件自己给
223
+ const meta = createPluginMeta({ pkgUrl, defaultName: '@you/plugin' });
224
+ // { name, version, repository?: 'https://github.com/o/r', repo: 'o/r' | null }
225
+
226
+ readInstalledVersion({ pkgUrl }); // 每次重读(更新后同进程读到新值);失败 → ''
227
+ ```
228
+
229
+ 只在 host 侧使用(含 `node:fs`),不被 `./update/client` 引用。
230
+
117
231
  ## 版本与兼容
118
232
 
119
233
  - Node `^22.20.0 || >=24.0.0`,纯 ESM。
120
- - `./update` 与 `./http` 走 host 侧;`./update/client` 只含状态机,不 import node 内置模块(客户端产物只允许 react)。
234
+ - `./update`、`./http`、`./exec`、`./fs`、`./state`、`./github`、`./meta` 走 host 侧;`./update/client` 只含状态机,不 import node 内置模块(客户端产物只允许 react)。
121
235
  - 宿主服务形状按结构化类型处理(`PluginManagerLike` / `ProfileLike`),宿主版本变化不会把插件一起拖下去。
122
236
 
123
237
  ## 开发
124
238
 
125
239
  ```bash
126
240
  npm install
127
- npm run check # typecheck + build + 26 条离线测试(假 fetch / 假 pluginManager,不联网、不真装)
241
+ npm run check # typecheck + build + 67 条离线测试(假 fetch / 假 pluginManager / 假 exec,不联网、不真装)
128
242
  ```
package/dist/exec.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ interface ExecResult {
2
+ code: number | null;
3
+ signal: NodeJS.Signals | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ /** stdout + stderr 交错合并后的结果(同 dsh-commands 的 `output`)。 */
7
+ output: string;
8
+ timedOut: boolean;
9
+ aborted: boolean;
10
+ durationMs: number;
11
+ /** spawn 本身失败(ENOENT / EACCES 等)时带出;此时 `code === null` 且不 reject。 */
12
+ error?: Error;
13
+ }
14
+ /**
15
+ * 从 `process.env` 里挑出白名单键 + 调用方额外给的键,拼成子进程环境。
16
+ * 空字符串视为未设置(避免把 `KEY=` 传给子进程,部分工具会当成空值处理)。
17
+ */
18
+ declare function sanitizedEnv(source?: NodeJS.ProcessEnv, extra?: Record<string, string | undefined>): Record<string, string>;
19
+ interface ExecOptions {
20
+ cwd?: string;
21
+ /** 省略时继承 `process.env`;要清洗时先过 `sanitizedEnv` 再传进来。 */
22
+ env?: NodeJS.ProcessEnv;
23
+ signal?: AbortSignal;
24
+ /** 超时毫秒;默认 10_000。传 0 / Infinity / 非正数表示不设超时。 */
25
+ timeoutMs?: number;
26
+ /** SIGTERM 之后到 SIGKILL 的宽限;默认 2_000。 */
27
+ abortGraceMs?: number;
28
+ /** stdout 上限(保留尾部);默认 1 MiB。 */
29
+ maxStdout?: number;
30
+ /** stderr 上限(保留尾部);默认 1 MiB。 */
31
+ maxStderr?: number;
32
+ /** 合并 `output` 的上限(保留尾部);默认 = maxStdout + maxStderr。 */
33
+ maxOutput?: number;
34
+ /** 写入子进程 stdin(写完即关闭);省略则 stdin = 'ignore'。 */
35
+ input?: string;
36
+ /** 原始分片旁路(去 ANSI 由调用方决定);回调抛错会被吞,不影响采集。 */
37
+ onData?: (text: string, channel: 'stdout' | 'stderr') => void;
38
+ }
39
+ /**
40
+ * 执行一次命令并收集输出。永不 reject:进程级失败落到 `error` 字段。
41
+ */
42
+ declare function spawnCollect(command: string, args?: string[], options?: ExecOptions): Promise<ExecResult>;
43
+ /**
44
+ * 从 `pnpm view <spec> version --json` 的 stdout 取版本字符串。
45
+ * - 成功:可能返回单值或数组(多版本时 pnpm 给数组),取最后一个非空字符串;
46
+ * - 空输出 / 非 JSON / 取不到字符串 → null。
47
+ *
48
+ * **不在这里做 semver 合法性校验**(避免 `./exec` 依赖 `./semver`),由调用方用 `parseSemver` 判定。
49
+ */
50
+ declare function collectVersionView(result: Pick<ExecResult, 'code' | 'stdout'>): string | null;
51
+
52
+ export { type ExecOptions, type ExecResult, collectVersionView, sanitizedEnv, spawnCollect };
package/dist/exec.js ADDED
@@ -0,0 +1,171 @@
1
+ // src/exec.ts
2
+ import { spawn } from "child_process";
3
+ var ENV_KEYS = [
4
+ "PATH",
5
+ "HOME",
6
+ "USER",
7
+ "LOGNAME",
8
+ "SHELL",
9
+ "TMPDIR",
10
+ "TMP",
11
+ "TEMP",
12
+ "LANG",
13
+ "LC_ALL",
14
+ "LC_CTYPE",
15
+ "SSL_CERT_FILE",
16
+ "SSL_CERT_DIR",
17
+ "GIT_AUTHOR_NAME",
18
+ "GIT_AUTHOR_EMAIL",
19
+ "GIT_COMMITTER_NAME",
20
+ "GIT_COMMITTER_EMAIL",
21
+ "GIT_CONFIG_GLOBAL",
22
+ "GIT_SSH_COMMAND"
23
+ ];
24
+ var PROXY_KEYS = [
25
+ "HTTP_PROXY",
26
+ "HTTPS_PROXY",
27
+ "NO_PROXY",
28
+ "ALL_PROXY",
29
+ "http_proxy",
30
+ "https_proxy",
31
+ "no_proxy",
32
+ "all_proxy"
33
+ ];
34
+ function sanitizedEnv(source = process.env, extra = {}) {
35
+ const env = {};
36
+ for (const key of [...ENV_KEYS, ...PROXY_KEYS]) {
37
+ const value = source[key];
38
+ if (typeof value === "string" && value !== "") env[key] = value;
39
+ }
40
+ for (const [key, value] of Object.entries(extra)) {
41
+ if (typeof value === "string" && value !== "") env[key] = value;
42
+ }
43
+ return env;
44
+ }
45
+ function keepTail(text, limit) {
46
+ if (!Number.isFinite(limit) || limit <= 0 || text.length <= limit) return text;
47
+ return text.slice(text.length - limit);
48
+ }
49
+ async function spawnCollect(command, args = [], options = {}) {
50
+ const started = Date.now();
51
+ const timeoutMs = options.timeoutMs ?? 1e4;
52
+ const abortGraceMs = options.abortGraceMs ?? 2e3;
53
+ const maxStdout = options.maxStdout ?? 1024 * 1024;
54
+ const maxStderr = options.maxStderr ?? 1024 * 1024;
55
+ const maxOutput = options.maxOutput ?? maxStdout + maxStderr;
56
+ let stdout = "";
57
+ let stderr = "";
58
+ let output = "";
59
+ let timedOut = false;
60
+ return new Promise((resolve) => {
61
+ let child;
62
+ try {
63
+ child = spawn(command, args, {
64
+ cwd: options.cwd,
65
+ env: options.env ?? process.env,
66
+ stdio: [options.input === void 0 ? "ignore" : "pipe", "pipe", "pipe"],
67
+ windowsHide: true
68
+ });
69
+ } catch (error) {
70
+ resolve(failureResult(error, started, options.signal?.aborted === true));
71
+ return;
72
+ }
73
+ if (options.input !== void 0) {
74
+ child.stdin?.on("error", () => void 0);
75
+ child.stdin?.end(options.input);
76
+ }
77
+ let settled = false;
78
+ let timer;
79
+ let hardKill;
80
+ const killTree = (signal) => {
81
+ try {
82
+ child.kill(signal);
83
+ } catch {
84
+ }
85
+ };
86
+ const softKill = () => {
87
+ killTree("SIGTERM");
88
+ hardKill = setTimeout(() => killTree("SIGKILL"), abortGraceMs);
89
+ hardKill.unref?.();
90
+ };
91
+ const onAbort = () => softKill();
92
+ const finish = (code, signal, error) => {
93
+ if (settled) return;
94
+ settled = true;
95
+ if (timer !== void 0) clearTimeout(timer);
96
+ if (hardKill !== void 0) clearTimeout(hardKill);
97
+ options.signal?.removeEventListener("abort", onAbort);
98
+ resolve({
99
+ code,
100
+ signal,
101
+ stdout,
102
+ stderr,
103
+ output,
104
+ timedOut,
105
+ aborted: options.signal?.aborted === true,
106
+ durationMs: Date.now() - started,
107
+ ...error === void 0 ? {} : { error }
108
+ });
109
+ };
110
+ child.stdout?.setEncoding("utf8");
111
+ child.stdout?.on("data", (chunk) => {
112
+ stdout = keepTail(stdout + chunk, maxStdout);
113
+ output = keepTail(output + chunk, maxOutput);
114
+ try {
115
+ options.onData?.(chunk, "stdout");
116
+ } catch {
117
+ }
118
+ });
119
+ child.stderr?.setEncoding("utf8");
120
+ child.stderr?.on("data", (chunk) => {
121
+ stderr = keepTail(stderr + chunk, maxStderr);
122
+ output = keepTail(output + chunk, maxOutput);
123
+ try {
124
+ options.onData?.(chunk, "stderr");
125
+ } catch {
126
+ }
127
+ });
128
+ if (options.signal?.aborted === true) onAbort();
129
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
130
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
131
+ timer = setTimeout(() => {
132
+ timedOut = true;
133
+ softKill();
134
+ }, timeoutMs);
135
+ }
136
+ child.on("error", (error) => finish(null, null, error));
137
+ child.on("close", (code, signalName) => finish(code, signalName));
138
+ });
139
+ }
140
+ function failureResult(error, started, aborted) {
141
+ const err = error instanceof Error ? error : new Error(String(error));
142
+ return {
143
+ code: null,
144
+ signal: null,
145
+ stdout: "",
146
+ stderr: "",
147
+ output: err.message,
148
+ timedOut: false,
149
+ aborted,
150
+ durationMs: Date.now() - started,
151
+ error: err
152
+ };
153
+ }
154
+ function collectVersionView(result) {
155
+ if (result.code !== 0) return null;
156
+ const text = result.stdout.trim();
157
+ if (text === "") return null;
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(text);
161
+ } catch {
162
+ return null;
163
+ }
164
+ const value = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
165
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
166
+ }
167
+ export {
168
+ collectVersionView,
169
+ sanitizedEnv,
170
+ spawnCollect
171
+ };
package/dist/fs.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ interface WriteFileAtomicOptions {
2
+ /** 目标权限位;省略时沿用已有文件权限,新文件用 `defaultMode`。 */
3
+ mode?: number;
4
+ /** 新文件默认权限位;已有文件仍沿用其原权限。默认 0o644。 */
5
+ defaultMode?: number;
6
+ }
7
+ /** 同步原子整文件替换(`mkdir → open('wx') → write → fsync → rename → fsync(dir)`)。 */
8
+ declare function writeFileAtomicSync(file: string, content: string, options?: WriteFileAtomicOptions): void;
9
+ /** 异步原子整文件替换,语义与同步版一致(`fs/promises`)。 */
10
+ declare function writeFileAtomic(file: string, content: string, options?: WriteFileAtomicOptions): Promise<void>;
11
+
12
+ export { type WriteFileAtomicOptions, writeFileAtomic, writeFileAtomicSync };
package/dist/fs.js ADDED
@@ -0,0 +1,119 @@
1
+ // src/fs.ts
2
+ import fs from "fs";
3
+ import fsp from "fs/promises";
4
+ import path from "path";
5
+ import { randomBytes } from "crypto";
6
+ var WINDOWS_TRANSIENT_RENAME = /* @__PURE__ */ new Set(["EACCES", "EBUSY", "EPERM"]);
7
+ var RENAME_RETRY_DELAYS_MS = [20, 40, 80, 160, 200, 200, 200, 200];
8
+ function resolveMode(file, options) {
9
+ if (typeof options.mode === "number") return options.mode;
10
+ try {
11
+ return fs.statSync(file).mode & 511;
12
+ } catch {
13
+ return options.defaultMode ?? 420;
14
+ }
15
+ }
16
+ function tempPath(dir, base) {
17
+ return path.join(dir, `.${base}.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}.tmp`);
18
+ }
19
+ function sleepSync(ms) {
20
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
21
+ }
22
+ function renameWithRetrySync(from, to) {
23
+ if (process.platform !== "win32") {
24
+ fs.renameSync(from, to);
25
+ return;
26
+ }
27
+ for (let attempt = 0; ; attempt += 1) {
28
+ try {
29
+ fs.renameSync(from, to);
30
+ return;
31
+ } catch (error) {
32
+ const code = error?.code;
33
+ if (code === void 0 || !WINDOWS_TRANSIENT_RENAME.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) throw error;
34
+ sleepSync(RENAME_RETRY_DELAYS_MS[attempt]);
35
+ }
36
+ }
37
+ }
38
+ function fsyncDirSync(dir) {
39
+ if (process.platform === "win32") return;
40
+ try {
41
+ const dirFd = fs.openSync(dir, "r");
42
+ try {
43
+ fs.fsyncSync(dirFd);
44
+ } finally {
45
+ fs.closeSync(dirFd);
46
+ }
47
+ } catch {
48
+ }
49
+ }
50
+ function writeFileAtomicSync(file, content, options = {}) {
51
+ const dir = path.dirname(file);
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ const mode = resolveMode(file, options);
54
+ const tmp = tempPath(dir, path.basename(file));
55
+ let fd = null;
56
+ try {
57
+ fd = fs.openSync(tmp, "wx", mode);
58
+ fs.writeFileSync(fd, content, "utf8");
59
+ fs.fsyncSync(fd);
60
+ fs.closeSync(fd);
61
+ fd = null;
62
+ renameWithRetrySync(tmp, file);
63
+ fsyncDirSync(dir);
64
+ } catch (error) {
65
+ if (fd !== null) {
66
+ try {
67
+ fs.closeSync(fd);
68
+ } catch {
69
+ }
70
+ }
71
+ try {
72
+ fs.rmSync(tmp, { force: true });
73
+ } catch {
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+ async function writeFileAtomic(file, content, options = {}) {
79
+ const dir = path.dirname(file);
80
+ await fsp.mkdir(dir, { recursive: true });
81
+ const mode = resolveMode(file, options);
82
+ const tmp = tempPath(dir, path.basename(file));
83
+ let handle = null;
84
+ try {
85
+ handle = await fsp.open(tmp, "wx", mode);
86
+ await handle.writeFile(content, "utf8");
87
+ await handle.sync();
88
+ await handle.close();
89
+ handle = null;
90
+ await fsp.rename(tmp, file);
91
+ if (process.platform !== "win32") {
92
+ try {
93
+ const dirFd = await fsp.open(dir, "r");
94
+ try {
95
+ await dirFd.sync();
96
+ } finally {
97
+ await dirFd.close();
98
+ }
99
+ } catch {
100
+ }
101
+ }
102
+ } catch (error) {
103
+ if (handle !== null) {
104
+ try {
105
+ await handle.close();
106
+ } catch {
107
+ }
108
+ }
109
+ try {
110
+ await fsp.rm(tmp, { force: true });
111
+ } catch {
112
+ }
113
+ throw error;
114
+ }
115
+ }
116
+ export {
117
+ writeFileAtomic,
118
+ writeFileAtomicSync
119
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `git+https://github.com/o/r.git` / `git@github.com:o/r.git` / `https://github.com/o/r`
3
+ * → `https://github.com/o/r`。非 GitHub / 解析失败 → undefined。
4
+ */
5
+ declare function normalizeRepositoryUrl(value: unknown): string | undefined;
6
+ /** 从仓库地址(或 `owner/repo` 短名)取 `owner/repo`;拿不到返回 null。 */
7
+ declare function repoSlug(value: unknown): string | null;
8
+ interface ReleaseInfo {
9
+ version: string;
10
+ body: string;
11
+ htmlUrl: string | null;
12
+ name: string | null;
13
+ }
14
+ interface ReleaseReaderDeps {
15
+ /** `owner/repo`。 */
16
+ repo: string;
17
+ fetchImpl?: typeof fetch;
18
+ /** 成功缓存 TTL;默认 10 分钟。 */
19
+ ttlMs?: number;
20
+ /** 失败短缓存 TTL;默认 = ttlMs(与既有实现一致,失败也缓存 10 分钟)。 */
21
+ failureTtlMs?: number;
22
+ /** 单次请求超时;默认 8s。 */
23
+ timeoutMs?: number;
24
+ now?: () => number;
25
+ }
26
+ interface ReleaseReader {
27
+ /** 拉某版本 Release 正文;无 repo / 网络 / 404 / 超时 / 非法版本 → null(不抛)。 */
28
+ release(version: string): Promise<ReleaseInfo | null>;
29
+ }
30
+ declare function createReleaseReader(deps: ReleaseReaderDeps): ReleaseReader;
31
+
32
+ export { type ReleaseInfo, type ReleaseReader, type ReleaseReaderDeps, createReleaseReader, normalizeRepositoryUrl, repoSlug };
package/dist/github.js ADDED
@@ -0,0 +1,90 @@
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
+
15
+ // src/github.ts
16
+ var GITHUB_HOST = "github.com";
17
+ var DEFAULT_TIMEOUT_MS = 8e3;
18
+ var DEFAULT_TTL_MS = 10 * 60 * 1e3;
19
+ function normalizeRepositoryUrl(value) {
20
+ if (typeof value !== "string") return void 0;
21
+ let url = value.trim();
22
+ if (url === "") return void 0;
23
+ url = url.replace(/^git\+/i, "");
24
+ const ssh = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i.exec(url);
25
+ if (ssh !== null) url = `https://github.com/${ssh[1]}/${ssh[2]}`;
26
+ url = url.replace(/\.git$/i, "");
27
+ try {
28
+ const parsed = new URL(url);
29
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return void 0;
30
+ if (parsed.hostname.toLowerCase() !== GITHUB_HOST) return void 0;
31
+ const pathname = parsed.pathname.replace(/\/+$/, "");
32
+ if (pathname === "") return void 0;
33
+ return `https://github.com${pathname}`;
34
+ } catch {
35
+ return void 0;
36
+ }
37
+ }
38
+ function repoSlug(value) {
39
+ if (typeof value !== "string") return null;
40
+ const text = value.trim();
41
+ if (text === "") return null;
42
+ if (/^[\w.-]+\/[\w.-]+$/.test(text)) return text;
43
+ const normalized = normalizeRepositoryUrl(text);
44
+ if (normalized === void 0) return null;
45
+ const pathname = normalized.slice(`https://github.com/`.length);
46
+ return pathname === "" ? null : pathname;
47
+ }
48
+ function createReleaseReader(deps) {
49
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
50
+ const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS;
51
+ const failureTtlMs = deps.failureTtlMs ?? ttlMs;
52
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
53
+ const now = deps.now ?? Date.now;
54
+ const repo = deps.repo.trim();
55
+ const cache = /* @__PURE__ */ new Map();
56
+ async function release(version) {
57
+ const target = String(version ?? "").trim();
58
+ if (repo === "" || parseSemver(target) === void 0) return null;
59
+ const cached = cache.get(target);
60
+ if (cached !== void 0 && now() < cached.expiresAt) return cached.value;
61
+ const tag = `v${target.replace(/^v/, "")}`;
62
+ try {
63
+ const response = await fetchImpl(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, {
64
+ headers: { accept: "application/vnd.github+json" },
65
+ redirect: "error",
66
+ signal: AbortSignal.timeout(timeoutMs)
67
+ });
68
+ const value = response.ok ? await response.json().then((raw) => {
69
+ const release2 = raw ?? {};
70
+ return {
71
+ version: target,
72
+ body: typeof release2.body === "string" ? release2.body : "",
73
+ htmlUrl: typeof release2.html_url === "string" ? release2.html_url : null,
74
+ name: typeof release2.name === "string" ? release2.name : null
75
+ };
76
+ }) : null;
77
+ cache.set(target, { value, expiresAt: now() + ttlMs });
78
+ return value;
79
+ } catch {
80
+ cache.set(target, { value: null, expiresAt: now() + failureTtlMs });
81
+ return null;
82
+ }
83
+ }
84
+ return { release };
85
+ }
86
+ export {
87
+ createReleaseReader,
88
+ normalizeRepositoryUrl,
89
+ repoSlug
90
+ };
package/dist/http.d.ts CHANGED
@@ -24,5 +24,38 @@ interface ReadJsonOptions {
24
24
  }
25
25
  /** 读一个 JSON 对象 body;超过上限抛 PAYLOAD_TOO_LARGE,非对象/非法 JSON 抛 INVALID_JSON。 */
26
26
  declare function readJsonBody(request: AsyncIterable<Uint8Array | string>, options?: ReadJsonOptions): Promise<Record<string, unknown>>;
27
+ interface RouteContext {
28
+ request: RequestLike & AsyncIterable<Uint8Array | string>;
29
+ url: URL;
30
+ /** 大写方法名。 */
31
+ method: string;
32
+ /** 已剥掉 prefix 的 pathname(`/state` 之类)。 */
33
+ pathname: string;
34
+ /** 已解析的 JSON body(超出上限/非法会先抛 HttpError);无 body 时空对象。 */
35
+ body: Record<string, unknown>;
36
+ }
37
+ /** 把插件自己的错误(IpcError / ServiceError …)映射成 HTTP 响应;返回 undefined 走默认 500。 */
38
+ type RouteErrorMapper = (error: unknown) => {
39
+ status: number;
40
+ code?: string;
41
+ message: string;
42
+ extra?: Record<string, unknown>;
43
+ } | undefined;
44
+ interface CreateRouteHandlerOptions {
45
+ /** 路由前缀,如 `/api/morehao/xxx`。 */
46
+ prefix: string;
47
+ /** 鉴权头名;要求值 `1`(跨源预检顺带挡 CSRF)。 */
48
+ header: string;
49
+ /** 业务 dispatch:返回结果对象(包成 `{ok:true,...}`)或自行写完响应(返回 void)。 */
50
+ dispatch: (ctx: RouteContext) => Promise<Record<string, unknown> | void>;
51
+ /** body 上限;默认 512 KiB。 */
52
+ maxBodyBytes?: number;
53
+ /** 错误映射;缺省 500 INTERNAL。 */
54
+ onError?: RouteErrorMapper;
55
+ logger?: (event: string, data?: Record<string, unknown>) => void;
56
+ }
57
+ type RouteHandler = (request: unknown, response: unknown) => Promise<void>;
58
+ /** 造一个宿主的 request 处理器:统一鉴权 / body / 响应 / 错误兜底。 */
59
+ declare function createRouteHandler(options: CreateRouteHandlerOptions): RouteHandler;
27
60
 
28
- export { HttpError, type HttpErrorCode, type ReadJsonOptions, type RequestLike, type ResponseLike, hasHeader, readJsonBody, sendJson };
61
+ export { type CreateRouteHandlerOptions, HttpError, type HttpErrorCode, type ReadJsonOptions, type RequestLike, type ResponseLike, type RouteContext, type RouteErrorMapper, type RouteHandler, createRouteHandler, hasHeader, readJsonBody, sendJson };