@luolingp/request-doctor 0.0.0-stage → 0.11.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 +21 -0
- package/README.md +184 -3
- package/client.js +174 -0
- package/cordis.patch.yml +16 -0
- package/docs/00-/346/226/207/346/241/243/347/264/242/345/274/225.md +26 -0
- package/docs/01-/350/275/257/344/273/266/351/234/200/346/261/202/350/247/204/346/240/274/350/257/264/346/230/216.md +192 -0
- package/docs/02-/350/275/257/344/273/266/350/256/276/350/256/241/350/257/264/346/230/216.md +391 -0
- package/docs/03-/350/275/257/344/273/266/346/265/213/350/257/225/346/212/245/345/221/212.md +214 -0
- package/docs/04-/345/256/211/350/243/205/351/203/250/347/275/262/350/257/264/346/230/216.md +176 -0
- package/docs/05-/347/224/250/346/210/267/346/211/213/345/206/214.md +197 -0
- package/docs/06-/350/275/257/344/273/266/350/264/250/351/207/217/345/272/246/351/207/217/346/212/245/345/221/212.md +109 -0
- package/docs/quality-report.json +27 -0
- package/icon.svg +12 -0
- package/index.js +471 -0
- package/lib/arbitrate.js +45 -0
- package/lib/cache-store.js +79 -0
- package/lib/classify.js +389 -0
- package/lib/compat-env.js +490 -0
- package/lib/compat-repair.js +196 -0
- package/lib/compat.js +332 -0
- package/lib/diagnostics.js +77 -0
- package/lib/env-verify.js +160 -0
- package/lib/install-bundle.js +199 -0
- package/lib/install-rescue.js +161 -0
- package/lib/install-support.js +206 -0
- package/lib/model-repair.js +190 -0
- package/lib/package-fetch.js +181 -0
- package/lib/plugin-discovery.js +232 -0
- package/lib/repair-listeners.js +126 -0
- package/lib/restart-notice.js +21 -0
- package/lib/retry-ledger.js +67 -0
- package/lib/runtime-probe.js +171 -0
- package/lib/semver.js +159 -0
- package/lib/sleep.js +18 -0
- package/lib/stat-cache.js +67 -0
- package/lib/symbol-alias.js +44 -0
- package/lib/timers.js +39 -0
- package/lib/version-repair.js +499 -0
- package/lib/version-search.js +44 -0
- package/locale/en.json +17 -0
- package/locale/zh.json +17 -0
- package/package.json +51 -3
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// 兼容环境的搭建与拆除。
|
|
2
|
+
//
|
|
3
|
+
// 这一半负责「让旧插件在它以为的旧环境里跑」:在插件目录里建一个嵌套 node_modules,
|
|
4
|
+
// 每个包都是一层很薄的垫片,声明的是旧版本号,转发的是当前真正的实现。Node 往上找包
|
|
5
|
+
// 时会先撞上嵌套的这一份,插件于是拿到了它期望的版本,而实跑的仍是新代码。
|
|
6
|
+
//
|
|
7
|
+
// 没有任何版本检查被绕过——插件解析到的那份声明,是真的满足它的。
|
|
8
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { bareNameOf, parseExportedNames } from './compat.js';
|
|
12
|
+
import { guessRenamedSymbol } from './symbol-alias.js';
|
|
13
|
+
import { entryPathOf } from './compat-repair.js';
|
|
14
|
+
|
|
15
|
+
// 标记文件,说明这个目录是模块自己建的
|
|
16
|
+
export const ENV_MARKER = '.@luolingp/request-doctor-compat.json';
|
|
17
|
+
|
|
18
|
+
// 原样保存的 manifest 副本
|
|
19
|
+
export const MANIFEST_BACKUP = 'package.json.@luolingp/request-doctor-original';
|
|
20
|
+
|
|
21
|
+
// 运行时自己发布的包,版本跟 DSH 一起走。schemastery、cordis 这些虽然也在
|
|
22
|
+
// @deepseek-ai 下,但它们有自己的版本线(3.x、4.x),不能跟着运行时改。
|
|
23
|
+
const RUNTIME_OWNED = /^@deepseek-ai\/dsh(?:[-/]|$)/;
|
|
24
|
+
|
|
25
|
+
// 需要建垫片的范围:整个 @deepseek-ai 生态都由运行时供给,插件解析到哪一份
|
|
26
|
+
// 取决于环境,所以都要拦下来。
|
|
27
|
+
const DSH_PACKAGE = /^@deepseek-ai\/[a-z]/;
|
|
28
|
+
|
|
29
|
+
// 读 JSON,读不出来就返回 undefined
|
|
30
|
+
export function readJson(path) {
|
|
31
|
+
try {
|
|
32
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
33
|
+
return value !== null && typeof value === 'object' ? value : undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 从 peer 范围里推出它当初是按哪个版本写的。
|
|
40
|
+
export function pinnedVersion(range, fallback) {
|
|
41
|
+
if (typeof range !== 'string') return fallback;
|
|
42
|
+
const match = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(range);
|
|
43
|
+
return match === null ? fallback : match[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 找出某个 DSH 包真正装在哪儿。
|
|
47
|
+
export function resolveInstalledDir(input) {
|
|
48
|
+
const fromRuntime = input.resolveDir?.(input.name);
|
|
49
|
+
// asar 里的路径 existsSync 看不到,但 Electron 的 fs 能读,直接认下来。
|
|
50
|
+
if (typeof fromRuntime === 'string' && (fromRuntime.includes('app.asar') || existsSync(join(fromRuntime, 'package.json')))) {
|
|
51
|
+
return fromRuntime;
|
|
52
|
+
}
|
|
53
|
+
const candidate = join(input.profileDir, 'node_modules', ...input.name.split('/'));
|
|
54
|
+
return existsSync(join(candidate, 'package.json')) ? candidate : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 写一个最小垫片包,把真实实现转发出去。
|
|
58
|
+
function writeShimPackage(input) {
|
|
59
|
+
const manifest = {
|
|
60
|
+
name: input.name,
|
|
61
|
+
version: input.version,
|
|
62
|
+
type: 'module',
|
|
63
|
+
private: true,
|
|
64
|
+
description: '@luolingp/request-doctor compatibility environment: re-exports the current runtime under the version this plugin expects.',
|
|
65
|
+
main: 'index.js',
|
|
66
|
+
exports: { '.': './index.js', './package.json': './package.json' },
|
|
67
|
+
};
|
|
68
|
+
mkdirSync(input.shimDir, { recursive: true });
|
|
69
|
+
|
|
70
|
+
const target = pathToFileURL(input.realEntry).href;
|
|
71
|
+
const realSource = readFileSync(input.realEntry, 'utf8');
|
|
72
|
+
// export * 把当前运行时还提供的东西全转发出去,旧接口因此能继续用,这里一行实现都不用抄。
|
|
73
|
+
// default 那一句只在目标真有默认导出时才写:指名一个不存在的导出是语法错误,不会软失败。
|
|
74
|
+
// 默认导出有两种写法:export default X 和 export { X as default }。
|
|
75
|
+
// 只认前一种会漏掉后者,而 export * 又不转发 default,插件导入就会报错。
|
|
76
|
+
const hasDefault = parseExportedNames(input.realEntry, (file) => readFileSync(file, 'utf8'))?.includes('default')
|
|
77
|
+
?? /\bexport\s+default\b/.test(realSource);
|
|
78
|
+
|
|
79
|
+
// 旧名字在新版本里改了名的,在这里以旧名重新导出一次。
|
|
80
|
+
// 只做名字层面的转发,不编造任何实现:指向的仍是运行时里真实存在的那个导出。
|
|
81
|
+
const aliases = Object.entries(input.aliases ?? {});
|
|
82
|
+
const aliasLines = aliases
|
|
83
|
+
.filter(([oldName, newName]) => typeof newName === 'string' && newName !== oldName)
|
|
84
|
+
.map(([oldName, newName]) => `export { ${newName} as ${oldName} } from ${JSON.stringify(target)};`);
|
|
85
|
+
|
|
86
|
+
// 插件从子路径导入时,那些导出在子路径模块里,根入口没有。
|
|
87
|
+
// 一并转发出去,`pkg/lib/x.js` 那样的导入才拿得到东西。
|
|
88
|
+
const extraLines = (input.extraTargets ?? [])
|
|
89
|
+
.map((path) => `export * from ${JSON.stringify(pathToFileURL(path).href)};`);
|
|
90
|
+
|
|
91
|
+
const body = [
|
|
92
|
+
`// Generated by @luolingp/request-doctor. Do not edit: this file is replaced on every`,
|
|
93
|
+
`// compatibility scan, and deleted when the plugin is restored.`,
|
|
94
|
+
`export * from ${JSON.stringify(target)};`,
|
|
95
|
+
...(hasDefault ? [`export { default } from ${JSON.stringify(target)};`] : []),
|
|
96
|
+
...extraLines,
|
|
97
|
+
...aliasLines,
|
|
98
|
+
'',
|
|
99
|
+
].join('\n');
|
|
100
|
+
|
|
101
|
+
// 每次扫描都把同样的内容重写一遍,既折腾文件系统,有监听器的话还会触发没必要的重载。
|
|
102
|
+
// 只在内容真变了的时候写。
|
|
103
|
+
writeIfChanged(join(input.shimDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
104
|
+
writeIfChanged(join(input.shimDir, 'index.js'), body);
|
|
105
|
+
return { ok: true, missing: [] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 内容没变就不写盘。
|
|
109
|
+
function writeIfChanged(path, content) {
|
|
110
|
+
try {
|
|
111
|
+
if (readFileSync(path, 'utf8') === content) return false;
|
|
112
|
+
} catch {
|
|
113
|
+
/* missing or unreadable: fall through and write */
|
|
114
|
+
}
|
|
115
|
+
writeFileSync(path, content, 'utf8');
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 给一个插件装好兼容环境。
|
|
120
|
+
export function installCompatEnvironment(input) {
|
|
121
|
+
const root = join(input.pluginDir, 'node_modules');
|
|
122
|
+
|
|
123
|
+
// 先把每个包都核一遍,一个字节都不写。
|
|
124
|
+
//
|
|
125
|
+
// 这一点很关键:边核边写的话,中途发现某个包拿不到,就得把已经写好的删掉,
|
|
126
|
+
// 连上一轮本来能用的环境也一起没了——插件从"能跑"变成"彻底起不来"。
|
|
127
|
+
// 先全部核对,全部通过才动手,拒绝时现场原样不动。
|
|
128
|
+
const plans = [];
|
|
129
|
+
const missing = [];
|
|
130
|
+
const needed = [];
|
|
131
|
+
// 按包名归组:插件可能既从包根导入、也从子路径导入(`pkg/lib/extra.js`)。
|
|
132
|
+
// 后者不是独立的包,当成包处理会去一个不存在的地方找 package.json,整个环境就被拒了。
|
|
133
|
+
for (const group of groupImports(input.imports)) {
|
|
134
|
+
const plan = planPackage(group, input);
|
|
135
|
+
if (plan.missing.length > 0) missing.push(...plan.missing);
|
|
136
|
+
else if (plan.needsOldVersion !== undefined) needed.push(plan.needsOldVersion);
|
|
137
|
+
else plans.push(plan);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 有包需要旧版本而还没拿到:交给调用方去取,这一轮什么都不改。
|
|
141
|
+
if (needed.length > 0) {
|
|
142
|
+
return { status: 'needs-old-version', packages: [], missing: [], needed };
|
|
143
|
+
}
|
|
144
|
+
if (missing.length > 0) {
|
|
145
|
+
// 什么都不改,让插件继续用它现有的环境。
|
|
146
|
+
return { status: 'refused', packages: [], missing, error: `cannot provide ${missing.join(', ')}` };
|
|
147
|
+
}
|
|
148
|
+
if (plans.length === 0) return { status: 'unchanged', packages: [], missing: [] };
|
|
149
|
+
// 只是问一句能不能建,不要动磁盘。调用方用它来判断需不需要先去取旧版本。
|
|
150
|
+
if (input.dryRun === true) return { status: 'installable', packages: plans.map((plan) => plan.name), missing: [] };
|
|
151
|
+
|
|
152
|
+
const created = [];
|
|
153
|
+
const renamed = [];
|
|
154
|
+
for (const plan of plans) {
|
|
155
|
+
const shimDir = join(root, ...plan.name.split('/'));
|
|
156
|
+
// 拿到了真实旧版本,就把整份旧实现放进去。这才是兼容模式:
|
|
157
|
+
// 插件跑的是它当初对着写的那份代码,不是一个转发到今天实现的替身。
|
|
158
|
+
if (plan.copyFrom !== undefined) {
|
|
159
|
+
rmSync(shimDir, { recursive: true, force: true });
|
|
160
|
+
cpSync(plan.copyFrom, shimDir, { recursive: true });
|
|
161
|
+
created.push(plan.name);
|
|
162
|
+
renamed.push(`${plan.name} 使用真实旧版本(${plan.oldVersion})`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// 当前实现就够用,写一层薄垫片指着它。垫片服务的就是当前实现,所以声明当前版本是真的。
|
|
166
|
+
writeShimPackage({
|
|
167
|
+
shimDir,
|
|
168
|
+
name: plan.name,
|
|
169
|
+
version: input.runtimeVersion,
|
|
170
|
+
realEntry: plan.realEntry,
|
|
171
|
+
symbols: plan.symbols,
|
|
172
|
+
aliases: plan.aliases,
|
|
173
|
+
extraTargets: plan.extraTargets,
|
|
174
|
+
});
|
|
175
|
+
created.push(plan.name);
|
|
176
|
+
for (const [from, to] of Object.entries(plan.aliases)) renamed.push(`${plan.name}:${from} -> ${to}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 旧版本自己的依赖也要跟着放进来,而且必须是同一批版本。
|
|
180
|
+
// 只装一个包,它加载时 import 的兄弟包要么找不到,要么拿到新版本,两种都不是插件当初的环境。
|
|
181
|
+
for (const [name, entry] of input.closure ?? new Map()) {
|
|
182
|
+
if (created.includes(name)) continue;
|
|
183
|
+
const destination = join(root, ...name.split('/'));
|
|
184
|
+
rmSync(destination, { recursive: true, force: true });
|
|
185
|
+
cpSync(entry.dir, destination, { recursive: true });
|
|
186
|
+
created.push(name);
|
|
187
|
+
renamed.push(`${name} 使用真实旧版本(${entry.version})`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
dropStaleShims(input.pluginDir, root, created);
|
|
192
|
+
|
|
193
|
+
writeFileSync(
|
|
194
|
+
join(input.pluginDir, ENV_MARKER),
|
|
195
|
+
`${JSON.stringify({ runtimeVersion: input.runtimeVersion, packages: created }, null, 2)}\n`,
|
|
196
|
+
'utf8',
|
|
197
|
+
);
|
|
198
|
+
return { status: 'installed', packages: created, missing: [], renamed };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 把导入清单按包名归组,顺带记下每个包被用到的子路径。
|
|
202
|
+
//
|
|
203
|
+
// 返回 [{ name, symbols, subpaths }],name 一定是裸包名。
|
|
204
|
+
function groupImports(imports) {
|
|
205
|
+
const groups = new Map();
|
|
206
|
+
for (const [specifier, symbols] of imports ?? new Map()) {
|
|
207
|
+
if (!DSH_PACKAGE.test(specifier)) continue;
|
|
208
|
+
const name = bareNameOf(specifier);
|
|
209
|
+
if (groups.has(name) === false) groups.set(name, { name, symbols: new Set(), subpaths: new Set() });
|
|
210
|
+
const group = groups.get(name);
|
|
211
|
+
for (const symbol of symbols ?? []) group.symbols.add(symbol);
|
|
212
|
+
// 包名之后的部分就是子路径,例如 lib/extra.js。
|
|
213
|
+
const tail = specifier.slice(name.length).replace(/^\//, '');
|
|
214
|
+
if (tail !== '') group.subpaths.add(tail);
|
|
215
|
+
}
|
|
216
|
+
return [...groups.values()].map((group) => ({
|
|
217
|
+
name: group.name,
|
|
218
|
+
symbols: [...group.symbols],
|
|
219
|
+
subpaths: [...group.subpaths],
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 决定一个包该怎么装。不碰磁盘。
|
|
224
|
+
//
|
|
225
|
+
// 三条路:当前实现就满足,写一层薄垫片;当前实现缺符号,但已经拿到插件期望的
|
|
226
|
+
// 真实旧版本,就把旧版本整份放进去;旧版本还没拿到,就告诉调用方去取。
|
|
227
|
+
function planPackage(group, input) {
|
|
228
|
+
const { name, symbols } = group;
|
|
229
|
+
const realDir = resolveInstalledDir({ profileDir: input.profileDir, name, resolveDir: input.resolveDir });
|
|
230
|
+
if (realDir === undefined) return { name, symbols, missing: [`${name} (not installed in the profile)`] };
|
|
231
|
+
|
|
232
|
+
const realEntry = resolveEntryOf(realDir);
|
|
233
|
+
if (realEntry === undefined) return { name, symbols, missing: [`${name} (no resolvable entry module)`] };
|
|
234
|
+
|
|
235
|
+
// 插件从子路径导入时,那些符号在子路径模块里,根入口不一定有。
|
|
236
|
+
// 把可用的子路径文件也收进来,符号核对才看得全。
|
|
237
|
+
const extraTargets = group.subpaths
|
|
238
|
+
.map((tail) => join(realDir, ...tail.split('/')))
|
|
239
|
+
.filter((path) => existsSync(path));
|
|
240
|
+
|
|
241
|
+
const resolved = resolveSymbols(name, symbols, [realEntry, ...extraTargets]);
|
|
242
|
+
|
|
243
|
+
if (resolved.missing.length === 0) {
|
|
244
|
+
return { name, symbols, realEntry, extraTargets, aliases: resolved.aliases, missing: [] };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 当前实现确实缺东西。要么用插件期望的那份真实旧版本,要么如实报缺。
|
|
248
|
+
const old = input.oldVersionDirs?.get(name);
|
|
249
|
+
if (old === undefined) {
|
|
250
|
+
return { name, symbols, missing: [], needsOldVersion: { name, version: declaredVersionOf(input, name), symbols: resolved.missing } };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 拿到旧版本了,再核一遍:旧版本里必须真的有这些符号,否则装上去也没用。
|
|
254
|
+
const oldEntry = resolveEntryOf(old.dir);
|
|
255
|
+
if (oldEntry === undefined) return { name, symbols, missing: [`${name}@${old.version} (旧版本没有可用的入口文件)`] };
|
|
256
|
+
const againstOld = resolveSymbols(name, symbols, [oldEntry]);
|
|
257
|
+
if (againstOld.missing.length > 0) {
|
|
258
|
+
return { name, symbols, missing: [`${name}@${old.version} 里也没有 ${againstOld.missing.map((item) => item.split(':')[1]).join(', ')}`] };
|
|
259
|
+
}
|
|
260
|
+
return { name, symbols, copyFrom: old.dir, oldVersion: old.version, missing: [] };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 插件声明的是哪个版本;没声明就跟着运行时走。
|
|
264
|
+
function declaredVersionOf(input, name) {
|
|
265
|
+
const range = input.requirements?.[name];
|
|
266
|
+
return pinnedVersion(range, input.runtimeVersion);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 定位包入口。asar 内的入口 existsSync 看不到,但能读,所以只在非 asar 路径上做这个检查。
|
|
270
|
+
function resolveEntryOf(realDir) {
|
|
271
|
+
const realManifest = readJson(join(realDir, 'package.json'));
|
|
272
|
+
const realEntry = realManifest === undefined ? undefined : entryPathOf(realDir, realManifest);
|
|
273
|
+
if (realEntry === undefined) return undefined;
|
|
274
|
+
if (realEntry.includes('app.asar')) return realEntry;
|
|
275
|
+
return existsSync(realEntry) ? realEntry : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 核对一个包里插件要用的符号,把改名的、缺失的分出来。
|
|
279
|
+
//
|
|
280
|
+
// 目标可能不止一个文件:插件从包根和子路径都导入时,符号散在几处,都得算数。
|
|
281
|
+
function resolveSymbols(name, symbols, targets) {
|
|
282
|
+
const available = [];
|
|
283
|
+
const needles = [];
|
|
284
|
+
for (const target of targets) {
|
|
285
|
+
const exported = parseExportedNames(target, (file) => readFileSync(file, 'utf8'));
|
|
286
|
+
// 空清单同样不可信:CommonJS 包读出来的就是空数组,不能据此判它什么也没导出。
|
|
287
|
+
if (exported !== undefined && exported.length > 0) {
|
|
288
|
+
available.push(...exported);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
// 导出清单读不出来时退回按词边界搜源码,粗一点但能抓到一个明摆着不存在的符号。
|
|
292
|
+
const source = readFileSync(target, 'utf8');
|
|
293
|
+
needles.push(source);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const present = (symbol) => {
|
|
297
|
+
if (available.includes(symbol)) return true;
|
|
298
|
+
const pattern = new RegExp(`\\b${symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
299
|
+
return needles.some((source) => pattern.test(source));
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const aliases = {};
|
|
303
|
+
const missing = [];
|
|
304
|
+
for (const symbol of symbols) {
|
|
305
|
+
if (symbol === 'default' || present(symbol)) continue;
|
|
306
|
+
const candidate = guessRenamedSymbol(symbol, available);
|
|
307
|
+
if (candidate !== undefined && present(candidate)) {
|
|
308
|
+
aliases[symbol] = candidate;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// 当前实现确实没有这个符号。不编造、也不放会抛错的占位——
|
|
312
|
+
// 要么换上插件期望的真实旧版本(由调用方去取),要么如实报缺。
|
|
313
|
+
missing.push(`${name}:${symbol}`);
|
|
314
|
+
}
|
|
315
|
+
return { aliases, missing };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 清掉这个插件已经不需要的垫片。升级后不再导入某个包了,那层旧副本不能留着遮住真身。
|
|
319
|
+
function dropStaleShims(pluginDir, root, created) {
|
|
320
|
+
const previous = readCompatEnvironment(pluginDir);
|
|
321
|
+
for (const stale of previous?.packages ?? []) {
|
|
322
|
+
if (created.includes(stale)) continue;
|
|
323
|
+
try {
|
|
324
|
+
rmSync(join(root, ...stale.split('/')), { recursive: true, force: true });
|
|
325
|
+
} catch {
|
|
326
|
+
// 目录被占用就留给下一轮
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 拆掉兼容环境,把插件还原成它发布时的样子。
|
|
332
|
+
export function removeCompatEnvironment(pluginDir) {
|
|
333
|
+
const marker = join(pluginDir, ENV_MARKER);
|
|
334
|
+
const environment = readJson(marker);
|
|
335
|
+
const root = join(pluginDir, 'node_modules');
|
|
336
|
+
let removed = false;
|
|
337
|
+
// 只删这个环境自己建的包。插件可能自带 node_modules(打包进来的依赖),
|
|
338
|
+
// 整个目录一删就把跟兼容无关的东西也毁了。
|
|
339
|
+
for (const name of environment?.packages ?? []) {
|
|
340
|
+
try {
|
|
341
|
+
rmSync(join(root, ...name.split('/')), { recursive: true, force: true });
|
|
342
|
+
removed = true;
|
|
343
|
+
} catch {
|
|
344
|
+
/* a locked directory is retried on the next scan */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// 把环境留下的空目录收掉,这样没有自带依赖的插件能恢复成发布时的样子。
|
|
348
|
+
pruneEmptyTree(root);
|
|
349
|
+
try {
|
|
350
|
+
rmSync(marker, { force: true });
|
|
351
|
+
} catch {
|
|
352
|
+
/* nothing to do */
|
|
353
|
+
}
|
|
354
|
+
return removed;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 从最里层往外删空目录,碰到非空就停。
|
|
358
|
+
function pruneEmptyTree(root) {
|
|
359
|
+
// 只走本模块自己造的 @scope/name 这种形状。插件自己的依赖树可能有几百个目录,
|
|
360
|
+
// 为了发现它非空而整个扫一遍,代价比这点清理本身还大。
|
|
361
|
+
const removeIfEmpty = (dir) => {
|
|
362
|
+
try {
|
|
363
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
364
|
+
} catch {
|
|
365
|
+
/* missing or in use: nothing to prune */
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
let scopes;
|
|
369
|
+
try {
|
|
370
|
+
scopes = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
371
|
+
} catch {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
for (const scope of scopes) {
|
|
375
|
+
const dir = join(root, scope.name);
|
|
376
|
+
// A scoped package directory (`@deepseek-ai`) is pruned after its children.
|
|
377
|
+
if (scope.name.startsWith('@')) {
|
|
378
|
+
let children;
|
|
379
|
+
try {
|
|
380
|
+
children = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
381
|
+
} catch {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
for (const child of children) removeIfEmpty(join(dir, child.name));
|
|
385
|
+
}
|
|
386
|
+
removeIfEmpty(dir);
|
|
387
|
+
}
|
|
388
|
+
removeIfEmpty(root);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 把插件自己的 DSH 声明改成当前运行时满足的版本。
|
|
392
|
+
export function alignManifest(input) {
|
|
393
|
+
const manifestPath = join(input.pluginDir, 'package.json');
|
|
394
|
+
const manifest = readJson(manifestPath);
|
|
395
|
+
if (manifest === undefined) return { status: 'skipped', rewrote: [], error: 'package.json is unreadable' };
|
|
396
|
+
|
|
397
|
+
const { next, rewrote } = realignDeclarations(manifest, input.versions, input.runtimeVersion);
|
|
398
|
+
if (rewrote.length === 0) return { status: 'already', rewrote: [] };
|
|
399
|
+
|
|
400
|
+
const preserved = preserveOriginal(manifestPath, join(input.pluginDir, MANIFEST_BACKUP));
|
|
401
|
+
if (preserved !== undefined) return { status: 'failed', rewrote: [], error: preserved };
|
|
402
|
+
const written = replaceAtomically(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
403
|
+
if (written !== undefined) return { status: 'failed', rewrote: [], error: written };
|
|
404
|
+
return { status: 'aligned', rewrote };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 算出插件在这个运行时下应该声明成什么样。
|
|
408
|
+
function realignDeclarations(manifest, versions, runtimeVersion) {
|
|
409
|
+
const next = JSON.parse(JSON.stringify(manifest));
|
|
410
|
+
const rewrote = [];
|
|
411
|
+
for (const [name, target] of peerTargets(next, versions, runtimeVersion)) {
|
|
412
|
+
next.peerDependencies[name] = target;
|
|
413
|
+
rewrote.push(`peerDependencies.${name}`);
|
|
414
|
+
}
|
|
415
|
+
if (alignEnginesDsh(next, runtimeVersion)) rewrote.push('engines.dsh');
|
|
416
|
+
return { next, rewrote };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 列出需要改动的那些 DSH peer 范围。
|
|
420
|
+
//
|
|
421
|
+
// 只改运行时自己发布的那几个(dsh*)。schemastery、cordis 有自己的版本线,
|
|
422
|
+
// 把 "^3.18.5" 改成 "0.2.1-rc.3" 只会让声明变成假话——它们的实现根本没变。
|
|
423
|
+
function peerTargets(manifest, versions, runtimeVersion) {
|
|
424
|
+
const peers = manifest.peerDependencies;
|
|
425
|
+
if (!isPlainObject(peers)) return [];
|
|
426
|
+
return Object.entries(peers)
|
|
427
|
+
.filter(([name, range]) => RUNTIME_OWNED.test(name) && typeof range === 'string')
|
|
428
|
+
.map(([name, range]) => [name, versions?.[name] ?? runtimeVersion, range])
|
|
429
|
+
.filter(([, target, range]) => typeof target === 'string' && range !== target)
|
|
430
|
+
.map(([name, target]) => [name, target]);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 是不是普通对象(数组和 null 不算)。
|
|
434
|
+
function isPlainObject(value) {
|
|
435
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 把 engines.dsh 指到当前运行时。
|
|
439
|
+
function alignEnginesDsh(manifest, runtimeVersion) {
|
|
440
|
+
const engines = manifest.engines;
|
|
441
|
+
if (engines === null || typeof engines !== 'object') return false;
|
|
442
|
+
if (typeof engines.dsh !== 'string' || typeof runtimeVersion !== 'string') return false;
|
|
443
|
+
if (engines.dsh === runtimeVersion) return false;
|
|
444
|
+
engines.dsh = runtimeVersion;
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 把原来的 manifest 备份一份,只备一次。
|
|
449
|
+
function preserveOriginal(manifestPath, backupPath) {
|
|
450
|
+
try {
|
|
451
|
+
if (!existsSync(backupPath)) copyFileSync(manifestPath, backupPath);
|
|
452
|
+
return undefined;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
return `could not preserve the original manifest: ${String(error)}`;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 走临时文件再改名的方式替换文件。
|
|
459
|
+
function replaceAtomically(path, content) {
|
|
460
|
+
const temporary = `${path}.@luolingp/request-doctor-tmp`;
|
|
461
|
+
try {
|
|
462
|
+
writeFileSync(temporary, content, 'utf8');
|
|
463
|
+
renameSync(temporary, path);
|
|
464
|
+
return undefined;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
try {
|
|
467
|
+
rmSync(temporary, { force: true });
|
|
468
|
+
} catch {
|
|
469
|
+
/* the temp file is harmless if it cannot be removed */
|
|
470
|
+
}
|
|
471
|
+
return String(error);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// 还原插件原本的 manifest。
|
|
475
|
+
export function restoreManifest(pluginDir) {
|
|
476
|
+
const manifestPath = join(pluginDir, 'package.json');
|
|
477
|
+
const backupPath = join(pluginDir, MANIFEST_BACKUP);
|
|
478
|
+
if (!existsSync(backupPath)) return false;
|
|
479
|
+
try {
|
|
480
|
+
renameSync(backupPath, manifestPath);
|
|
481
|
+
return true;
|
|
482
|
+
} catch {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 读一下已装环境的标记,没有就返回 undefined。
|
|
488
|
+
export function readCompatEnvironment(pluginDir) {
|
|
489
|
+
return readJson(join(pluginDir, ENV_MARKER));
|
|
490
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// 找到装好的插件,拿它跟当前运行时对一遍。
|
|
2
|
+
//
|
|
3
|
+
// 这是版本修复里「能不能真跑」那一半:manifest 里的 peer 范围只是句声明,
|
|
4
|
+
// 所以放行之前要拿插件实际导入的东西,去对运行时实际导出的东西。
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
auditPluginCompatibility,
|
|
10
|
+
collectDshImports,
|
|
11
|
+
collectIdLiterals,
|
|
12
|
+
collectProviderIds,
|
|
13
|
+
parseExportedNames,
|
|
14
|
+
registersVariantRoute,
|
|
15
|
+
} from './compat.js';
|
|
16
|
+
|
|
17
|
+
// 一个插件的模块图最多读这么多个文件
|
|
18
|
+
const MAX_GRAPH_FILES = 40;
|
|
19
|
+
|
|
20
|
+
// 读文件,读不到就返回 undefined
|
|
21
|
+
function readText(path) {
|
|
22
|
+
try {
|
|
23
|
+
return readFileSync(path, 'utf8');
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 从 profile 目录推算某个包的安装位置
|
|
30
|
+
export function packageDir(profileDir, name) {
|
|
31
|
+
if (profileDir === undefined) return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const require = createRequire(join(profileDir, 'package.json'));
|
|
34
|
+
return dirname(require.resolve(`${name}/package.json`));
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 算出某个已装包的入口文件路径。
|
|
41
|
+
export function installedEntryPath(profileDir, name) {
|
|
42
|
+
const dir = packageDir(profileDir, name);
|
|
43
|
+
if (dir === undefined) return undefined;
|
|
44
|
+
const manifestText = readText(join(dir, 'package.json'));
|
|
45
|
+
if (manifestText === undefined) return undefined;
|
|
46
|
+
let manifest;
|
|
47
|
+
try {
|
|
48
|
+
manifest = JSON.parse(manifestText);
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return entryPathOf(dir, manifest);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 算出一个包的入口文件在哪。
|
|
56
|
+
export function entryPathOf(dir, manifest) {
|
|
57
|
+
const pick = (value) => {
|
|
58
|
+
if (typeof value === 'string') return value;
|
|
59
|
+
if (value !== null && typeof value === 'object') {
|
|
60
|
+
for (const key of ['default', 'import', 'node', 'require']) {
|
|
61
|
+
const nested = pick(value[key]);
|
|
62
|
+
if (nested !== undefined) return nested;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
};
|
|
67
|
+
const exported = manifest?.exports;
|
|
68
|
+
const fromExports = pick(exported?.['.'] ?? (typeof exported === 'string' ? exported : undefined));
|
|
69
|
+
const relative = fromExports ?? (typeof manifest?.main === 'string' ? manifest.main : 'index.js');
|
|
70
|
+
return relative === undefined ? undefined : resolve(dir, relative);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 把一个插件的 DSH 导入和 LLM provider 路由一起收上来。
|
|
74
|
+
export function collectPackageFacts(dir, entryPath) {
|
|
75
|
+
const scan = { imports: new Map(), providers: new Set(), literals: new Set(), variantRoute: false };
|
|
76
|
+
walkModuleGraph(entryPath, scan);
|
|
77
|
+
// A variant route's id lives in a sibling chunk; any declared id could be it.
|
|
78
|
+
if (scan.variantRoute) for (const id of scan.literals) scan.providers.add(id);
|
|
79
|
+
return { imports: scan.imports, providers: [...scan.providers] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 读一个模块和它相对路径上的依赖,把结果累计起来。
|
|
83
|
+
//
|
|
84
|
+
// 只跟相对导入:打包进来的 chunk 自己的 DSH 导入属于这个插件的契约,
|
|
85
|
+
// 但它的第三方依赖不算。
|
|
86
|
+
function walkModuleGraph(entryPath, scan) {
|
|
87
|
+
const queue = [entryPath];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
while (queue.length > 0) {
|
|
90
|
+
const current = queue.shift();
|
|
91
|
+
if (current === undefined || seen.has(current)) continue;
|
|
92
|
+
seen.add(current);
|
|
93
|
+
if (seen.size > MAX_GRAPH_FILES) break;
|
|
94
|
+
const source = readText(current);
|
|
95
|
+
if (source === undefined) continue;
|
|
96
|
+
absorbModule(source, current, scan, queue);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 把一个模块里的声明并进累加器,再把它导入的模块排进队列。
|
|
101
|
+
function absorbModule(source, current, scan, queue) {
|
|
102
|
+
for (const [packageName, symbols] of collectDshImports(source)) {
|
|
103
|
+
scan.imports.set(packageName, [...new Set([...(scan.imports.get(packageName) ?? []), ...symbols])]);
|
|
104
|
+
}
|
|
105
|
+
for (const id of collectProviderIds(source)) scan.providers.add(id);
|
|
106
|
+
for (const id of collectIdLiterals(source)) scan.literals.add(id);
|
|
107
|
+
if (registersVariantRoute(source)) scan.variantRoute = true;
|
|
108
|
+
for (const target of relativeImportsOf(source, current)) queue.push(target);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 把模块里的相对导入解析成真实存在的文件路径。
|
|
112
|
+
function relativeImportsOf(source, current) {
|
|
113
|
+
const found = [];
|
|
114
|
+
const pattern = /\bfrom\s*["'](\.[^"']+)["']/g;
|
|
115
|
+
let match;
|
|
116
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
117
|
+
const target = resolve(dirname(current), match[1]);
|
|
118
|
+
for (const candidate of [target, `${target}.js`, join(target, 'index.js')]) {
|
|
119
|
+
if (readText(candidate) !== undefined) {
|
|
120
|
+
found.push(candidate);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 拿一个已装的 bundle 跟当前运行时对一遍。
|
|
129
|
+
// 插件查不了的时候,按这个形状返回。
|
|
130
|
+
function review(reason) {
|
|
131
|
+
return { verdict: 'review', reasons: [reason], missing: [], imports: [], providers: [] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function auditInstalledPlugin(input) {
|
|
135
|
+
const dir = packageDir(input.profileDir, input.name);
|
|
136
|
+
if (dir === undefined) {
|
|
137
|
+
return review(`${input.name} is not installed under ${input.profileDir}`);
|
|
138
|
+
}
|
|
139
|
+
// 调用方判断「要审计」的时候八成已经解析过这个 manifest 了,直接拿来用,别读第二遍。
|
|
140
|
+
let manifest = input.manifest;
|
|
141
|
+
if (manifest === undefined) {
|
|
142
|
+
const manifestText = readText(join(dir, 'package.json'));
|
|
143
|
+
if (manifestText === undefined) {
|
|
144
|
+
return review(`${input.name} has no readable package.json`);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
manifest = JSON.parse(manifestText);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return review(`${input.name}/package.json is not valid JSON: ${String(error)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const entry = entryPathOf(dir, manifest);
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
return review(`${input.name} declares no resolvable entry module`);
|
|
155
|
+
}
|
|
156
|
+
const facts = collectPackageFacts(dir, entry);
|
|
157
|
+
const { imports, providers } = facts;
|
|
158
|
+
// 一个 DSH 包都不导入的插件,就没有符号可验。这时候声明满足就够了——
|
|
159
|
+
// 它跟运行时之间只有 manifest 这一层契约,没有别的可能出错的地方。
|
|
160
|
+
// 一个 DSH 包都不导入的插件,就没有符号可验。它跟运行时之间只有 manifest 这一层契约,
|
|
161
|
+
// 声明对不上也只是这一层的事——把声明对齐过去就能跑,不需要人来点头。
|
|
162
|
+
// 皮肤、纯客户端界面这类插件全是这种。
|
|
163
|
+
if (imports.size === 0) {
|
|
164
|
+
const unmet = Object.keys(input.requirements ?? {});
|
|
165
|
+
return {
|
|
166
|
+
verdict: 'compatible',
|
|
167
|
+
reasons: [unmet.length === 0
|
|
168
|
+
? `${input.name} imports no @deepseek-ai package, so its manifest declaration is the whole contract, and it holds`
|
|
169
|
+
: `${input.name} imports no @deepseek-ai package; only its manifest declaration needs realigning`],
|
|
170
|
+
missing: [],
|
|
171
|
+
imports: [],
|
|
172
|
+
providers,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const exportsCache = new Map();
|
|
176
|
+
const exportsOf = async (packageName) => {
|
|
177
|
+
if (exportsCache.has(packageName)) return exportsCache.get(packageName);
|
|
178
|
+
const runtimeEntry = input.runtimeEntryOf?.(packageName);
|
|
179
|
+
const names = runtimeEntry === undefined ? undefined : parseExportedNames(runtimeEntry, readText);
|
|
180
|
+
exportsCache.set(packageName, names);
|
|
181
|
+
return names;
|
|
182
|
+
};
|
|
183
|
+
const audit = input.assumeCompatible === true
|
|
184
|
+
? { verdict: 'compatible', reasons: ['already aligned for this runtime by a prepared install'], missing: [], conflicts: [] }
|
|
185
|
+
: await auditPluginCompatibility({
|
|
186
|
+
runtimeVersion: input.runtimeVersion,
|
|
187
|
+
requirements: input.requirements,
|
|
188
|
+
imports,
|
|
189
|
+
exportsOf,
|
|
190
|
+
providerIds: providers,
|
|
191
|
+
takenProviders: input.takenProviders ?? [],
|
|
192
|
+
});
|
|
193
|
+
// `importMap` is what the environment builder needs: which symbols to verify
|
|
194
|
+
// 在重定位插件导入之前,先拿每个真实包核一遍。
|
|
195
|
+
return { ...audit, imports: [...imports.keys()], importMap: imports, providers };
|
|
196
|
+
}
|