@morehao/dsh-plugin-kit 0.1.0 → 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 +89 -4
- package/dist/exec.d.ts +52 -0
- package/dist/exec.js +171 -0
- package/dist/fs.d.ts +12 -0
- package/dist/fs.js +119 -0
- package/dist/index.d.ts +4 -32
- package/dist/index.js +45 -44
- package/dist/profile-RKpxKDlK.d.ts +117 -0
- package/dist/state.d.ts +32 -0
- package/dist/state.js +186 -0
- package/dist/update-client.d.ts +2 -1
- package/dist/update.d.ts +56 -5
- package/dist/update.js +244 -40
- package/package.json +21 -3
- package/dist/profile-C-eyP5F6.d.ts +0 -44
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
|
|
12
|
+
设计约束:**零 `@deepseek-ai/*` 运行时依赖**,宿主能力(`pluginManager` / `profileContext`)全部由调用方注入。进程策略由调用方把握:`./update` 常驻能力不主动 spawn,需要 `pnpm view` 时由调用方注入 `runPnpmView`;也可直接用 `./exec`(或 `createPnpmViewRunner`,默认走 `./exec`、可注入 `exec` 替换进程策略)。
|
|
10
13
|
|
|
11
14
|
## 安装
|
|
12
15
|
|
|
@@ -14,16 +17,34 @@ DeepSeek Harness(dsh)插件共用的工具包。当前提供三件事:
|
|
|
14
17
|
npm install @morehao/dsh-plugin-kit
|
|
15
18
|
```
|
|
16
19
|
|
|
20
|
+
## 能力边界:哪些复用宿主、哪些必须自建
|
|
21
|
+
|
|
22
|
+
自更新这件事在宿主(`@deepseek-ai/dsh-plugin-manager`)里已有大半能力,本包的原则是**能复用就复用**:
|
|
23
|
+
|
|
24
|
+
| 能力 | 来源 | 说明 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| 安装 / 卸载 | **复用宿主** | apply 全程走 `pluginManager.installBundle` / `removeBundle`;`already-installed → remove → install` 就是宿主既定的更新流程,`applied/restart-required/overridden` 也直接来自宿主结果。 |
|
|
27
|
+
| registry 列表(镜像 / 私源 / token) | **复用宿主** | `resolved` 由宿主跑 `pnpm config get registry` 解析,本包不自己读 `.npmrc`。 |
|
|
28
|
+
| registry **排序 / 回退** | **复用宿主(注入)** | 优先用调用方注入的宿主原生 `registryPlan`(`@deepseek-ai/dsh-plugin-manager/registry`),与 `installBundle`/`inspect` 内部同一份源码、含私有源「只问它自己、不回退公有源」语义。本包不直接 import(保持零 `@deepseek-ai/*` 运行时依赖),由插件侧注入。未注入时用内置 `resolveRegistryPlan` 兜底(`resolved → registry → fallback` 宽松顺序,fail-open)。 |
|
|
29
|
+
| 查最新版 | **必须自建** | 宿主 `inspect()` 对「已装包」直接返回 `already-installed`,不会去问 registry;插件查的正是「我自己这个已装包」,必然被拦截。故只能走 `pnpm view`(`runPnpmView`,复用 profile 的包管理器)或 registry `/latest`。 |
|
|
30
|
+
| 安装形态判定 | **必须自建** | 宿主未暴露「本包以什么 spec 声明」;本包读 `<profile.dir>/package.json` 识别 `link:/file:/workspace:` 以拒绝把开发链接覆盖成 npm 版。 |
|
|
31
|
+
|
|
32
|
+
一句话:**装/卸、registry 取数与排序都复用宿主(排序原生能力通过注入复用);查版本、判形态因宿主 API 缺口而自建;不注入原生 `registryPlan` 时才回落到内置兜底顺序。**
|
|
33
|
+
|
|
17
34
|
## 一键更新(host)
|
|
18
35
|
|
|
19
36
|
```ts
|
|
20
37
|
import { createPluginUpdater } from '@morehao/dsh-plugin-kit/update';
|
|
38
|
+
// 宿主原生 registry 排序(与 installBundle/inspect 同一份),由插件侧注入、本包不直接依赖
|
|
39
|
+
import { registryPlan as hostRegistryPlan } from '@deepseek-ai/dsh-plugin-manager/registry';
|
|
21
40
|
|
|
22
41
|
const updater = createPluginUpdater({
|
|
23
42
|
meta: { name: pkg.name, version: pkg.version, ...(pkg.repository ? { repository: repoUrl(pkg.repository) } : {}) },
|
|
24
43
|
getPluginManager: () => ctx.get('pluginManager'),
|
|
25
44
|
getProfile: () => ctx.get('profileContext'),
|
|
26
45
|
getProfileName: () => ctx.get('profileContext')?.name,
|
|
46
|
+
// 可选但推荐:注入宿主原生 registryPlan,使「查版本的 registry 顺序」与宿主安装完全一致
|
|
47
|
+
registryPlan: hostRegistryPlan,
|
|
27
48
|
// 可选:用 profile 的包管理器查版本(继承 .npmrc 的镜像 / 私源 / token)
|
|
28
49
|
runPnpmView: async (spec) => {
|
|
29
50
|
const profile = ctx.get('profileContext');
|
|
@@ -96,15 +117,79 @@ const body = await readJsonBody(request, { maxBytes: 512 * 1024 });
|
|
|
96
117
|
sendJson(response, 200, { ok: true });
|
|
97
118
|
```
|
|
98
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
|
+
|
|
99
184
|
## 版本与兼容
|
|
100
185
|
|
|
101
186
|
- Node `^22.20.0 || >=24.0.0`,纯 ESM。
|
|
102
|
-
- `./update
|
|
187
|
+
- `./update`、`./http`、`./exec`、`./fs`、`./state` 走 host 侧;`./update/client` 只含状态机,不 import node 内置模块(客户端产物只允许 react)。
|
|
103
188
|
- 宿主服务形状按结构化类型处理(`PluginManagerLike` / `ProfileLike`),宿主版本变化不会把插件一起拖下去。
|
|
104
189
|
|
|
105
190
|
## 开发
|
|
106
191
|
|
|
107
192
|
```bash
|
|
108
193
|
npm install
|
|
109
|
-
npm run check # typecheck + build +
|
|
194
|
+
npm run check # typecheck + build + 49 条离线测试(假 fetch / 假 pluginManager / 假 exec,不联网、不真装)
|
|
110
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
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,43 +1,15 @@
|
|
|
1
|
-
export { D as DetectDeps, I as InstallKind,
|
|
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
2
|
|
|
3
|
-
/**
|
|
4
|
-
* semver 解析与比较(只做插件更新需要的部分)。
|
|
5
|
-
*
|
|
6
|
-
* 不引 semver 依赖:更新判定宁可不升级也不要误升,所以非法版本一律返回 undefined,
|
|
7
|
-
* 由调用方按「不更新」处理。支持 `v1.2.3` / `1.2.3-beta.1` / `1.2.3+build`。
|
|
8
|
-
*/
|
|
3
|
+
/** 保留旧接口形状:核心三段 + 预发布标识(字符串数组)。 */
|
|
9
4
|
interface Semver {
|
|
10
5
|
core: [number, number, number];
|
|
11
6
|
prerelease: string[];
|
|
12
7
|
}
|
|
8
|
+
/** 解析成 `Semver`;非法(含空串 / `1.2` / `1.2.3.4` / 非版本串)返回 undefined。 */
|
|
13
9
|
declare function parseSemver(value: unknown): Semver | undefined;
|
|
14
10
|
/** `candidate` 是否严格新于 `current`;任一方不是合法 semver 都返回 false。 */
|
|
15
11
|
declare function isNewerVersion(current: unknown, candidate: unknown): boolean;
|
|
16
12
|
/** 比较两个版本:负数 / 0 / 正数;任一方非法返回 undefined。 */
|
|
17
13
|
declare function compareVersions(left: unknown, right: unknown): number | undefined;
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
* npm registry 约定:把包名拼成 `/latest` 端点,并按宿主给出的 registry 优先级发请求。
|
|
21
|
-
*
|
|
22
|
-
* 宿主 `pluginManager.registries()` 会把 profile 的镜像 / 私源 / token 计划交出来;
|
|
23
|
-
* 拿不到就退回官方 registry。这里只做纯函数(不含网络),网络在 `latest.ts`。
|
|
24
|
-
*/
|
|
25
|
-
declare const OFFICIAL_REGISTRY = "https://registry.npmjs.org/";
|
|
26
|
-
declare const DEFAULT_TIMEOUT_MS = 8000;
|
|
27
|
-
declare const DEFAULT_CACHE_TTL_MS: number;
|
|
28
|
-
/** 宿主 registry 计划的形状(字段都当可选处理,宿主版本差异不该让我们崩)。 */
|
|
29
|
-
interface RegistriesLike {
|
|
30
|
-
resolved?: unknown;
|
|
31
|
-
registry?: unknown;
|
|
32
|
-
fallbackRegistries?: unknown;
|
|
33
|
-
}
|
|
34
|
-
/** npm 包名转 registry 路径:`@scope/name` → `@scope%2Fname`(保留 @,斜杠编码)。 */
|
|
35
|
-
declare function encodePackageName(name: string): string;
|
|
36
|
-
/** registry 归一成 `https://host/` 形式;非法(非 http(s) / 解析失败)返回 null。 */
|
|
37
|
-
declare function normalizeRegistry(value: unknown): string | null;
|
|
38
|
-
/** 由宿主的 registries() 结果拼出要问的 registry 顺序:resolved → registry → fallback,去重。 */
|
|
39
|
-
declare function registryPlan(registries: unknown): string[];
|
|
40
|
-
/** 手动更新命令:`dsh plugin --profile <name> add <pkg>@latest`。 */
|
|
41
|
-
declare function manualCommand(packageName: string, profileName?: string | null): string;
|
|
42
|
-
|
|
43
|
-
export { DEFAULT_CACHE_TTL_MS, DEFAULT_TIMEOUT_MS, OFFICIAL_REGISTRY, type RegistriesLike, type Semver, compareVersions, encodePackageName, isNewerVersion, manualCommand, normalizeRegistry, parseSemver, registryPlan };
|
|
15
|
+
export { type Semver, compareVersions, isNewerVersion, parseSemver };
|
package/dist/index.js
CHANGED
|
@@ -1,59 +1,29 @@
|
|
|
1
1
|
// src/semver.ts
|
|
2
|
-
|
|
2
|
+
import semver from "semver";
|
|
3
3
|
function parseSemver(value) {
|
|
4
4
|
const text = String(value ?? "").trim();
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
function comparePrerelease(left, right) {
|
|
14
|
-
if (left.length === 0 || right.length === 0) {
|
|
15
|
-
return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
16
|
-
}
|
|
17
|
-
const length = Math.max(left.length, right.length);
|
|
18
|
-
for (let index = 0; index < length; index += 1) {
|
|
19
|
-
const a = left[index];
|
|
20
|
-
const b = right[index];
|
|
21
|
-
if (a === void 0 || b === void 0) return a === b ? 0 : a === void 0 ? -1 : 1;
|
|
22
|
-
if (a === b) continue;
|
|
23
|
-
const aNumeric = /^\d+$/.test(a);
|
|
24
|
-
const bNumeric = /^\d+$/.test(b);
|
|
25
|
-
if (aNumeric && bNumeric) {
|
|
26
|
-
const an = BigInt(a);
|
|
27
|
-
const bn = BigInt(b);
|
|
28
|
-
if (an !== bn) return an > bn ? 1 : -1;
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
32
|
-
return a > b ? 1 : -1;
|
|
33
|
-
}
|
|
34
|
-
return 0;
|
|
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
|
+
};
|
|
35
13
|
}
|
|
36
14
|
function isNewerVersion(current, candidate) {
|
|
37
15
|
const a = parseSemver(current);
|
|
38
16
|
const b = parseSemver(candidate);
|
|
39
17
|
if (a === void 0 || b === void 0) return false;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (right !== left) return right > left;
|
|
44
|
-
}
|
|
45
|
-
return comparePrerelease(b.prerelease, a.prerelease) > 0;
|
|
18
|
+
const left = String(current).trim();
|
|
19
|
+
const right = String(candidate).trim();
|
|
20
|
+
return semver.gt(right, left);
|
|
46
21
|
}
|
|
47
22
|
function compareVersions(left, right) {
|
|
48
23
|
const a = parseSemver(left);
|
|
49
24
|
const b = parseSemver(right);
|
|
50
25
|
if (a === void 0 || b === void 0) return void 0;
|
|
51
|
-
|
|
52
|
-
const an = a.core[index] ?? 0;
|
|
53
|
-
const bn = b.core[index] ?? 0;
|
|
54
|
-
if (an !== bn) return an > bn ? 1 : -1;
|
|
55
|
-
}
|
|
56
|
-
return comparePrerelease(a.prerelease, b.prerelease);
|
|
26
|
+
return semver.compare(String(left).trim(), String(right).trim());
|
|
57
27
|
}
|
|
58
28
|
|
|
59
29
|
// src/registry.ts
|
|
@@ -96,6 +66,36 @@ function registryPlan(registries) {
|
|
|
96
66
|
if (ordered.length === 0) ordered.push(OFFICIAL_REGISTRY);
|
|
97
67
|
return ordered;
|
|
98
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
99
|
function manualCommand(packageName, profileName) {
|
|
100
100
|
const spec = `${packageName}@latest`;
|
|
101
101
|
return typeof profileName === "string" && profileName !== "" ? `dsh plugin --profile ${profileName} add ${spec}` : `dsh plugin add ${spec}`;
|
|
@@ -175,5 +175,6 @@ export {
|
|
|
175
175
|
normalizeRegistry,
|
|
176
176
|
parseSemver,
|
|
177
177
|
readDeclaredSpec,
|
|
178
|
-
registryPlan
|
|
178
|
+
registryPlan,
|
|
179
|
+
resolveRegistryPlan
|
|
179
180
|
};
|