dsh-plugin-manager-companion 0.1.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/LICENSE +21 -0
- package/README.en.md +144 -0
- package/README.md +142 -0
- package/cordis.patch.yml +9 -0
- package/dist/about.d.ts +77 -0
- package/dist/about.js +179 -0
- package/dist/cli.d.ts +226 -0
- package/dist/cli.js +856 -0
- package/dist/client/AboutPage.d.ts +75 -0
- package/dist/client/ConsolePage.d.ts +79 -0
- package/dist/client/KindsPage.d.ts +21 -0
- package/dist/client/MarketplacePage.d.ts +36 -0
- package/dist/client/OfficialSlots.d.ts +35 -0
- package/dist/client/UpgradeRow.d.ts +108 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/locales.d.ts +475 -0
- package/dist/client/pmSelect.d.ts +38 -0
- package/dist/client/shared.d.ts +928 -0
- package/dist/client/upgradeView.d.ts +278 -0
- package/dist/client/wire.d.ts +401 -0
- package/dist/client.js +9194 -0
- package/dist/diagnostics.d.ts +332 -0
- package/dist/diagnostics.js +2631 -0
- package/dist/envManager.d.ts +1047 -0
- package/dist/envManager.js +3214 -0
- package/dist/fix.d.ts +60 -0
- package/dist/fix.js +168 -0
- package/dist/guard.d.ts +133 -0
- package/dist/guard.js +232 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +1150 -0
- package/dist/installSession.d.ts +111 -0
- package/dist/installSession.js +150 -0
- package/dist/kinds.d.ts +464 -0
- package/dist/kinds.js +1029 -0
- package/dist/marketView.d.ts +261 -0
- package/dist/marketView.js +406 -0
- package/dist/marketplace.d.ts +248 -0
- package/dist/marketplace.js +500 -0
- package/dist/match.d.ts +67 -0
- package/dist/match.js +203 -0
- package/dist/net.d.ts +108 -0
- package/dist/net.js +163 -0
- package/dist/official.d.ts +145 -0
- package/dist/official.js +205 -0
- package/dist/paths.d.ts +108 -0
- package/dist/paths.js +236 -0
- package/dist/presets.d.ts +299 -0
- package/dist/presets.js +578 -0
- package/dist/qualityGate.d.ts +66 -0
- package/dist/qualityGate.js +247 -0
- package/dist/rank.d.ts +88 -0
- package/dist/rank.js +164 -0
- package/dist/registry.d.ts +295 -0
- package/dist/registry.js +686 -0
- package/dist/rest.d.ts +122 -0
- package/dist/rest.js +219 -0
- package/dist/scan.d.ts +134 -0
- package/dist/scan.js +396 -0
- package/dist/settings.d.ts +447 -0
- package/dist/settings.js +263 -0
- package/dist/tags.d.ts +119 -0
- package/dist/tags.js +166 -0
- package/dist/tools.d.ts +131 -0
- package/dist/tools.js +377 -0
- package/dist/types.d.ts +651 -0
- package/dist/types.js +13 -0
- package/dist/upgrade.d.ts +428 -0
- package/dist/upgrade.js +1100 -0
- package/dist/upgradeView.d.ts +313 -0
- package/dist/upgradeView.js +273 -0
- package/docs/images/readme/01-console-health.png +0 -0
- package/docs/images/readme/02-console-envs.png +0 -0
- package/docs/images/readme/03-marketplace.png +0 -0
- package/docs/images/readme/04-official-plugin-page.png +0 -0
- package/package.json +104 -0
package/dist/presets.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件拥有的 agent 预设:归属标记、卸载清理、禁用归档 / 启用恢复。
|
|
3
|
+
*
|
|
4
|
+
* 归属:A 类·重写(读标记载录、移动与删除目录、经官方服务落刀;旧 src/presets.ts
|
|
5
|
+
* 仅作意图参考,未复制代码)。
|
|
6
|
+
* 旧实现参考:dsh-web-plugin-manager/src/presets.ts(385 行:中立标准标记
|
|
7
|
+
* .dsh-preset-owner.json = {format, owners[], digest}、兼容读取 dsh-agent-rp 的
|
|
8
|
+
* .dsh-agent-rp-owner.json 与 gamelike 的 .plugin-manage-owner.json、卸载只清
|
|
9
|
+
* "唯一 owner 且 digest 匹配"、禁用零损失归档、启用恢复)。
|
|
10
|
+
* 官方复用:删除走 ctx.get("agentPresets").remove(id)——官方会同时清掉指向该预设的
|
|
11
|
+
* settings.default 并保留已挂载会话;本模块**绝不直接删官方 roster 里的目录**,
|
|
12
|
+
* 只有在宿主服务不存在时(CLI/离线)才降级为带路径守卫的直删。
|
|
13
|
+
* 目录枚举复用官方 roster 的 roots(ctx.get("agentPresets").roots),比"自己拼
|
|
14
|
+
* <dshHome>/.agent-presets"更贴近官方发现口径。
|
|
15
|
+
* 前提检查:仍然成立——预设目录名是 preset id,不是所属插件名,所以卸载插件后
|
|
16
|
+
* 预设会变成孤儿;官方明文不做这件事("the manager cannot ... edit an agent
|
|
17
|
+
* preset composition")。旧实现里"经宿主 settings 清 default"的部分已由官方
|
|
18
|
+
* remove() 承担,本模块不再自己写 settings。
|
|
19
|
+
*
|
|
20
|
+
* 为什么禁用只能归档不能删:官方对"坏预设"的判定只看组合文件是否可读/可解析,
|
|
21
|
+
* 不反映"它引用的插件被禁用了";拿不到"这个坏是我造成的"这个信号就没有安全删除
|
|
22
|
+
* 的依据,何况临时禁用本来就不该毁数据。
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
26
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { cacheRoot, isUnderRoot, presetsRoot, renameRetry, rmRetry, loadKindRecords, resolvedHome, } from "./kinds.js";
|
|
29
|
+
import { enqueueMutation, readEnvironmentManifest } from "./paths.js";
|
|
30
|
+
/**
|
|
31
|
+
* 中立标准归属标记文件名(本插件安装预设时写入;兼容读取生态标记见下)。
|
|
32
|
+
*
|
|
33
|
+
* 字面量在这里定义而不是从 kinds.ts 转口:kinds 与 presets 互相 import(kinds 安装时要
|
|
34
|
+
* 写标记、presets 要读磁盘与路径工具),而 ESM 的循环依赖在**模块初始化期**取值会撞上
|
|
35
|
+
* TDZ(Cannot access before initialization)。两边都只在函数体里引用对方,循环就是安全的。
|
|
36
|
+
*/
|
|
37
|
+
export const OWNER_MARKER = '.dsh-preset-owner.json';
|
|
38
|
+
/** 标记 schema 版本。 */
|
|
39
|
+
export const OWNER_MARKER_FORMAT = 0;
|
|
40
|
+
/** 生态里其他工具写的标记(只读,不写):文件名后缀匹配 owner.json。 */
|
|
41
|
+
const THIRD_PARTY_MARKER = /owner\.json$/i;
|
|
42
|
+
/** 组合文件:digest 覆盖的范围(与 dsh-agent-rp 的既有协议一致)。 */
|
|
43
|
+
const DIGEST_FILES = ['agent.cordis.yml', 'preset.yml'];
|
|
44
|
+
/**
|
|
45
|
+
* 禁用插件的预设归档目录。
|
|
46
|
+
*
|
|
47
|
+
* 必须在官方 user root **之外**(否则 roster 照样发现它),也必须在 profiles 根
|
|
48
|
+
* 之外(否则会被当成一个环境)。放在我们的缓存目录下,与旧仓库同名同位置,
|
|
49
|
+
* 两个包共存期间归档数据是同一份。
|
|
50
|
+
*
|
|
51
|
+
* @returns 归档根目录。
|
|
52
|
+
*/
|
|
53
|
+
export function presetArchiveDir() {
|
|
54
|
+
return join(cacheRoot(), 'preset-archive');
|
|
55
|
+
}
|
|
56
|
+
/** 归档子目录:每个插件一个子目录,避免不同插件的同名预设互相打架。 */
|
|
57
|
+
function archiveDirFor(pluginName) {
|
|
58
|
+
return join(presetArchiveDir(), slug(pluginName));
|
|
59
|
+
}
|
|
60
|
+
/** 归档用的插件名 slug(目录名安全)。 */
|
|
61
|
+
function slug(name) {
|
|
62
|
+
const value = name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
63
|
+
return value === '' ? 'unknown-plugin' : value;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 计算预设目录的安装 digest。
|
|
67
|
+
*
|
|
68
|
+
* 方案沿用既有生态协议:按 DIGEST_FILES 逐个 update(文件名 + NUL + 内容 + NUL),
|
|
69
|
+
* 取 sha256。文件不存在就跳过(预设可以只有组合文件);读取失败返回 null,
|
|
70
|
+
* 语义是"无法核实",由调用方按"可能被改过"处理(fail closed)。
|
|
71
|
+
*
|
|
72
|
+
* @param presetDir - 预设目录。
|
|
73
|
+
* @returns 十六进制 digest;无法读取时为 null。
|
|
74
|
+
*/
|
|
75
|
+
export function presetDigest(presetDir) {
|
|
76
|
+
const hash = createHash('sha256');
|
|
77
|
+
for (const file of DIGEST_FILES) {
|
|
78
|
+
const path = join(presetDir, file);
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
hash.update(file);
|
|
83
|
+
hash.update(String.fromCharCode(0));
|
|
84
|
+
hash.update(readFileSync(path));
|
|
85
|
+
hash.update(String.fromCharCode(0));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return hash.digest('hex');
|
|
92
|
+
}
|
|
93
|
+
/** 把记录的 owner/owners 字段收敛为非空字符串数组。 */
|
|
94
|
+
function ownersOfRecord(value) {
|
|
95
|
+
if (typeof value === 'string')
|
|
96
|
+
return value.length > 0 ? [value] : [];
|
|
97
|
+
if (Array.isArray(value))
|
|
98
|
+
return value.filter((item) => typeof item === 'string' && item.length > 0);
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
/** 读一个 JSON 文件;失败返回 null。 */
|
|
102
|
+
function readJson(path) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** 目录项名列表;失败返回空数组。 */
|
|
111
|
+
function entriesOf(dir) {
|
|
112
|
+
try {
|
|
113
|
+
return readdirSync(dir);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 读取一个预设目录声明的全部 owner(标准标记 + 生态既有标记)。
|
|
121
|
+
*
|
|
122
|
+
* 兼容三种形态:
|
|
123
|
+
* - 我们的标准标记 .dsh-preset-owner.json:{format: 0, owners: string[], digest};
|
|
124
|
+
* - dsh-agent-rp 的 .dsh-agent-rp-owner.json:{owner, format: 0, digest};
|
|
125
|
+
* - gamelike-plugin-manage 的 .plugin-manage-owner.json:{format, owners[]}(无 digest)。
|
|
126
|
+
*
|
|
127
|
+
* 失败策略是**不对称**的,这是刻意的:标准标记存在但损坏 → 整体返回空数组
|
|
128
|
+
* (fail closed,什么都不删);第三方标记损坏/format 不符 → 单个跳过(他们的写入
|
|
129
|
+
* 端约束更松,用一条坏文件阻断全部清理不合理)。
|
|
130
|
+
*
|
|
131
|
+
* @param presetDir - 预设目录。
|
|
132
|
+
* @returns owner 列表(去重、按发现顺序)。
|
|
133
|
+
*/
|
|
134
|
+
export function readPresetOwners(presetDir) {
|
|
135
|
+
const owners = new Set();
|
|
136
|
+
const marker = join(presetDir, OWNER_MARKER);
|
|
137
|
+
if (existsSync(marker)) {
|
|
138
|
+
const record = readJson(marker);
|
|
139
|
+
if (record === null || record.format !== OWNER_MARKER_FORMAT)
|
|
140
|
+
return [];
|
|
141
|
+
for (const owner of [...ownersOfRecord(record.owners), ...ownersOfRecord(record.owner)])
|
|
142
|
+
owners.add(owner);
|
|
143
|
+
}
|
|
144
|
+
for (const file of entriesOf(presetDir)) {
|
|
145
|
+
if (file === OWNER_MARKER || !THIRD_PARTY_MARKER.test(file))
|
|
146
|
+
continue;
|
|
147
|
+
const record = readJson(join(presetDir, file));
|
|
148
|
+
if (record === null)
|
|
149
|
+
continue;
|
|
150
|
+
// 带 format 的第三方标记(dsh-agent-rp 形态)必须匹配,不猜未来 schema。
|
|
151
|
+
if (record.format !== undefined && record.format !== OWNER_MARKER_FORMAT)
|
|
152
|
+
continue;
|
|
153
|
+
for (const owner of [...ownersOfRecord(record.owner), ...ownersOfRecord(record.owners)])
|
|
154
|
+
owners.add(owner);
|
|
155
|
+
}
|
|
156
|
+
return [...owners];
|
|
157
|
+
}
|
|
158
|
+
/** 标记里记录的 digest:标准标记优先,其次生态标记。 */
|
|
159
|
+
function recordedDigest(presetDir) {
|
|
160
|
+
const marker = join(presetDir, OWNER_MARKER);
|
|
161
|
+
if (existsSync(marker)) {
|
|
162
|
+
const record = readJson(marker);
|
|
163
|
+
if (record !== null && typeof record.digest === 'string')
|
|
164
|
+
return record.digest;
|
|
165
|
+
if (record !== null)
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
for (const file of entriesOf(presetDir)) {
|
|
169
|
+
if (!THIRD_PARTY_MARKER.test(file))
|
|
170
|
+
continue;
|
|
171
|
+
const record = readJson(join(presetDir, file));
|
|
172
|
+
if (record !== null && typeof record.digest === 'string')
|
|
173
|
+
return record.digest;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 枚举一个根下的预设目录(跳过点目录与预设归档目录)。
|
|
179
|
+
* @param root - 预设根目录。
|
|
180
|
+
* @returns 预设条目;根不存在或不可读时返回空数组。
|
|
181
|
+
*/
|
|
182
|
+
export function scanPresets(root) {
|
|
183
|
+
const found = [];
|
|
184
|
+
let entries;
|
|
185
|
+
try {
|
|
186
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return found;
|
|
190
|
+
}
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
193
|
+
continue;
|
|
194
|
+
found.push({ id: entry.name, dir: join(root, entry.name) });
|
|
195
|
+
}
|
|
196
|
+
return found;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 一个预设目录的归属判定。
|
|
200
|
+
*
|
|
201
|
+
* 只有**唯一 owner 且等于给定插件名**才叫 owned:多 owner 预设(两个插件的归属
|
|
202
|
+
* 重叠)不做任何操作——一个 owner 的卸载不该带走另一个的数据。
|
|
203
|
+
*
|
|
204
|
+
* modified 的三种来源都算"用户改过":digest 不匹配、标准标记损坏(fail closed)、
|
|
205
|
+
* 无法读取组合文件(presetDigest 返回 null)。只有根本没有 digest 的生态标记
|
|
206
|
+
* (gamelike 形态)报未修改——它没有可比对的基线。
|
|
207
|
+
*
|
|
208
|
+
* @param presetDir - 预设目录。
|
|
209
|
+
* @param pluginName - 插件名(通常是包名)。
|
|
210
|
+
* @returns 归属判定。
|
|
211
|
+
*/
|
|
212
|
+
export function presetOwnedBy(presetDir, pluginName) {
|
|
213
|
+
const owners = readPresetOwners(presetDir);
|
|
214
|
+
if (owners.length !== 1 || owners[0] !== pluginName)
|
|
215
|
+
return { owned: false, modified: false };
|
|
216
|
+
const marker = join(presetDir, OWNER_MARKER);
|
|
217
|
+
if (existsSync(marker) && readJson(marker) === null)
|
|
218
|
+
return { owned: true, modified: true };
|
|
219
|
+
const digest = recordedDigest(presetDir);
|
|
220
|
+
if (digest === null)
|
|
221
|
+
return { owned: true, modified: false };
|
|
222
|
+
return { owned: true, modified: presetDigest(presetDir) !== digest };
|
|
223
|
+
}
|
|
224
|
+
/** 调用的返回值既可能是同步值也可能是 Promise;统一等待。 */
|
|
225
|
+
async function call(value) {
|
|
226
|
+
return await value;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 取官方 agentPresets 服务(ctx 或服务本身都能传)。
|
|
230
|
+
*
|
|
231
|
+
* 传入的可以是 Cordis 的 Context(用 ctx.get 取服务),也可以是服务对象自己
|
|
232
|
+
* (CLI 测试替身)。两者都不满足时返回 undefined——调用方据此降级为直删。
|
|
233
|
+
*
|
|
234
|
+
* @param source - Context 或服务对象。
|
|
235
|
+
* @returns 服务视图;不可用时 undefined。
|
|
236
|
+
*/
|
|
237
|
+
export function agentPresetsOf(source) {
|
|
238
|
+
if (source === null || typeof source === 'undefined')
|
|
239
|
+
return undefined;
|
|
240
|
+
const candidate = typeof source.get === 'function'
|
|
241
|
+
? source.get('agentPresets')
|
|
242
|
+
: source;
|
|
243
|
+
if (candidate === null || typeof candidate !== 'object')
|
|
244
|
+
return undefined;
|
|
245
|
+
const service = candidate;
|
|
246
|
+
if (typeof service.list !== 'function' || typeof service.remove !== 'function')
|
|
247
|
+
return undefined;
|
|
248
|
+
return service;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* 官方 roster 里 trust 为 user 的预设根;拿不到时退回 <dshHome>/.agent-presets。
|
|
252
|
+
*
|
|
253
|
+
* @param ctx - Context 或有 root 的服务对象。
|
|
254
|
+
* @returns 用户预设根目录。
|
|
255
|
+
*/
|
|
256
|
+
export function userPresetRoot(ctx) {
|
|
257
|
+
const service = agentPresetsOf(ctx);
|
|
258
|
+
const roots = service?.roots;
|
|
259
|
+
if (Array.isArray(roots)) {
|
|
260
|
+
for (let index = roots.length - 1; index >= 0; index -= 1) {
|
|
261
|
+
const root = roots[index];
|
|
262
|
+
if (root !== undefined && root.trust === 'user' && typeof root.path === 'string')
|
|
263
|
+
return root.path;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return presetsRoot();
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 扫描一个根下的全部预设及其归属。
|
|
270
|
+
*
|
|
271
|
+
* @param root - 预设根目录。
|
|
272
|
+
* @param pluginName - 归属判定用的插件名。
|
|
273
|
+
* @returns 扫描结果。
|
|
274
|
+
*/
|
|
275
|
+
export function scanPresetOwnership(root, pluginName) {
|
|
276
|
+
return {
|
|
277
|
+
root,
|
|
278
|
+
entries: scanPresets(root).map(entry => ({
|
|
279
|
+
id: entry.id,
|
|
280
|
+
dir: entry.dir,
|
|
281
|
+
owners: readPresetOwners(entry.dir),
|
|
282
|
+
owned: presetOwnedBy(entry.dir, pluginName).owned,
|
|
283
|
+
modified: presetOwnedBy(entry.dir, pluginName).modified,
|
|
284
|
+
})),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* 列出某插件拥有的预设:安装记录 + 磁盘标记两个来源合并。
|
|
289
|
+
*
|
|
290
|
+
* 两个来源都要看:记录可能被删(用户手工 rm 过),标记可能是别的工具写的。
|
|
291
|
+
* 合并后去重,卸载与归档都要用这份清单。
|
|
292
|
+
*
|
|
293
|
+
* @param pluginName - 插件名(包名)。
|
|
294
|
+
* @param root - 覆盖预设根(默认官方用户根)。
|
|
295
|
+
* @returns 预设 id 清单。
|
|
296
|
+
*/
|
|
297
|
+
export async function listOwnedPresetIds(pluginName, root = presetsRoot()) {
|
|
298
|
+
const recorded = [];
|
|
299
|
+
const records = await loadKindRecords();
|
|
300
|
+
for (const [key, record] of records) {
|
|
301
|
+
if (record.kind !== 'agent-preset')
|
|
302
|
+
continue;
|
|
303
|
+
if (key !== pluginName.toLowerCase() && record.repo.toLowerCase() !== pluginName.toLowerCase())
|
|
304
|
+
continue;
|
|
305
|
+
const dir = record.dir;
|
|
306
|
+
if (dir !== root && existsSync(dir)) {
|
|
307
|
+
const id = dir.split(/[\\/]+/).filter(Boolean).pop();
|
|
308
|
+
if (id !== undefined && id !== '')
|
|
309
|
+
recorded.push(id);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
recorded.push(slug(record.repo));
|
|
313
|
+
}
|
|
314
|
+
const marked = scanPresets(root)
|
|
315
|
+
.filter(entry => presetOwnedBy(entry.dir, pluginName).owned)
|
|
316
|
+
.map(entry => entry.id);
|
|
317
|
+
return { recorded: [...new Set(recorded)], marked: [...new Set(marked)] };
|
|
318
|
+
}
|
|
319
|
+
/** 把一条清理/移动结果里已经处理过的 id 汇总成可读句子。 */
|
|
320
|
+
function summarize(verb, ids, skipped) {
|
|
321
|
+
const parts = [];
|
|
322
|
+
if (ids.length > 0)
|
|
323
|
+
parts.push(verb + ' ' + ids.join(', '));
|
|
324
|
+
for (const skip of skipped)
|
|
325
|
+
parts.push('kept ' + skip.id + ' (' + skip.reason + ')');
|
|
326
|
+
return parts.length === 0 ? '' : parts.join('; ');
|
|
327
|
+
}
|
|
328
|
+
/** 清理结果的可读摘要(CLI/UI 输出用)。 */
|
|
329
|
+
export function formatCleanupResult(pluginName, result) {
|
|
330
|
+
const body = summarize('removed', result.removed, result.skipped);
|
|
331
|
+
const notes = result.notes.length === 0 ? '' : ' [' + result.notes.join('; ') + ']';
|
|
332
|
+
return (body === '' ? 'no owned presets found for ' + pluginName : 'preset cleanup for ' + pluginName + ': ' + body) + notes;
|
|
333
|
+
}
|
|
334
|
+
/** 归档结果的可读摘要。 */
|
|
335
|
+
export function formatArchiveResult(pluginName, result) {
|
|
336
|
+
const body = summarize('archived', result.moved, result.skipped);
|
|
337
|
+
return body === '' ? 'no owned presets to archive for ' + pluginName : 'preset archive for ' + pluginName + ': ' + body;
|
|
338
|
+
}
|
|
339
|
+
/** 恢复结果的可读摘要。 */
|
|
340
|
+
export function formatRestoreResult(pluginName, result) {
|
|
341
|
+
const body = summarize('restored', result.moved, result.skipped);
|
|
342
|
+
return body === '' ? 'no archived presets for ' + pluginName : 'preset restore for ' + pluginName + ': ' + body;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 卸载清理:删除"唯一 owner 是它且未被用户改过"的预设。
|
|
346
|
+
*
|
|
347
|
+
* 三条判定线,任一条不满足就保留并报告:
|
|
348
|
+
* - 多 owner:归属重叠,一个 owner 的卸载不该带走另一个的数据;
|
|
349
|
+
* - digest 不匹配(用户改过):用户的编辑比插件的原版更有价值,交还给用户;
|
|
350
|
+
* - 路径越界:兜底防线,正常路径不会触发(目录来自 scanPresets)。
|
|
351
|
+
*
|
|
352
|
+
* 删除路径优先官方 agentPresets.remove(id)——它会同时清掉指向该预设的
|
|
353
|
+
* settings.default,并让已挂载会话继续跑;只有在服务不存在时(CLI 无宿主)
|
|
354
|
+
* 才降级为带守卫的直删,此时会多报告一条"宿主不可用,已直删;如需清理
|
|
355
|
+
* 默认预设请在设置页确认"。
|
|
356
|
+
*
|
|
357
|
+
* @param ctx - Context 或官方服务对象;可为 undefined(CLI)。
|
|
358
|
+
* @param pluginName - 被卸载的插件名。
|
|
359
|
+
* @param options - 根覆盖与"是否还有别的环境装着它"。
|
|
360
|
+
* @returns 清理结果。
|
|
361
|
+
*/
|
|
362
|
+
export async function cleanupOwnedPresets(ctx, pluginName, options = {}) {
|
|
363
|
+
const root = options.root ?? userPresetRoot(ctx);
|
|
364
|
+
const removed = [];
|
|
365
|
+
const skipped = [];
|
|
366
|
+
const notes = [];
|
|
367
|
+
if (options.stillInstalledElsewhere === true) {
|
|
368
|
+
return {
|
|
369
|
+
removed,
|
|
370
|
+
skipped: [{ id: '*', reason: 'the plugin is still installed in another environment — presets are global and were left alone' }],
|
|
371
|
+
notes,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const service = agentPresetsOf(ctx);
|
|
375
|
+
for (const { id, dir } of scanPresets(root)) {
|
|
376
|
+
const verdict = presetOwnedBy(dir, pluginName);
|
|
377
|
+
if (!verdict.owned)
|
|
378
|
+
continue;
|
|
379
|
+
if (verdict.modified) {
|
|
380
|
+
skipped.push({ id, reason: 'modified by the user (digest mismatch) — kept' });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (!isUnderRoot(dir, root)) {
|
|
384
|
+
skipped.push({ id, reason: 'outside the preset root — kept' });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
if (service !== undefined) {
|
|
389
|
+
await call(service.remove(id));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
// 没有宿主服务(CLI/离线):只能直删目录。官方 remove() 会顺手清掉指向该预设的
|
|
393
|
+
// settings.default,这里做不到,因此把事实写进 notes 让用户自己确认。
|
|
394
|
+
await rmRetry(dir);
|
|
395
|
+
if (!notes.includes(DIRECT_REMOVAL_NOTE))
|
|
396
|
+
notes.push(DIRECT_REMOVAL_NOTE);
|
|
397
|
+
}
|
|
398
|
+
removed.push(id);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
skipped.push({ id, reason: error instanceof Error ? error.message : String(error) });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { removed, skipped, notes };
|
|
405
|
+
}
|
|
406
|
+
/** 直删路径的固定说明(同一个结果里只出现一次)。 */
|
|
407
|
+
const DIRECT_REMOVAL_NOTE = 'removed without the host agentPresets service; if a settings default pointed at one of these presets, clear it in the agent-preset settings page';
|
|
408
|
+
/**
|
|
409
|
+
* 禁用归档:把该插件拥有的预设移出用户根,零数据损失。
|
|
410
|
+
*
|
|
411
|
+
* 与清理的关键差别:**不检查 digest**——用户改过的预设也一起归档。归档不是删除,
|
|
412
|
+
* 数据一点没丢,只是从选择器里消失;禁用本来就是一个可逆动作,没有理由拿
|
|
413
|
+
* "用户改过"当理由把它留在选择器里引用一个被禁用的插件。
|
|
414
|
+
*
|
|
415
|
+
* 目标位置已存在同名归档时保留现场并报告(覆盖等于扔掉上一份归档)。
|
|
416
|
+
*
|
|
417
|
+
* @param pluginName - 被禁用的插件名。
|
|
418
|
+
* @param options - 根覆盖。
|
|
419
|
+
* @returns 归档结果。
|
|
420
|
+
*/
|
|
421
|
+
export async function archiveOwnedPresets(pluginName, options = {}) {
|
|
422
|
+
const root = options.root ?? presetsRoot();
|
|
423
|
+
const archiveRoot = archiveDirFor(pluginName);
|
|
424
|
+
const moved = [];
|
|
425
|
+
const skipped = [];
|
|
426
|
+
return await enqueueMutation(async () => {
|
|
427
|
+
for (const { id, dir } of scanPresets(root)) {
|
|
428
|
+
if (!presetOwnedBy(dir, pluginName).owned)
|
|
429
|
+
continue;
|
|
430
|
+
if (!isUnderRoot(dir, root)) {
|
|
431
|
+
skipped.push({ id, reason: 'outside the preset root — kept' });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const target = join(archiveRoot, id);
|
|
435
|
+
if (existsSync(target)) {
|
|
436
|
+
skipped.push({ id, reason: 'an archived copy already exists — kept in place' });
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
await mkdir(archiveRoot, { recursive: true, mode: 0o700 });
|
|
441
|
+
await renameRetry(dir, target);
|
|
442
|
+
moved.push(id);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
skipped.push({ id, reason: error instanceof Error ? error.message : String(error) });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { moved, skipped };
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 启用恢复:把归档的预设移回用户根。
|
|
453
|
+
*
|
|
454
|
+
* 同名预设已经出现(用户新建了同 id 的预设,或另一个插件装了同名的)时**保留
|
|
455
|
+
* 归档副本并报告**——让新的那份继续生效,不静默覆盖用户此刻正在用的东西。
|
|
456
|
+
*
|
|
457
|
+
* @param pluginName - 被重新启用的插件名。
|
|
458
|
+
* @param options - 根覆盖。
|
|
459
|
+
* @returns 恢复结果。
|
|
460
|
+
*/
|
|
461
|
+
export async function restoreArchivedPresets(pluginName, options = {}) {
|
|
462
|
+
const root = options.root ?? presetsRoot();
|
|
463
|
+
const archiveRoot = archiveDirFor(pluginName);
|
|
464
|
+
const moved = [];
|
|
465
|
+
const skipped = [];
|
|
466
|
+
return await enqueueMutation(async () => {
|
|
467
|
+
for (const { id, dir } of scanPresets(archiveRoot)) {
|
|
468
|
+
const target = join(root, id);
|
|
469
|
+
if (existsSync(target)) {
|
|
470
|
+
skipped.push({ id, reason: 'a same-id preset already exists — archived copy kept at ' + dir });
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
475
|
+
await renameRetry(dir, target);
|
|
476
|
+
moved.push(id);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
skipped.push({ id, reason: error instanceof Error ? error.message : String(error) });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return { moved, skipped };
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* 写入中立标准归属标记。
|
|
487
|
+
*
|
|
488
|
+
* 已有标记就**不覆盖**:插件或别的工具可能已经声明过这个目录,覆盖等于篡改
|
|
489
|
+
* 别人的归属(随后卸载会删掉不属于我们的东西)。digest 记录"刚装下去时是什么
|
|
490
|
+
* 样子",之后用户改了它,卸载就会跳过删除。
|
|
491
|
+
*
|
|
492
|
+
* @param presetDir - 预设目录。
|
|
493
|
+
* @param owners - owner 列表(通常是仓库/插件名)。
|
|
494
|
+
* @returns 是否真的写了(已存在时为 false)。
|
|
495
|
+
*/
|
|
496
|
+
export async function writeOwnerMarker(presetDir, owners) {
|
|
497
|
+
const marker = join(presetDir, OWNER_MARKER);
|
|
498
|
+
if (existsSync(marker))
|
|
499
|
+
return false;
|
|
500
|
+
const digest = presetDigest(presetDir);
|
|
501
|
+
const record = {
|
|
502
|
+
format: OWNER_MARKER_FORMAT,
|
|
503
|
+
owners: [...new Set(owners.filter(owner => owner.length > 0))],
|
|
504
|
+
};
|
|
505
|
+
if (digest !== null)
|
|
506
|
+
record['digest'] = digest;
|
|
507
|
+
// 原子写:临时文件 + 改名。安装链路是并发的,半截标记会被读成损坏标记并让
|
|
508
|
+
// 整个预设目录 fail closed(什么都不清),比不写更糟。
|
|
509
|
+
const tmp = marker + '.' + String(process.pid) + '.tmp';
|
|
510
|
+
await writeFile(tmp, JSON.stringify(record, undefined, 2) + String.fromCharCode(10), { encoding: 'utf8', mode: 0o600 });
|
|
511
|
+
await rename(tmp, marker);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* 从安装记录里取"这次安装落下去的预设目录"并写归属标记。
|
|
516
|
+
*
|
|
517
|
+
* installPreset 已经写过一次标记(它知道落地目录);这个函数给"记录已存在但
|
|
518
|
+
* 标记缺失"的补写路径用(例如旧包装的预设迁过来)。
|
|
519
|
+
*
|
|
520
|
+
* @param outcome - 直装结果。
|
|
521
|
+
* @param pluginName - 归属插件名。
|
|
522
|
+
* @returns 实际写了标记的目录数。
|
|
523
|
+
*/
|
|
524
|
+
export async function markInstalledPresets(outcome, pluginName) {
|
|
525
|
+
let written = 0;
|
|
526
|
+
for (const dir of outcome.dirs) {
|
|
527
|
+
if (await writeOwnerMarker(dir, [pluginName]))
|
|
528
|
+
written += 1;
|
|
529
|
+
}
|
|
530
|
+
return written;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* 该插件是否还装在**别的环境**里。
|
|
534
|
+
*
|
|
535
|
+
* 预设是全局的(在 dshHome 下,不属于任何 profile),所以在一个环境里卸载插件
|
|
536
|
+
* 不等于预设失去了主人。安装记录会写进环境的 package.json dependencies,因此
|
|
537
|
+
* 依赖检查就是"它还在不在"的权威信号。
|
|
538
|
+
*
|
|
539
|
+
* @param currentEnvironment - 正在卸载的环境名(跳过它自己)。
|
|
540
|
+
* @param pluginName - 插件包名。
|
|
541
|
+
* @param profilesRootDir - profiles 根目录;默认取 dshHome()/profiles。
|
|
542
|
+
* @returns 是否还有其他环境声明该依赖。
|
|
543
|
+
*/
|
|
544
|
+
export async function pluginInstalledInOtherEnvironments(currentEnvironment, pluginName, profilesRootDir) {
|
|
545
|
+
const { readdir } = await import('node:fs/promises');
|
|
546
|
+
const root = profilesRootDir ?? join(resolvedHome(), 'profiles');
|
|
547
|
+
let names;
|
|
548
|
+
try {
|
|
549
|
+
names = await readdir(root);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
for (const name of names) {
|
|
555
|
+
if (name === currentEnvironment || name.startsWith('.'))
|
|
556
|
+
continue;
|
|
557
|
+
let manifest;
|
|
558
|
+
try {
|
|
559
|
+
manifest = readEnvironmentManifest(join(root, name));
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// 读不了的环境不该阻断清理;当成"里面没有它"。
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (manifest.dependencies.includes(pluginName))
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
/** 一条预设安装记录的组装(供 kinds.ts 的调用点保持单一路径)。 */
|
|
571
|
+
export function presetKindRecord(repo, outcome, installedAt = new Date().toISOString()) {
|
|
572
|
+
return {
|
|
573
|
+
kind: 'agent-preset',
|
|
574
|
+
repo,
|
|
575
|
+
dir: outcome.dirs.length === 1 ? outcome.dirs[0] : outcome.location,
|
|
576
|
+
installedAt,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安装前质量门 — 对**已经装进 profile 的包**做静态体检(不写文件、不调 pnpm)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:A 类·重写(旧 src/installFlow.ts#qualityIssues 只作检查面意图参考,未复制代码)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/installFlow.ts#qualityIssues(约 1422 行起)与
|
|
6
|
+
* docs/private/audit/correctness.md 的 C-1/C-2 记录。旧实现的两个 Critical 缺陷在这里被正面修掉:
|
|
7
|
+
* C-1 只扫 exports["."] 一个入口 → 现在扫全部导出子路径 + 相对 import 可达文件(有界 BFS);
|
|
8
|
+
* C-2 对 scoped 子路径假阳性(只要 @scope/pkg 目录存在就判"可解析")→ 现在用
|
|
9
|
+
* diagnostics.ts 的 specifierResolves:Node 真实解析(只用于判真)与文件/exports 探测两把尺子,
|
|
10
|
+
* 判定"不可解析"时必须两者都不成立。
|
|
11
|
+
* 官方复用:安装流程本身由官方 Remote 编排(inspect → installBundle(enabled:false) → 本门 →
|
|
12
|
+
* removeBundle / setBundleEnabled),本模块只提供第 3 步的静态判定,不参与写路径。
|
|
13
|
+
* 包入口解析、import 扫描、行号定位与 diagnostics.ts 共用同一套实现,避免两处口径漂移。
|
|
14
|
+
* 解析根:与诊断同源——profile 层 → 共享兜底层 → **安装锚点**(官方包与 bundle 本体由安装侧
|
|
15
|
+
* 提供,只看 profile 会把 peer 与 bundle 行全判成缺包)。installAnchor 省略时退回 profile 解析,
|
|
16
|
+
* 调用方应当把 ctx.get('profileContext').installAnchor 递进来。
|
|
17
|
+
* 前提检查:旧前提是"装完只看一个入口就够"——实测不够(C-1);旧实现还用自建 bash 探测
|
|
18
|
+
* patch 行名,现在改为读原始 patch 文本定位行 + Node 侧解析,不再自建第二套解析器。
|
|
19
|
+
*
|
|
20
|
+
* 调用契约:config.qualityGate 的 enabled/mode 是**调用方**(安装流程包装器)的事——
|
|
21
|
+
* enabled=false 时调用方不该调用本函数,mode='warn' 时调用方只展示不过滤。
|
|
22
|
+
* 本函数只消费 allowlist(命中即跳过全部检查,用户显式承担风险)。
|
|
23
|
+
*/
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
+
import type { CompanionConfig, QualityGateConfig } from './settings.ts';
|
|
26
|
+
/**
|
|
27
|
+
* 质量门的判定结果。
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* 验收契约只要求 { ok, issues };notes 是**额外**的非判定性事实
|
|
31
|
+
* (豁免生效、扫描截断等),调用方可以直接展示,也可以忽略——它永远不代表不合格。
|
|
32
|
+
*/
|
|
33
|
+
export interface QualityGateResult {
|
|
34
|
+
/** 是否通过(没有任何 issue)。 */
|
|
35
|
+
readonly ok: boolean;
|
|
36
|
+
/** 面向用户的问题清单;空数组表示通过。 */
|
|
37
|
+
readonly issues: readonly string[];
|
|
38
|
+
/** 非判定性事实:豁免、扫描上限截断说明等。不影响 ok。 */
|
|
39
|
+
readonly notes: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 体检一个已安装的包。
|
|
43
|
+
*
|
|
44
|
+
* 检查三件事:
|
|
45
|
+
* 1. 未声明的 import(loader/平台提供项与 Node 内置模块豁免)——挂载后必 ERR_MODULE_NOT_FOUND;
|
|
46
|
+
* 2. 声明了但没装(peer 或普通依赖落不下来);
|
|
47
|
+
* 3. @deepseek-ai/* 被声明成普通 dependencies —— 模块身份分裂会劫持官方 loader 行(豁免见
|
|
48
|
+
* diagnostics.ts 的 OFFICIAL_DEP_ALLOWED)。
|
|
49
|
+
* 另外检查包自己的 bundle patch 行名是否解析得到(该行解析失败会让整个 profile 起不来)。
|
|
50
|
+
*
|
|
51
|
+
* @param envDir - profile 环境目录(解析锚点,也是包所在 node_modules 的父目录)。
|
|
52
|
+
* @param packageName - 已安装的包名。
|
|
53
|
+
* @param config - 本插件配置;同时接受 CompanionConfig 与单独的 qualityGate 段。
|
|
54
|
+
* @param ctxOrAnchor - host Context(从 ctx.get('profileContext') 取安装锚点),
|
|
55
|
+
* 或直接给 dsh 应用包的 package.json 路径;都不给时只按 profile 与共享兜底层解析,
|
|
56
|
+
* 安装侧提供的包会被判成缺包。
|
|
57
|
+
* @returns 判定结果;问题为空即 ok。
|
|
58
|
+
*/
|
|
59
|
+
export declare function inspectPackage(envDir: string, packageName: string, config: CompanionConfig | QualityGateConfig, ctxOrAnchor?: Context | string): Promise<QualityGateResult>;
|
|
60
|
+
/**
|
|
61
|
+
* 包名安全校验:质量门要把包名拼进路径,必须挡住路径穿越
|
|
62
|
+
* (与 paths.ts 的 isSafeEnvironmentName 同样的纵深防御思路)。
|
|
63
|
+
*/
|
|
64
|
+
export declare function isSafePackageName(name: string): boolean;
|
|
65
|
+
/** Node 内置模块说明符:crypto 与 node:crypto 等价,fs/promises 这类子路径同样豁免。 */
|
|
66
|
+
export declare function isBuiltinSpecifier(spec: string): boolean;
|