dsh-m 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +21 -5
- package/README.md +21 -5
- package/docs/DESIGN.md +11 -1
- package/lib/cli.js +41 -8
- package/lib/client.js +230 -14
- package/lib/core/build-approval.js +88 -0
- package/lib/core/compat-check.js +56 -0
- package/lib/core/dsh-cli.js +34 -7
- package/lib/core/enablement.js +92 -0
- package/lib/core/host-api.js +35 -3
- package/lib/core/installed.js +9 -0
- package/lib/core/market.js +76 -3
- package/lib/core/patch-yaml.js +143 -0
- package/lib/core/profile-transaction.js +30 -2
- package/lib/core/registry.js +32 -1
- package/lib/core/toggle.js +220 -0
- package/lib/core/versions.js +18 -5
- package/lib/host.js +10 -1
- package/lib/tools.js +69 -5
- package/package.json +7 -1
- package/registry.json +128 -22
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cordis 补丁层 YAML 的三个纯函数(0.4.0 开关功能,DESIGN/plan Task 2):
|
|
3
|
+
*
|
|
4
|
+
* 1) readBundlePatchRows —— 包内 `cordis.patch.yml`(dsh.bundle.patch 层)行枚举,
|
|
5
|
+
* 供「单行/多行插件」粒度判定(CONTEXT.md:行覆盖 vs Bundle 选择);
|
|
6
|
+
* 2) planRowOverride —— 官方 `writePluginEnabled`(dsh-plugin-manager L881-916)的纯函数版:
|
|
7
|
+
* 在 profile `cordis.patch.yml` 文本上做保注释的 `disabled` 覆盖编辑(findLast 按 id 匹配、
|
|
8
|
+
* 跳过含 insert 的行、name 可选限定、已是目标态 changed:false、无匹配 append `{id, disabled}`;
|
|
9
|
+
* 启用写显式 `disabled: false` 以压过更低层)。文件 IO 留给调用方(toggle.ts 持锁后落盘);
|
|
10
|
+
* 3) readProfileOverrides —— 读 profile 补丁层的覆盖行(id → disabled),enablement 读路径用。
|
|
11
|
+
*
|
|
12
|
+
* 解析约定与官方一致:customTags `tag:yaml.org,2002:js` 原样保留(官方补丁可含 `!!js`
|
|
13
|
+
* disabled 表达式);planRowOverride 对坏 YAML / 根非序列 throw(调用方映射为失败),
|
|
14
|
+
* readBundlePatchRows 对不可解析形态返回 readable:false(调用方按多行 → Bundle 级处理),
|
|
15
|
+
* readProfileOverrides 宽松(读路径,坏文件 → [])。
|
|
16
|
+
*/
|
|
17
|
+
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
18
|
+
/** 官方同款 customTags:`!!js` 标量原样解析为字符串,不因未知标签炸解析。 */
|
|
19
|
+
const YAML_OPTIONS = {
|
|
20
|
+
customTags: [
|
|
21
|
+
{
|
|
22
|
+
tag: 'tag:yaml.org,2002:js',
|
|
23
|
+
resolve(value) {
|
|
24
|
+
return value;
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
function scalarString(value) {
|
|
30
|
+
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 包内补丁层行枚举。判定口径:
|
|
34
|
+
* - map 且含 `insert` 键 → 其值必须是序列,逐元素取 {id, name?};元素非 map / 缺 id → 整体不可读;
|
|
35
|
+
* - map 无 `insert`(含 `- id: x` 的配置补丁行与覆盖行)→ configRows +1;
|
|
36
|
+
* - 非映射项(纯标量等畸形)→ 整体不可读。
|
|
37
|
+
*/
|
|
38
|
+
export function readBundlePatchRows(text) {
|
|
39
|
+
const result = { inserts: [], configRows: 0, readable: true };
|
|
40
|
+
let seq;
|
|
41
|
+
try {
|
|
42
|
+
seq = parsePatchSeq(text).seq;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { ...result, readable: false };
|
|
46
|
+
}
|
|
47
|
+
for (const item of seq.items) {
|
|
48
|
+
if (!isMap(item))
|
|
49
|
+
return { inserts: [], configRows: 0, readable: false };
|
|
50
|
+
if (item.has('insert')) {
|
|
51
|
+
const inserted = item.get('insert');
|
|
52
|
+
if (!isSeq(inserted))
|
|
53
|
+
return { inserts: [], configRows: 0, readable: false };
|
|
54
|
+
for (const element of inserted.items) {
|
|
55
|
+
if (!isMap(element))
|
|
56
|
+
return { inserts: [], configRows: 0, readable: false };
|
|
57
|
+
const id = scalarString(element.get('id'));
|
|
58
|
+
if (id === null)
|
|
59
|
+
return { inserts: [], configRows: 0, readable: false };
|
|
60
|
+
const name = scalarString(element.get('name'));
|
|
61
|
+
result.inserts.push(name === null ? { id } : { id, name });
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
result.configRows += 1;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/** 单行插件:可读、恰好一个 insert 条目、零配置补丁行——行开关即整插件开关。 */
|
|
70
|
+
export function isSingleRowPlugin(rows) {
|
|
71
|
+
return rows.readable && rows.inserts.length === 1 && rows.configRows === 0;
|
|
72
|
+
}
|
|
73
|
+
function parsePatchSeq(text) {
|
|
74
|
+
const document = parseDocument(text, YAML_OPTIONS);
|
|
75
|
+
const error = document.errors[0];
|
|
76
|
+
if (error !== undefined)
|
|
77
|
+
throw error;
|
|
78
|
+
// Document.contents 是 accessor,CFA 无法收窄——先落到局部变量再判形
|
|
79
|
+
const seq = document.contents;
|
|
80
|
+
if (!isSeq(seq))
|
|
81
|
+
throw new Error('cordis.patch.yml 必须是 YAML 序列');
|
|
82
|
+
return { document, seq };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 保注释的行覆盖编辑(官方 writePluginEnabled 纯函数版)。
|
|
86
|
+
* @param text 当前 profile `cordis.patch.yml` 文本(文件缺失由调用方传 `"[]\n"`)
|
|
87
|
+
* @param id 目标合成条目 id(来自 insert 行声明或 loader entry)
|
|
88
|
+
* @param name 模块名(可选限定:行有 name 且不等 → 不匹配;append 时不写 name,与官方一致)
|
|
89
|
+
* @param enabled 目标启停态(true → 写 `disabled: false` 显式正覆盖)
|
|
90
|
+
*/
|
|
91
|
+
export function planRowOverride(text, id, name, enabled) {
|
|
92
|
+
const { document, seq } = parsePatchSeq(text);
|
|
93
|
+
const items = seq.items;
|
|
94
|
+
const targetIndex = findLastIndex(items, (item, index) => {
|
|
95
|
+
if (!isMap(item) || item.has('insert'))
|
|
96
|
+
return false;
|
|
97
|
+
if (document.getIn([index, 'id']) !== id)
|
|
98
|
+
return false;
|
|
99
|
+
const expectedName = document.getIn([index, 'name']);
|
|
100
|
+
return !expectedName || expectedName === name;
|
|
101
|
+
});
|
|
102
|
+
if (targetIndex >= 0) {
|
|
103
|
+
if (document.getIn([targetIndex, 'disabled']) === !enabled) {
|
|
104
|
+
return { text, changed: false };
|
|
105
|
+
}
|
|
106
|
+
document.setIn([targetIndex, 'disabled'], !enabled);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
document.add({ id, disabled: !enabled });
|
|
110
|
+
}
|
|
111
|
+
return { text: String(document), changed: true };
|
|
112
|
+
}
|
|
113
|
+
/** Array.prototype.findLast 的索引版(官方用 items.findLast 取项,这里需要下标做 setIn)。 */
|
|
114
|
+
function findLastIndex(items, predicate) {
|
|
115
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
116
|
+
if (predicate(items[index], index))
|
|
117
|
+
return index;
|
|
118
|
+
}
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 读 profile 补丁层覆盖行(读路径,宽松):坏 YAML / 根非序列 / 非 map 项 → 跳过或整体 []。
|
|
123
|
+
* 只返回带字符串 id 的非 insert 行;`disabled` 键缺省视为 false。
|
|
124
|
+
*/
|
|
125
|
+
export function readProfileOverrides(text) {
|
|
126
|
+
let seq;
|
|
127
|
+
try {
|
|
128
|
+
seq = parsePatchSeq(text).seq;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const rows = [];
|
|
134
|
+
for (const item of seq.items) {
|
|
135
|
+
if (!isMap(item) || item.has('insert'))
|
|
136
|
+
continue;
|
|
137
|
+
const id = scalarString(item.get('id'));
|
|
138
|
+
if (id === null)
|
|
139
|
+
continue;
|
|
140
|
+
rows.push({ id, disabled: item.get('disabled') === true });
|
|
141
|
+
}
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
@@ -499,6 +499,30 @@ async function addWithLagRetry(req, spec, d, heal) {
|
|
|
499
499
|
* npm 门最终验证(R3:可重复执行)——manifest 依赖存在与锚定、lockfile 存在、
|
|
500
500
|
* 与 npm dist 一致的 resolution.integrity。firstPass 控制是否记录 range 放行 heal。
|
|
501
501
|
*/
|
|
502
|
+
/**
|
|
503
|
+
* bundle 身份观察(Task 10,只警告不回滚):包内无 cordis.patch.yml 且 profile
|
|
504
|
+
* bundles 数组不含该包 → 装成「纯依赖」(不进加载树、已装页进 others 折叠)。
|
|
505
|
+
* 存在合法的无补丁层 dsh 包(伴生库/纯资源包),故不判失败。
|
|
506
|
+
*/
|
|
507
|
+
async function bundleIdentityWarning(profileDir, pkg) {
|
|
508
|
+
try {
|
|
509
|
+
await readFile(join(profileDir, 'node_modules', pkg, 'cordis.patch.yml'));
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
/* 无补丁层 → 再看 bundles */
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
const raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
517
|
+
const bundles = raw?.dsh?.profile?.bundles;
|
|
518
|
+
if (Array.isArray(bundles) && bundles.includes(pkg))
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
/* manifest 读不到 → 如实报 warning(不升级为失败) */
|
|
523
|
+
}
|
|
524
|
+
return 'no-patch-layer';
|
|
525
|
+
}
|
|
502
526
|
async function verifyNpmCommitState(req, d, heal, firstPass) {
|
|
503
527
|
const depsNow = await strictReadDeps(d);
|
|
504
528
|
if (depsNow[req.pkg] === undefined) {
|
|
@@ -577,7 +601,9 @@ async function installNpm(req, d, snapshots) {
|
|
|
577
601
|
pkg: req.pkg,
|
|
578
602
|
spec,
|
|
579
603
|
version: req.version,
|
|
580
|
-
|
|
604
|
+
buildApprovals: addOut.buildApprovals ?? [],
|
|
605
|
+
fallbackAllBuilds: addOut.fallbackAllBuilds === true,
|
|
606
|
+
...(await bundleIdentityWarning(d.profileDir, req.pkg)) ? { bundleWarning: 'no-patch-layer' } : {},
|
|
581
607
|
});
|
|
582
608
|
}
|
|
583
609
|
catch (err) {
|
|
@@ -646,7 +672,9 @@ async function installGithub(req, d, snapshots) {
|
|
|
646
672
|
spec,
|
|
647
673
|
sha: req.sha,
|
|
648
674
|
tag: req.tag,
|
|
649
|
-
|
|
675
|
+
buildApprovals: addOut.buildApprovals ?? [],
|
|
676
|
+
fallbackAllBuilds: addOut.fallbackAllBuilds === true,
|
|
677
|
+
...(await bundleIdentityWarning(d.profileDir, key)) ? { bundleWarning: 'no-patch-layer' } : {},
|
|
650
678
|
});
|
|
651
679
|
}
|
|
652
680
|
catch (err) {
|
package/lib/core/registry.js
CHANGED
|
@@ -116,7 +116,12 @@ export function parseRegistryAddress(raw) {
|
|
|
116
116
|
}
|
|
117
117
|
// ---------- 严格 v1 校验 ----------
|
|
118
118
|
const TOP_LEVEL_KEYS = new Set(['version', 'plugins']);
|
|
119
|
-
const ENTRY_KEYS = new Set(['id', 'name', 'description', 'category', 'tags', 'source', 'npm', 'github', 'homepage', 'icon']);
|
|
119
|
+
const ENTRY_KEYS = new Set(['id', 'name', 'description', 'category', 'tags', 'source', 'npm', 'github', 'homepage', 'icon', 'verified']);
|
|
120
|
+
/** 精确 semver 判定(与 versions.ts EXACT_VERSION_RE 同语义;接受 prerelease/build,拒绝 range/前缀)。 */
|
|
121
|
+
const EXACT_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
122
|
+
function semverParse(value) {
|
|
123
|
+
return EXACT_SEMVER_RE.test(value) ? value : null;
|
|
124
|
+
}
|
|
120
125
|
function httpsUrlError(raw) {
|
|
121
126
|
if (raw.length > MAX_URL)
|
|
122
127
|
return `超过 ${MAX_URL} 字符`;
|
|
@@ -269,6 +274,31 @@ export function validateRegistry(raw) {
|
|
|
269
274
|
entryIcon = s;
|
|
270
275
|
}
|
|
271
276
|
// 错误条目不产生合法输出:整份清单在存在任何错误时返回 null
|
|
277
|
+
// verified(0.4.0):可选字符串数组,每项精确 semver(禁 range/前缀)
|
|
278
|
+
let verified;
|
|
279
|
+
if (e.verified !== undefined) {
|
|
280
|
+
if (!Array.isArray(e.verified)) {
|
|
281
|
+
errors.push(`${where}.verified: 必须是字符串数组`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
const list = [];
|
|
285
|
+
e.verified.forEach((v, vi) => {
|
|
286
|
+
if (typeof v !== 'string' || v.trim() === '' || v !== v.trim()) {
|
|
287
|
+
errors.push(`${where}.verified[${vi}]: 必须是非空白精确 semver`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const parsed = semverParse(v);
|
|
291
|
+
if (parsed === null)
|
|
292
|
+
errors.push(`${where}.verified[${vi}}: ${JSON.stringify(v)} 不是合法 semver(禁 range/前缀)`);
|
|
293
|
+
else if (e.verified.includes(v, vi + 1))
|
|
294
|
+
errors.push(`${where}.verified[${vi}]: 重复 ${v}`);
|
|
295
|
+
else
|
|
296
|
+
list.push(v);
|
|
297
|
+
});
|
|
298
|
+
if (list.length > 0)
|
|
299
|
+
verified = list;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
272
302
|
plugins.push({
|
|
273
303
|
id,
|
|
274
304
|
name,
|
|
@@ -280,6 +310,7 @@ export function validateRegistry(raw) {
|
|
|
280
310
|
...(entryGithub !== undefined ? { github: entryGithub } : {}),
|
|
281
311
|
...(entryHomepage !== undefined ? { homepage: entryHomepage } : {}),
|
|
282
312
|
...(entryIcon !== undefined ? { icon: entryIcon } : {}),
|
|
313
|
+
...(verified !== undefined ? { verified } : {}),
|
|
283
314
|
});
|
|
284
315
|
});
|
|
285
316
|
return { ok: errors.length === 0, errors, registry: errors.length === 0 ? { version: 1, plugins } : null };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开关写路径编排(0.4.0,plan Task 5;ADR-0001 委派降级)。
|
|
3
|
+
*
|
|
4
|
+
* 判定顺序(Rev2.1 定稿):
|
|
5
|
+
* ① 保护门最先(纯名单检查,无需枚举已装清单——空 profile 下 CLI 冒烟也得 protected);
|
|
6
|
+
* ② 不在已装清单 → not-installed;
|
|
7
|
+
* ③ 粒度路由:单 insert 行插件 → 行覆盖;多行/不可解析 → Bundle 选择;
|
|
8
|
+
* ④ 委派优先:`getService()` 探测官方 pluginManager(运行时探测,不判版本号),
|
|
9
|
+
* 官方 readOnlyReason 映射:'management-required'→protected、'unaddressable'→unaddressable;
|
|
10
|
+
* applied 以复读为准(官方富结果 application 字段鸭子读兜底);
|
|
11
|
+
* ⑤ 降级:行级 = 锁内保注释 YAML 编辑 + `setLivePluginDisabled(pkg, !enabled)` 活体
|
|
12
|
+
* (注意 live-plugin.ts 第二参是 disabled,方向取反);bundle 级 = 锁内 bundles 数组
|
|
13
|
+
* 编辑,恒 restart-required。
|
|
14
|
+
*
|
|
15
|
+
* 锁:与官方同款 `withFileLock(<profile>/package.json)`(dsh-atomic-write,锁文件
|
|
16
|
+
* `package.json.lock`、wx 独占)——防与官方 UI 并发编辑 cordis.patch.yml 丢更新。
|
|
17
|
+
* 开关不经 Profile 变更事务(grilling Q1):单文件轻量编辑、幂等可逆。
|
|
18
|
+
*/
|
|
19
|
+
import { readFile as fsReadFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
22
|
+
import { listInstalledPlugins } from './installed.js';
|
|
23
|
+
import { isSingleRowPlugin, planRowOverride } from './patch-yaml.js';
|
|
24
|
+
import { PROTECTED_MODULES } from './enablement.js';
|
|
25
|
+
import { setLivePluginDisabled, loaderHost } from './live-plugin.js';
|
|
26
|
+
import { webProfileDir } from './env.js';
|
|
27
|
+
export class ToggleError extends Error {
|
|
28
|
+
code;
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** 官方 change() 富结果的鸭子读(不建类型依赖)。 */
|
|
35
|
+
function applicationOf(result) {
|
|
36
|
+
if (result && typeof result === 'object') {
|
|
37
|
+
const record = result;
|
|
38
|
+
return {
|
|
39
|
+
application: typeof record.application === 'string' ? record.application : undefined,
|
|
40
|
+
warnings: Array.isArray(record.warnings) ? record.warnings.map((w) => String(w)) : [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { warnings: [] };
|
|
44
|
+
}
|
|
45
|
+
async function readPatchText(io, path) {
|
|
46
|
+
try {
|
|
47
|
+
return await io.readFile(path, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === 'ENOENT')
|
|
51
|
+
return '[]\n';
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function togglePlugin(pkg, enabled, deps = {}) {
|
|
56
|
+
const key = String(pkg || '').trim();
|
|
57
|
+
// ① 保护门最先:纯名单检查(空 profile 也先于 not-installed 命中)
|
|
58
|
+
if (key === 'dsh-m' || PROTECTED_MODULES.includes(key)) {
|
|
59
|
+
throw new ToggleError('protected', `受保护插件不可开关: ${key}(dsh-m 自身或官方宿主命脉)`);
|
|
60
|
+
}
|
|
61
|
+
const io = deps.io ?? { readFile: fsReadFile };
|
|
62
|
+
const profileDir = deps.profileDir ?? webProfileDir();
|
|
63
|
+
// ② 在装判定
|
|
64
|
+
const installed = await listInstalledPlugins(profileDir);
|
|
65
|
+
const item = installed.items.find((it) => it.pkg === key);
|
|
66
|
+
if (item === undefined)
|
|
67
|
+
throw new ToggleError('not-installed', `web profile 未安装该插件: ${key}`);
|
|
68
|
+
const granularity = item.patchRows && isSingleRowPlugin(item.patchRows) ? 'row' : 'bundle';
|
|
69
|
+
const service = deps.getService?.();
|
|
70
|
+
if (service !== undefined) {
|
|
71
|
+
// 委派一律走 Bundle 选择(官方插件页主开关同层,两层 UI 状态一致;
|
|
72
|
+
// 行覆盖残留由启用路径的清理扫描兜底)。行覆盖仅是降级路径的实现细节。
|
|
73
|
+
return toggleViaService(service, item, enabled);
|
|
74
|
+
}
|
|
75
|
+
// 降级:Bundle 退选态的「启用」必须走 bundles 编辑(行覆盖救不回未合成的树)
|
|
76
|
+
let inBundles = false;
|
|
77
|
+
try {
|
|
78
|
+
const manifest = JSON.parse(await io.readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
79
|
+
const bundles = manifest?.dsh?.profile?.bundles;
|
|
80
|
+
inBundles = Array.isArray(bundles) && bundles.includes(item.pkg);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* 读不到按不在 bundles 处理 */
|
|
84
|
+
}
|
|
85
|
+
const route = enabled && !inBundles ? 'bundle' : granularity;
|
|
86
|
+
return toggleFallback(item, enabled, route, { profileDir, io, loaderHost: deps.loaderHost ?? loaderHost() });
|
|
87
|
+
}
|
|
88
|
+
// ---------- 委派路径 ----------
|
|
89
|
+
async function toggleViaService(service, item, enabled) {
|
|
90
|
+
const warnings = [];
|
|
91
|
+
// 启用前清理本插件的历史行覆盖(dsh-m 单开关模型:行覆盖是它自己写下的,
|
|
92
|
+
// 不清则 bundle 重选后旧行仍 disabled,官方页第二层会出现一开一关)
|
|
93
|
+
if (enabled) {
|
|
94
|
+
const cleared = await clearRowOverrides(service, item.pkg, warnings);
|
|
95
|
+
if (cleared > 0)
|
|
96
|
+
warnings.push(`已清理 ${cleared} 条历史行覆盖`);
|
|
97
|
+
}
|
|
98
|
+
const raw = await service.setBundleEnabled(item.pkg, enabled);
|
|
99
|
+
const rich = applicationOf(raw);
|
|
100
|
+
warnings.push(...rich.warnings);
|
|
101
|
+
if (enabled) {
|
|
102
|
+
// selectBundle 后才可见的行覆盖(bundle 退选期 loader 无条目)再扫一遍
|
|
103
|
+
await clearRowOverrides(service, item.pkg, warnings);
|
|
104
|
+
}
|
|
105
|
+
// applied 复读:bundle on → 该模块行应存在且启用;bundle off → 行消失或全禁用
|
|
106
|
+
const after = (await service.listPlugins()).filter((r) => r.moduleName === item.pkg);
|
|
107
|
+
const running = after.some((r) => r.enabled);
|
|
108
|
+
let applied;
|
|
109
|
+
if (rich.application === 'restart-required')
|
|
110
|
+
applied = 'restart-required';
|
|
111
|
+
else
|
|
112
|
+
applied = running === enabled ? 'live' : 'restart-required';
|
|
113
|
+
if (rich.application === 'overridden')
|
|
114
|
+
warnings.push('被更高补丁层覆盖,落盘状态与请求不符——重启后以下层为准');
|
|
115
|
+
return { pkg: item.pkg, enabled, applied, via: 'delegate', warnings };
|
|
116
|
+
}
|
|
117
|
+
/** 清理本插件可见的 disabled 行覆盖(跳过官方 readOnly 行);返回清理条数。 */
|
|
118
|
+
async function clearRowOverrides(service, pkg, warnings) {
|
|
119
|
+
const rows = (await service.listPlugins()).filter((r) => r.moduleName === pkg && !r.readOnlyReason && r.enabled === false);
|
|
120
|
+
let cleared = 0;
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const rich = applicationOf(await service.setPluginEnabled(row.entryId, true));
|
|
123
|
+
warnings.push(...rich.warnings);
|
|
124
|
+
cleared += 1;
|
|
125
|
+
}
|
|
126
|
+
return cleared;
|
|
127
|
+
}
|
|
128
|
+
async function toggleFallback(item, enabled, granularity, ctx) {
|
|
129
|
+
if (granularity === 'row') {
|
|
130
|
+
return toggleRowFallback(item, enabled, ctx);
|
|
131
|
+
}
|
|
132
|
+
return toggleBundleFallback(item, enabled, ctx);
|
|
133
|
+
}
|
|
134
|
+
/** 行覆盖降级:锁内保注释 YAML 编辑 + loader 活体翻转。 */
|
|
135
|
+
async function toggleRowFallback(item, enabled, ctx) {
|
|
136
|
+
const loaderEntry = findLoaderEntryByName(ctx.loaderHost, item.pkg);
|
|
137
|
+
const insert = item.patchRows?.readable ? item.patchRows.inserts[0] : undefined;
|
|
138
|
+
const id = loaderEntry?.id ?? insert?.id;
|
|
139
|
+
const name = loaderEntry?.options?.name ?? insert?.name;
|
|
140
|
+
if (id === undefined || id === '') {
|
|
141
|
+
throw new ToggleError('unknown-plugin', `无法定位该插件的合成条目 id(loader 与补丁行均无): ${item.pkg}`);
|
|
142
|
+
}
|
|
143
|
+
const patchPath = join(ctx.profileDir, 'cordis.patch.yml');
|
|
144
|
+
let liveFound = false;
|
|
145
|
+
await withFileLock(join(ctx.profileDir, 'package.json'), async () => {
|
|
146
|
+
const text = await readPatchText(ctx.io, patchPath);
|
|
147
|
+
const plan = planRowOverride(text, id, name, enabled);
|
|
148
|
+
if (plan.changed) {
|
|
149
|
+
await writeFileAtomic(patchPath, plan.text, { mode: 0o600 });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
liveFound = await setLivePluginDisabled(item.pkg, !enabled, ctx.loaderHost);
|
|
153
|
+
return {
|
|
154
|
+
pkg: item.pkg,
|
|
155
|
+
enabled,
|
|
156
|
+
applied: liveFound ? 'live' : 'restart-required',
|
|
157
|
+
via: 'fallback',
|
|
158
|
+
warnings: [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function findLoaderEntryByName(host, pkg) {
|
|
162
|
+
const entries = host?.loader?.entries;
|
|
163
|
+
if (typeof entries !== 'function')
|
|
164
|
+
return null;
|
|
165
|
+
for (const entry of entries.call(host.loader)) {
|
|
166
|
+
if (entry && entry.options?.name === pkg)
|
|
167
|
+
return entry;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/** Bundle 选择降级:锁内 bundles 数组编辑(恒 restart-required)。 */
|
|
172
|
+
async function toggleBundleFallback(item, enabled, ctx) {
|
|
173
|
+
const manifestPath = join(ctx.profileDir, 'package.json');
|
|
174
|
+
await withFileLock(join(ctx.profileDir, 'package.json'), async () => {
|
|
175
|
+
if (enabled) {
|
|
176
|
+
// 启用即清本包历史行覆盖(与委派路径同语义)
|
|
177
|
+
const patchPath = join(ctx.profileDir, 'cordis.patch.yml');
|
|
178
|
+
let patchText = '[]\n';
|
|
179
|
+
try {
|
|
180
|
+
patchText = await ctx.io.readFile(patchPath, 'utf8');
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code !== 'ENOENT')
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
let patchMutated = false;
|
|
187
|
+
for (const insert of item.patchRows?.inserts ?? []) {
|
|
188
|
+
const plan = planRowOverride(patchText, insert.id, insert.name, true);
|
|
189
|
+
if (plan.changed) {
|
|
190
|
+
patchText = plan.text;
|
|
191
|
+
patchMutated = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (patchMutated)
|
|
195
|
+
await writeFileAtomic(patchPath, patchText, { mode: 0o600 });
|
|
196
|
+
}
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = await ctx.io.readFile(manifestPath, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code === 'ENOENT')
|
|
203
|
+
raw = '{}';
|
|
204
|
+
else
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
const doc = JSON.parse(raw);
|
|
208
|
+
if (typeof doc.dsh !== 'object' || doc.dsh === null)
|
|
209
|
+
doc.dsh = {};
|
|
210
|
+
if (typeof doc.dsh.profile !== 'object' || doc.dsh.profile === null)
|
|
211
|
+
doc.dsh.profile = {};
|
|
212
|
+
const previous = Array.isArray(doc.dsh.profile.bundles) ? doc.dsh.profile.bundles : [];
|
|
213
|
+
const bundles = enabled
|
|
214
|
+
? previous.includes(item.pkg) ? previous : [...previous, item.pkg]
|
|
215
|
+
: previous.filter((name) => name !== item.pkg);
|
|
216
|
+
doc.dsh.profile.bundles = bundles;
|
|
217
|
+
await writeFileAtomic(manifestPath, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o600 });
|
|
218
|
+
});
|
|
219
|
+
return { pkg: item.pkg, enabled, applied: 'restart-required', via: 'fallback', warnings: [] };
|
|
220
|
+
}
|
package/lib/core/versions.js
CHANGED
|
@@ -18,43 +18,56 @@ function githubRateLimitMessage(err) {
|
|
|
18
18
|
}
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
|
+
/** registry base 归一(去尾斜杠;空/缺省 → 官方 npmjs)。 */
|
|
22
|
+
function registryBase(registry) {
|
|
23
|
+
const base = typeof registry === 'string' && registry.trim() !== '' ? registry.trim() : 'https://registry.npmjs.org';
|
|
24
|
+
return base.replace(/\/+$/, '');
|
|
25
|
+
}
|
|
21
26
|
/** 精确 semver:接受 prerelease/build metadata,拒绝 v 前缀、range、tag 与脏尾缀。 */
|
|
22
27
|
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
23
28
|
export function isExactVersion(version) {
|
|
24
29
|
return EXACT_VERSION_RE.test(version);
|
|
25
30
|
}
|
|
26
31
|
/** 读取该精确版本的 dist metadata(不使用 /latest endpoint);integrity 缺失由调用方拒绝安装。 */
|
|
27
|
-
export async function npmVersion(pkg, version, timeoutMs = 20_000, signal) {
|
|
32
|
+
export async function npmVersion(pkg, version, timeoutMs = 20_000, signal, registry) {
|
|
28
33
|
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
29
34
|
throw new Error(`无效 npm 包名: ${pkg}`);
|
|
30
35
|
if (!isExactVersion(version))
|
|
31
36
|
throw new Error(`不是精确版本(拒绝 range/tag/前缀): ${version}`);
|
|
32
|
-
const data = await fetchJsonLimited(
|
|
37
|
+
const data = await fetchJsonLimited(`${registryBase(registry)}/${encodeURIComponent(pkg)}/${version}`, { timeoutMs, signal });
|
|
33
38
|
const resolved = typeof data.version === 'string' ? data.version : '';
|
|
34
39
|
if (!resolved)
|
|
35
40
|
throw new Error(`npm 未返回版本: ${pkg}@${version}`);
|
|
41
|
+
const peers = {};
|
|
42
|
+
if (data.peerDependencies !== null && typeof data.peerDependencies === 'object') {
|
|
43
|
+
for (const [name, range] of Object.entries(data.peerDependencies)) {
|
|
44
|
+
if (typeof range === 'string')
|
|
45
|
+
peers[name] = range;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
36
48
|
return {
|
|
37
49
|
version: resolved,
|
|
38
50
|
integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
|
|
39
51
|
tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
|
|
52
|
+
peers,
|
|
40
53
|
};
|
|
41
54
|
}
|
|
42
55
|
/**
|
|
43
56
|
* 拉取完整 packument(NO_MATCHING_VERSION 退避重试前的预热/校验原语)。
|
|
44
57
|
* 返回该包已知的全部版本号;解析不出 versions 时返回空列表(不抛)。
|
|
45
58
|
*/
|
|
46
|
-
export async function npmPackument(pkg, timeoutMs = 20_000, signal) {
|
|
59
|
+
export async function npmPackument(pkg, timeoutMs = 20_000, signal, registry) {
|
|
47
60
|
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
48
61
|
throw new Error(`无效 npm 包名: ${pkg}`);
|
|
49
62
|
const data = await fetchJsonLimited(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
|
|
50
63
|
const versions = data?.versions !== null && typeof data?.versions === 'object' ? Object.keys(data.versions) : [];
|
|
51
64
|
return { versions };
|
|
52
65
|
}
|
|
53
|
-
export async function npmLatest(pkg, timeoutMs = 20_000, signal) {
|
|
66
|
+
export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry) {
|
|
54
67
|
// 允许 scoped 包名:@scope/name(isSafePkgName 同款字符集)
|
|
55
68
|
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
56
69
|
throw new Error(`无效 npm 包名: ${pkg}`);
|
|
57
|
-
const data = await fetchJsonLimited(
|
|
70
|
+
const data = await fetchJsonLimited(`${registryBase(registry)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs, signal });
|
|
58
71
|
const version = typeof data.version === 'string' ? data.version : '';
|
|
59
72
|
if (!version)
|
|
60
73
|
throw new Error(`npm 未返回版本: ${pkg}`);
|
package/lib/host.js
CHANGED
|
@@ -36,6 +36,8 @@ export function apply(ctx, config) {
|
|
|
36
36
|
// using it avoids guessing the service unit from release-specific cgroups.
|
|
37
37
|
const appExit = appExitFromContext(ctx);
|
|
38
38
|
const restart = (port = null) => scheduleRestart(port, { appExit });
|
|
39
|
+
// 开关委派的官方服务探测(ADR-0001):运行时按服务存在性探测,不判 DSH 版本号
|
|
40
|
+
const getService = () => ctx.get?.('pluginManager');
|
|
39
41
|
registerTools(ctx, controller.config, { restart });
|
|
40
42
|
// 设置页接线(双代兼容,详见 core/settings-compat.ts):
|
|
41
43
|
// - ≤0.1.5:settings.register('dshm', …) scope(get/update/watch);
|
|
@@ -66,7 +68,14 @@ export function apply(ctx, config) {
|
|
|
66
68
|
// 本地 API:单路由 + method 分发(防护与状态映射在 core/host-api.ts)
|
|
67
69
|
ctx.inject(['webServer'], (c) => {
|
|
68
70
|
const server = c.webServer;
|
|
69
|
-
const handleApi = createApiDispatcher({
|
|
71
|
+
const handleApi = createApiDispatcher({
|
|
72
|
+
controller,
|
|
73
|
+
pkg,
|
|
74
|
+
deps: {
|
|
75
|
+
scheduleRestart: restart,
|
|
76
|
+
getService,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
70
79
|
server.register({
|
|
71
80
|
kind: 'exact',
|
|
72
81
|
path: '/dshm',
|