@morehao/dsh-plugin-kit 0.2.1 → 0.3.0

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,15 @@
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
7
  - **HTTP 小工具**(`./http`):自定义头鉴权、`no-store` JSON、有上限的 JSON body。
8
+ - **子进程执行**(`./exec`):argv 直传(不经 shell)、环境白名单、超时/取消、输出上限,外加 `pnpm view` 版本解析。
9
+ - **原子写**(`./fs`):`writeFileAtomicSync` / `writeFileAtomic`,杜绝「半个文件」。
10
+ - **JSON 状态持久化**(`./state`):`$DSH_HOME` 解析 + `<home>/<plugin>/*.json` 读写(读 fail-open、写抛错)。
8
11
 
9
- 设计约束:**零 `@deepseek-ai/*` 运行时依赖**,宿主能力(`pluginManager` / `profileContext`)全部由调用方注入;进程相关的策略(比如用哪个包管理器查版本)也由插件自己决定,本包不替你 spawn。
12
+ 设计约束:**零 `@deepseek-ai/*` 运行时依赖**,宿主能力(`pluginManager` / `profileContext`)全部由调用方注入。进程策略由调用方把握:`./update` 常驻能力不主动 spawn,需要 `pnpm view` 时由调用方注入 `runPnpmView`;也可直接用 `./exec`(或 `createPnpmViewRunner`,默认走 `./exec`、可注入 `exec` 替换进程策略)。
10
13
 
11
14
  ## 安装
12
15
 
@@ -114,15 +117,79 @@ const body = await readJsonBody(request, { maxBytes: 512 * 1024 });
114
117
  sendJson(response, 200, { ok: true });
115
118
  ```
116
119
 
120
+ ## 子进程执行(`./exec`)
121
+
122
+ argv 直传、不经 shell;超时/取消先 SIGTERM、宽限后 SIGKILL;输出有上限且保留尾部;`spawn` 失败**不 reject**,落到 `error` 字段。
123
+
124
+ ```ts
125
+ import { spawnCollect, sanitizedEnv, collectVersionView } from '@morehao/dsh-plugin-kit/exec';
126
+
127
+ const result = await spawnCollect('git', ['rev-parse', 'HEAD'], {
128
+ cwd: repo,
129
+ env: sanitizedEnv(process.env), // 只放行 PATH/HOME/代理/证书/locale/git 身份等白名单
130
+ timeoutMs: 8_000,
131
+ });
132
+ // { code, signal, stdout, stderr, output, timedOut, aborted, durationMs, error? }
133
+
134
+ // 从 `pnpm view <spec> version --json` 的输出里取版本串(单值或数组取尾;非法 → null)
135
+ const version = collectVersionView({ code: result.code, stdout: result.stdout });
136
+ ```
137
+
138
+ ## 原子写(`./fs`)
139
+
140
+ 同目录临时文件 → fsync → rename → fsync(父目录);Windows rename 有界重试;失败清理临时文件,原文件在 rename 前不被截断。
141
+
142
+ ```ts
143
+ import { writeFileAtomicSync, writeFileAtomic } from '@morehao/dsh-plugin-kit/fs';
144
+
145
+ writeFileAtomicSync('/path/state.json', `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
146
+ await writeFileAtomic('/path/state.json', content); // 同语义异步版
147
+ ```
148
+
149
+ `mode` 省略时沿用已有文件权限,新文件走 `defaultMode`(默认 `0o644`)。
150
+
151
+ ## JSON 状态持久化(`./state`)
152
+
153
+ `$DSH_HOME` 解析 + `<home>/<plugin>/*.json` 读写。**读 fail-open**(缺失/坏 JSON/非对象 → `fallback()`,永不抛);**写抛错**(写通常是一次事务,静默吞掉会掩盖数据丢失,需要「尽力而为」的调用方自行 try/catch)。
154
+
155
+ ```ts
156
+ import { resolveDshHome, createJsonStore } from '@morehao/dsh-plugin-kit/state';
157
+
158
+ const store = createJsonStore({
159
+ dir: `${resolveDshHome()}/my-plugin`, // $DSH_HOME → ~/.dsh
160
+ file: 'state.json',
161
+ fallback: () => ({ lastSeenVersion: '' }), // 工厂函数,避免共享可变引用
162
+ });
163
+ const state = store.read(); // 坏文件也返回 fallback
164
+ store.write({ lastSeenVersion: '0.3.0' }); // 原子写
165
+ ```
166
+
167
+ ## 复用 `pnpm view` 查版本(`./update`)
168
+
169
+ 三仓此前各手写一遍的「读 profile → 拼 `pnpm view` → 解析 → 校验」,收敛为一个工厂:
170
+
171
+ ```ts
172
+ import { createPnpmViewRunner, createPluginUpdater } from '@morehao/dsh-plugin-kit/update';
173
+
174
+ const runPnpmView = createPnpmViewRunner({
175
+ getProfile: () => ctx.get('profileContext'),
176
+ // 可选:注入自定义 exec 以沿用本插件更细的进程策略;默认走 ./exec
177
+ // exec: myExecCapture,
178
+ });
179
+ const updater = createPluginUpdater({ meta, getPluginManager: () => ctx.get('pluginManager'), runPnpmView });
180
+ ```
181
+
182
+ 拿不到 `profile.dir` / 命令失败 / 输出非法 / 版本不是合法 semver → 返回 `null`(交由 registry 兜底)。
183
+
117
184
  ## 版本与兼容
118
185
 
119
186
  - Node `^22.20.0 || >=24.0.0`,纯 ESM。
120
- - `./update` 与 `./http` 走 host 侧;`./update/client` 只含状态机,不 import node 内置模块(客户端产物只允许 react)。
187
+ - `./update`、`./http`、`./exec`、`./fs`、`./state` 走 host 侧;`./update/client` 只含状态机,不 import node 内置模块(客户端产物只允许 react)。
121
188
  - 宿主服务形状按结构化类型处理(`PluginManagerLike` / `ProfileLike`),宿主版本变化不会把插件一起拖下去。
122
189
 
123
190
  ## 开发
124
191
 
125
192
  ```bash
126
193
  npm install
127
- npm run check # typecheck + build + 26 条离线测试(假 fetch / 假 pluginManager,不联网、不真装)
194
+ npm run check # typecheck + build + 49 条离线测试(假 fetch / 假 pluginManager / 假 exec,不联网、不真装)
128
195
  ```
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
+ /** `$DSH_HOME`(去空白、`path.resolve`)→ 缺省 `homedir()/.dsh`。与 dsh-core 及三仓既有实现一致。 */
2
+ declare function resolveDshHome(env?: NodeJS.ProcessEnv): string;
3
+ interface JsonStoreOptions<T> {
4
+ /** 状态目录(绝对路径)。调用方用 `resolveDshHome` + 插件目录名拼。 */
5
+ dir: string;
6
+ /** 文件名,如 `'state.json'`。 */
7
+ file: string;
8
+ /** 读失败 / 缺失 / 非对象时返回的默认值(工厂函数,避免共享可变引用)。 */
9
+ fallback: () => T;
10
+ /** 写回时的文件权限位;默认 0o600(状态文件默认私有)。 */
11
+ mode?: number;
12
+ /** mkdir 的目录权限;默认 0o700。 */
13
+ dirMode?: number;
14
+ /** 测试注入:默认读真实文件。 */
15
+ readFile?: (path: string) => Promise<string>;
16
+ readFileSync?: (path: string) => string;
17
+ }
18
+ interface JsonStore<T> {
19
+ /** 状态文件的绝对路径。 */
20
+ readonly path: string;
21
+ /** 同步读:坏文件 / 缺失 / 非对象 → `fallback()`。永不抛。 */
22
+ read(): T;
23
+ /** 同步原子写(`writeFileAtomicSync`)。失败抛错。 */
24
+ write(value: T): void;
25
+ /** 异步读。 */
26
+ readAsync(): Promise<T>;
27
+ /** 异步原子写(`writeFileAtomic`)。失败抛错。 */
28
+ writeAsync(value: T): Promise<void>;
29
+ }
30
+ declare function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
31
+
32
+ export { type JsonStore, type JsonStoreOptions, createJsonStore, resolveDshHome };
package/dist/state.js ADDED
@@ -0,0 +1,186 @@
1
+ // src/state.ts
2
+ import { mkdirSync, promises as fsp2, readFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+
6
+ // src/fs.ts
7
+ import fs from "fs";
8
+ import fsp from "fs/promises";
9
+ import path from "path";
10
+ import { randomBytes } from "crypto";
11
+ var WINDOWS_TRANSIENT_RENAME = /* @__PURE__ */ new Set(["EACCES", "EBUSY", "EPERM"]);
12
+ var RENAME_RETRY_DELAYS_MS = [20, 40, 80, 160, 200, 200, 200, 200];
13
+ function resolveMode(file, options) {
14
+ if (typeof options.mode === "number") return options.mode;
15
+ try {
16
+ return fs.statSync(file).mode & 511;
17
+ } catch {
18
+ return options.defaultMode ?? 420;
19
+ }
20
+ }
21
+ function tempPath(dir, base) {
22
+ return path.join(dir, `.${base}.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}.tmp`);
23
+ }
24
+ function sleepSync(ms) {
25
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
26
+ }
27
+ function renameWithRetrySync(from, to) {
28
+ if (process.platform !== "win32") {
29
+ fs.renameSync(from, to);
30
+ return;
31
+ }
32
+ for (let attempt = 0; ; attempt += 1) {
33
+ try {
34
+ fs.renameSync(from, to);
35
+ return;
36
+ } catch (error) {
37
+ const code = error?.code;
38
+ if (code === void 0 || !WINDOWS_TRANSIENT_RENAME.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) throw error;
39
+ sleepSync(RENAME_RETRY_DELAYS_MS[attempt]);
40
+ }
41
+ }
42
+ }
43
+ function fsyncDirSync(dir) {
44
+ if (process.platform === "win32") return;
45
+ try {
46
+ const dirFd = fs.openSync(dir, "r");
47
+ try {
48
+ fs.fsyncSync(dirFd);
49
+ } finally {
50
+ fs.closeSync(dirFd);
51
+ }
52
+ } catch {
53
+ }
54
+ }
55
+ function writeFileAtomicSync(file, content, options = {}) {
56
+ const dir = path.dirname(file);
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ const mode = resolveMode(file, options);
59
+ const tmp = tempPath(dir, path.basename(file));
60
+ let fd = null;
61
+ try {
62
+ fd = fs.openSync(tmp, "wx", mode);
63
+ fs.writeFileSync(fd, content, "utf8");
64
+ fs.fsyncSync(fd);
65
+ fs.closeSync(fd);
66
+ fd = null;
67
+ renameWithRetrySync(tmp, file);
68
+ fsyncDirSync(dir);
69
+ } catch (error) {
70
+ if (fd !== null) {
71
+ try {
72
+ fs.closeSync(fd);
73
+ } catch {
74
+ }
75
+ }
76
+ try {
77
+ fs.rmSync(tmp, { force: true });
78
+ } catch {
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+ async function writeFileAtomic(file, content, options = {}) {
84
+ const dir = path.dirname(file);
85
+ await fsp.mkdir(dir, { recursive: true });
86
+ const mode = resolveMode(file, options);
87
+ const tmp = tempPath(dir, path.basename(file));
88
+ let handle = null;
89
+ try {
90
+ handle = await fsp.open(tmp, "wx", mode);
91
+ await handle.writeFile(content, "utf8");
92
+ await handle.sync();
93
+ await handle.close();
94
+ handle = null;
95
+ await fsp.rename(tmp, file);
96
+ if (process.platform !== "win32") {
97
+ try {
98
+ const dirFd = await fsp.open(dir, "r");
99
+ try {
100
+ await dirFd.sync();
101
+ } finally {
102
+ await dirFd.close();
103
+ }
104
+ } catch {
105
+ }
106
+ }
107
+ } catch (error) {
108
+ if (handle !== null) {
109
+ try {
110
+ await handle.close();
111
+ } catch {
112
+ }
113
+ }
114
+ try {
115
+ await fsp.rm(tmp, { force: true });
116
+ } catch {
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ // src/state.ts
123
+ function resolveDshHome(env = process.env) {
124
+ const raw = env.DSH_HOME?.trim();
125
+ return raw !== void 0 && raw !== "" ? raw : join(homedir(), ".dsh");
126
+ }
127
+ function serialize(value) {
128
+ return `${JSON.stringify(value, null, 2)}
129
+ `;
130
+ }
131
+ function coerce(text, fallback) {
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(text);
135
+ } catch {
136
+ return fallback();
137
+ }
138
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return fallback();
139
+ return parsed;
140
+ }
141
+ var defaultReadFileSync = (target) => readFileSync(target, "utf8");
142
+ var defaultReadFile = (target) => fsp2.readFile(target, "utf8");
143
+ function createJsonStore(options) {
144
+ const file = join(options.dir, options.file);
145
+ const mode = options.mode ?? 384;
146
+ const dirMode = options.dirMode ?? 448;
147
+ const readFileSyncImpl = options.readFileSync ?? defaultReadFileSync;
148
+ const readFileImpl = options.readFile ?? defaultReadFile;
149
+ function read() {
150
+ let text;
151
+ try {
152
+ text = readFileSyncImpl(file);
153
+ } catch {
154
+ return options.fallback();
155
+ }
156
+ return coerce(text, options.fallback);
157
+ }
158
+ async function readAsync() {
159
+ let text;
160
+ try {
161
+ text = await readFileImpl(file);
162
+ } catch {
163
+ return options.fallback();
164
+ }
165
+ return coerce(text, options.fallback);
166
+ }
167
+ function write(value) {
168
+ try {
169
+ mkdirSync(options.dir, { recursive: true, mode: dirMode });
170
+ } catch {
171
+ }
172
+ writeFileAtomicSync(file, serialize(value), { mode });
173
+ }
174
+ async function writeAsync(value) {
175
+ try {
176
+ await fsp2.mkdir(options.dir, { recursive: true, mode: dirMode });
177
+ } catch {
178
+ }
179
+ await writeFileAtomic(file, serialize(value), { mode });
180
+ }
181
+ return { path: file, read, write, readAsync, writeAsync };
182
+ }
183
+ export {
184
+ createJsonStore,
185
+ resolveDshHome
186
+ };
@@ -1,4 +1,5 @@
1
1
  import { UpdateView, ApplyResult } from './update.js';
2
+ import './exec.js';
2
3
  import './profile-RKpxKDlK.js';
3
4
 
4
5
  /**
package/dist/update.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ExecOptions, ExecResult } from './exec.js';
1
2
  import { I as InstallKind, H as HostRegistryPlan } from './profile-RKpxKDlK.js';
2
3
  export { P as ProfileLike } from './profile-RKpxKDlK.js';
3
4
 
@@ -43,7 +44,9 @@ interface PluginMeta {
43
44
  * - **装不上要能回退**:宿主 `installBundle` 对已装包会返回 `already-installed`,按官方
44
45
  * 更新路径先 remove 再 install;remove 之后 install 失败则尝试装回原版本。
45
46
  * - **不重写宿主进程策略**:需要 `pnpm view` 时由调用方注入 `runPnpmView`(进程/环境
46
- * 白名单是各插件自己的事),本包只做 registry 兜底。
47
+ * 白名单是各插件自己的事),本包只做 registry 兜底。为免三个插件各抄一遍「读 profile → 拼
48
+ * `pnpm view` → 解析 → 校验」,本模块导出 `createPnpmViewRunner` 供直接复用(内部走 `./exec`,
49
+ * 环境清洗仍由调用方把握;也可注入 `exec` 替换进程策略)。
47
50
  * - 更新会把运行中的插件自己卸载,界面会一起消失:全过程落固定事件名,便于事后对账。
48
51
  */
49
52
 
@@ -109,5 +112,27 @@ interface Updater {
109
112
  isApplying(): boolean;
110
113
  }
111
114
  declare function createPluginUpdater(deps: UpdaterDeps): Updater;
115
+ interface PnpmViewRunnerDeps {
116
+ /** 取宿主 `profileContext`(提供 `dir` / `packageManager`);可抛,内部会兜。 */
117
+ getProfile: () => unknown;
118
+ /**
119
+ * 可注入的子进程执行器(默认 `./exec` 的 `spawnCollect`)。
120
+ * 各插件若要沿用自己更细的进程策略(环境白名单 / 输出上限 / 作业面板旁路),注入即可。
121
+ */
122
+ exec?: (command: string, args: string[], options?: ExecOptions) => Promise<ExecResult>;
123
+ /** 环境来源;默认 `process.env`,内部过 `sanitizedEnv` 清洗。 */
124
+ env?: NodeJS.ProcessEnv;
125
+ /** 单个查询超时毫秒;默认 8_000。 */
126
+ timeoutMs?: number;
127
+ }
128
+ /**
129
+ * 造一个 `runPnpmView`:用 profile 的包管理器在 profile 目录执行
130
+ * `pnpm view <spec> version --json`,继承 `.npmrc` 的镜像 / 私源 / token。
131
+ *
132
+ * 返回 `(spec) => Promise<string | null>`,与 `UpdaterDeps.runPnpmView` 兼容:
133
+ * 拿不到 profile.dir / 命令失败 / 输出非法 / 版本不是合法 semver → `null`(交由 registry 兜底)。
134
+ * 三个插件此前各自手写这段,本工厂与它们的历史行为一致。
135
+ */
136
+ declare function createPnpmViewRunner(deps: PnpmViewRunnerDeps): (spec: string) => Promise<string | null>;
112
137
 
113
- export { type ApplyResult, HostRegistryPlan, InstallKind, type Logger, type PluginManagerLike, type PluginMeta, PluginUpdateError, type UpdateErrorCode, type UpdateView, type Updater, type UpdaterDeps, createPluginUpdater };
138
+ export { type ApplyResult, HostRegistryPlan, InstallKind, type Logger, type PluginManagerLike, type PluginMeta, PluginUpdateError, type PnpmViewRunnerDeps, type UpdateErrorCode, type UpdateView, type Updater, type UpdaterDeps, createPluginUpdater, createPnpmViewRunner };
package/dist/update.js CHANGED
@@ -1,3 +1,170 @@
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
+
1
168
  // src/registry.ts
2
169
  var OFFICIAL_REGISTRY = "https://registry.npmjs.org/";
3
170
  var DEFAULT_TIMEOUT_MS = 8e3;
@@ -459,7 +626,38 @@ function createPluginUpdater(deps) {
459
626
  isApplying: () => applying
460
627
  };
461
628
  }
629
+ function createPnpmViewRunner(deps) {
630
+ const timeoutMs = deps.timeoutMs ?? 8e3;
631
+ const run = deps.exec ?? spawnCollect;
632
+ const env = sanitizedEnv(deps.env ?? process.env);
633
+ return async (spec) => {
634
+ let profile;
635
+ try {
636
+ profile = normalizeProfile(deps.getProfile());
637
+ } catch {
638
+ return null;
639
+ }
640
+ if (profile === null || typeof profile.dir !== "string" || profile.dir === "") return null;
641
+ const command = profile.packageManager?.command ?? "pnpm";
642
+ const prefix = profile.packageManager?.args ?? [];
643
+ let result;
644
+ try {
645
+ result = await run(command, [...prefix, "view", spec, "version", "--json"], {
646
+ cwd: profile.dir,
647
+ env,
648
+ timeoutMs,
649
+ maxStdout: 64 * 1024,
650
+ maxStderr: 16 * 1024
651
+ });
652
+ } catch {
653
+ return null;
654
+ }
655
+ const version = collectVersionView(result);
656
+ return version !== null && parseSemver(version) !== void 0 ? version : null;
657
+ };
658
+ }
462
659
  export {
463
660
  PluginUpdateError,
464
- createPluginUpdater
661
+ createPluginUpdater,
662
+ createPnpmViewRunner
465
663
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@morehao/dsh-plugin-kit",
3
- "version": "0.2.1",
4
- "description": "DeepSeek Harness 插件共用工具包:插件自更新引擎(查 npm 最新版 + 经宿主 pluginManager 重装)、headless 客户端控制器与 HTTP 小工具。零 @deepseek-ai 运行时依赖,宿主能力全部注入。",
3
+ "version": "0.3.0",
4
+ "description": "DeepSeek Harness 插件共用工具包:插件自更新引擎(查 npm 最新版 + 经宿主 pluginManager 重装)、headless 客户端控制器、HTTP 小工具,以及子进程执行 / 原子写 / JSON 状态持久化等通用底座。零 @deepseek-ai 运行时依赖,宿主能力全部注入。",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -22,6 +22,18 @@
22
22
  "types": "./dist/http.d.ts",
23
23
  "default": "./dist/http.js"
24
24
  },
25
+ "./exec": {
26
+ "types": "./dist/exec.d.ts",
27
+ "default": "./dist/exec.js"
28
+ },
29
+ "./fs": {
30
+ "types": "./dist/fs.d.ts",
31
+ "default": "./dist/fs.js"
32
+ },
33
+ "./state": {
34
+ "types": "./dist/state.d.ts",
35
+ "default": "./dist/state.js"
36
+ },
25
37
  "./package.json": "./package.json"
26
38
  },
27
39
  "files": [
@@ -45,7 +57,9 @@
45
57
  "typecheck": "tsc --noEmit",
46
58
  "test": "node --test \"test/*.test.mjs\"",
47
59
  "pretest": "npm run build",
48
- "check": "npm run typecheck && npm run build && npm test --ignore-scripts"
60
+ "check": "npm run typecheck && npm run build && npm test --ignore-scripts",
61
+ "check:release-notes": "node scripts/smoke-release-notes.mjs",
62
+ "prepack": "npm run build"
49
63
  },
50
64
  "devDependencies": {
51
65
  "@types/node": "22.20.5",