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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 精确构建放行(0.4.0,ADR-0002;plan Task 3):
|
|
3
|
+
* pnpm 11 拦下依赖构建脚本时会在 `pnpm-workspace.yaml` 的 `allowBuilds` 映射里留下
|
|
4
|
+
* 待决占位(值为 "set this to true or false")。本模块提供两个纯函数:
|
|
5
|
+
*
|
|
6
|
+
* - readPendingBuilds:解析待决名单(键不含通配符、值精确等于占位串)——官方
|
|
7
|
+
* `readPendingBuilds`(dsh-plugin-manager L941)同款判定;官方对坏结构 throw,
|
|
8
|
+
* 此处收窄为返回 [](调用方据此走全量兜底,ADR-0002 的保守回退);
|
|
9
|
+
* - applyPreciseBuilds:为名单逐键写 `allowBuilds.<name>: true`(保注释 YAML 编辑)。
|
|
10
|
+
*
|
|
11
|
+
* 官方 `approveBuilds` 的调用前提是「caller 持 profile manifest 锁」;dsh-m 事务
|
|
12
|
+
* 只有进程内 FIFO,与官方 UI 并发安装时此处存在低概率写竞态窗口——按 ADR-0002
|
|
13
|
+
* 声明为已知风险,相关整单失败走既有回滚语义,不额外加锁。
|
|
14
|
+
*/
|
|
15
|
+
import { isAlias, isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
16
|
+
/** pnpm 11 写下的待决占位值(逐字,官方同款)。 */
|
|
17
|
+
const PENDING_PLACEHOLDER = 'set this to true or false';
|
|
18
|
+
/** 键含通配符的规则不进精确放行(官方同款排除)。 */
|
|
19
|
+
function isPlainKey(key) {
|
|
20
|
+
return !/[*?]/.test(key);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 读 pnpm-workspace.yaml 文本的待决构建名单。
|
|
24
|
+
* 保守口径:坏 YAML / 根非映射 / allowBuilds 非映射 / 条目含锚点或别名 / 值非标量
|
|
25
|
+
* 一律返回 []——调用方走 `withDangerouslyAllowAllBuilds` 全量兜底并如实标注。
|
|
26
|
+
*/
|
|
27
|
+
export function readPendingBuilds(text) {
|
|
28
|
+
let document;
|
|
29
|
+
try {
|
|
30
|
+
document = parseDocument(text);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
if (document.errors.length > 0)
|
|
36
|
+
return [];
|
|
37
|
+
if (!isMap(document.contents))
|
|
38
|
+
return [];
|
|
39
|
+
const builds = document.contents.get('allowBuilds');
|
|
40
|
+
if (builds === undefined || builds === null)
|
|
41
|
+
return [];
|
|
42
|
+
if (!isMap(builds))
|
|
43
|
+
return [];
|
|
44
|
+
const pending = [];
|
|
45
|
+
for (const pair of builds.items) {
|
|
46
|
+
const { key, value } = pair;
|
|
47
|
+
if (!isScalar(key) || typeof key.value !== 'string')
|
|
48
|
+
continue;
|
|
49
|
+
if (isAlias(key) || isAlias(value))
|
|
50
|
+
return [];
|
|
51
|
+
// 锚点定义同样拒绝(官方 visit 检查的保守化)
|
|
52
|
+
if ((isMap(value) || isSeq(value)) && 'anchor' in value && value.anchor)
|
|
53
|
+
return [];
|
|
54
|
+
if (!isScalar(value) || value.value !== PENDING_PLACEHOLDER)
|
|
55
|
+
continue;
|
|
56
|
+
if (!isPlainKey(key.value))
|
|
57
|
+
continue;
|
|
58
|
+
pending.push(key.value);
|
|
59
|
+
}
|
|
60
|
+
return pending;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 为名单逐键写 `allowBuilds.<name>: true`(保注释;allowBuilds 缺失时创建)。
|
|
64
|
+
* 坏 YAML / 根非映射 → throw(调用方按放行写入失败处理,走既有 hard-fail 转换契约)。
|
|
65
|
+
* names 为空返回原文。
|
|
66
|
+
*/
|
|
67
|
+
export function applyPreciseBuilds(text, names) {
|
|
68
|
+
if (names.length === 0)
|
|
69
|
+
return text;
|
|
70
|
+
const document = parseDocument(text);
|
|
71
|
+
const error = document.errors[0];
|
|
72
|
+
if (error !== undefined)
|
|
73
|
+
throw error;
|
|
74
|
+
if (!isMap(document.contents))
|
|
75
|
+
throw new Error('pnpm-workspace.yaml 必须是 YAML 映射');
|
|
76
|
+
const existing = document.contents.get('allowBuilds');
|
|
77
|
+
if (existing !== undefined && existing !== null && !isMap(existing)) {
|
|
78
|
+
throw new Error('allowBuilds 必须是 YAML 映射');
|
|
79
|
+
}
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (typeof name !== 'string' || !isPlainKey(name)) {
|
|
82
|
+
throw new Error(`精确放行拒绝通配/非法键: ${JSON.stringify(name)}`);
|
|
83
|
+
}
|
|
84
|
+
// setIn 自动创建缺失的中间映射(allowBuilds 缺失时一并创建)
|
|
85
|
+
document.setIn(['allowBuilds', name], true);
|
|
86
|
+
}
|
|
87
|
+
return String(document);
|
|
88
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer 兼容预检(0.4.0,plan Task 6;语义逐条对齐官方 evaluatePluginCompatibility
|
|
3
|
+
* ——dsh-app-boot L286-313,源码在 app-boot 而非 plugin-manager 本体):
|
|
4
|
+
*
|
|
5
|
+
* - 只检 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 键;
|
|
6
|
+
* - `workspace:^` / `workspace:~` / `workspace:*` 视为当前运行时版本(workspace 依赖
|
|
7
|
+
* 在发布时由运行时替换);
|
|
8
|
+
* - 空 range 不满足;semver satisfies 带 includePrerelease(rc 生态必需);
|
|
9
|
+
* - 运行时版本解析失败(null)→ 不拦(结果由调用方注明「未检」);
|
|
10
|
+
* - github 源不在此路径(无 packument),由 market 层提示 compatSkipped;
|
|
11
|
+
* - 无豁免机制(grilling Q6 定稿):确认/force 即通道。
|
|
12
|
+
*/
|
|
13
|
+
import { satisfies } from 'semver';
|
|
14
|
+
import { npmVersion } from './versions.js';
|
|
15
|
+
import { resolveDshVersion } from './dsh-version.js';
|
|
16
|
+
const WORKSPACE_SPECS = ['workspace:^', 'workspace:~', 'workspace:*'];
|
|
17
|
+
export class IncompatibleError extends Error {
|
|
18
|
+
issue;
|
|
19
|
+
constructor(issue) {
|
|
20
|
+
super(`插件 ${issue.pkg}@${issue.version} 与 DSH ${issue.runtimeVersion} 不兼容(peerDependencies: ${JSON.stringify(issue.peers)})`);
|
|
21
|
+
this.issue = issue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 纯函数:peers 中不满足运行时版本的 dsh 系键(空对象 = 兼容)。
|
|
26
|
+
* 非法 range 按 semver 抛错口径让位给 satisfies(异常上抛,调用方如实报告)。
|
|
27
|
+
*/
|
|
28
|
+
export function evaluatePeers(peers, runtimeVersion) {
|
|
29
|
+
const failing = {};
|
|
30
|
+
for (const [name, range] of Object.entries(peers)) {
|
|
31
|
+
if (typeof range !== 'string')
|
|
32
|
+
continue;
|
|
33
|
+
if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-'))
|
|
34
|
+
continue;
|
|
35
|
+
const requirement = WORKSPACE_SPECS.includes(range) ? runtimeVersion : range;
|
|
36
|
+
if (requirement.trim() === '' || !satisfies(runtimeVersion, requirement, { includePrerelease: true })) {
|
|
37
|
+
failing[name] = range;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return failing;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* npm 源预检:拉该精确版本的 peerDependencies 与运行时版本比对。
|
|
44
|
+
* 返回 null = 兼容或无法判定(runtime 解析失败 → 不拦)。
|
|
45
|
+
*/
|
|
46
|
+
export async function precheckNpmCompat(pkg, version, opts) {
|
|
47
|
+
const runtime = opts.runtimeVersion !== undefined ? opts.runtimeVersion : await resolveDshVersion();
|
|
48
|
+
if (runtime === null || runtime === '')
|
|
49
|
+
return null;
|
|
50
|
+
const fetchVersion = opts.fetchVersion ?? npmVersion;
|
|
51
|
+
const meta = await fetchVersion(pkg, version, opts.timeoutMs, opts.signal);
|
|
52
|
+
const failing = evaluatePeers(meta.peers ?? {}, runtime);
|
|
53
|
+
if (Object.keys(failing).length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
return { pkg, version, runtimeVersion: runtime, peers: failing };
|
|
56
|
+
}
|
package/lib/core/dsh-cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
10
10
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
11
11
|
import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
|
|
12
12
|
import { createProgressTracker } from './progress.js';
|
|
13
|
+
import { applyPreciseBuilds, readPendingBuilds } from './build-approval.js';
|
|
13
14
|
/** pnpm patchedDependencies 条目键与目标包匹配:`pkg` 或 `pkg@任意版本/区间`。 */
|
|
14
15
|
function matchesPatchedKey(key, pkg) {
|
|
15
16
|
return key === pkg || key.startsWith(`${pkg}@`);
|
|
@@ -297,7 +298,7 @@ export function withDangerouslyAllowAllBuilds(yaml) {
|
|
|
297
298
|
return 'dangerouslyAllowAllBuilds: true\n';
|
|
298
299
|
return `${yaml.replace(/\s*$/u, '\n')}\ndangerouslyAllowAllBuilds: true\n`;
|
|
299
300
|
}
|
|
300
|
-
/** 基线 §17.4
|
|
301
|
+
/** 基线 §17.4:放行构建脚本前必须能被明确报告(结果带 buildApprovals)。 */
|
|
301
302
|
function writeDangerouslyAllowAllBuilds(profileDirectory) {
|
|
302
303
|
const file = join(profileDirectory, 'pnpm-workspace.yaml');
|
|
303
304
|
let yaml = '';
|
|
@@ -314,6 +315,31 @@ function writeDangerouslyAllowAllBuilds(profileDirectory) {
|
|
|
314
315
|
writeFileSync(file, next);
|
|
315
316
|
return true;
|
|
316
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* 精确放行默认实现(ADR-0002):needs-builds 拦截后先读 pending 名单,
|
|
320
|
+
* 非空 → 逐键写 allowBuilds(approvals 携带包名);空/不可读 → 全量兜底。
|
|
321
|
+
* 写入失败 throw(ladder 的 Y2 契约转换为 hard-fail 结果)。
|
|
322
|
+
* 已知风险(不加锁):官方 approveBuilds 前提是 caller 持 manifest 锁;dsh-m 只有
|
|
323
|
+
* 进程内 FIFO,与官方 UI 并发安装时有低概率写竞态,相关整单失败走既有回滚语义。
|
|
324
|
+
*/
|
|
325
|
+
function writePreciseBuilds(profileDirectory) {
|
|
326
|
+
const file = join(profileDirectory, 'pnpm-workspace.yaml');
|
|
327
|
+
let yaml = '';
|
|
328
|
+
try {
|
|
329
|
+
yaml = readFileSync(file, 'utf8');
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
/* 文件缺失 → pending 名单为空 → 走兜底创建 */
|
|
333
|
+
}
|
|
334
|
+
const pending = readPendingBuilds(yaml);
|
|
335
|
+
if (pending.length > 0) {
|
|
336
|
+
mkdirSync(profileDirectory, { recursive: true });
|
|
337
|
+
writeFileSync(file, applyPreciseBuilds(yaml, pending));
|
|
338
|
+
return { approvals: pending, fallbackAll: false };
|
|
339
|
+
}
|
|
340
|
+
writeDangerouslyAllowAllBuilds(profileDirectory);
|
|
341
|
+
return { approvals: [], fallbackAll: true };
|
|
342
|
+
}
|
|
317
343
|
/**
|
|
318
344
|
* 失败摘要:命令失败时从完整输出里提取可诊断的行,而不是盲取末尾。
|
|
319
345
|
* 2026-09-05 实证(dsh-better-sidebar 安装失败):pnpm ndjson 错误行 ~1.2KB,
|
|
@@ -550,13 +576,14 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
|
|
|
550
576
|
*/
|
|
551
577
|
export function makeAddViaLadder(deps) {
|
|
552
578
|
const run = deps.runDshPlugin;
|
|
553
|
-
const
|
|
579
|
+
const resolveBuilds = deps.resolveBuilds ?? writePreciseBuilds;
|
|
554
580
|
const opts = (signal) => (signal !== undefined ? { signal } : undefined);
|
|
555
581
|
return async (source, profileDir, signal) => {
|
|
556
582
|
const retryAfterPrepare = async () => {
|
|
557
|
-
// Y2
|
|
583
|
+
// Y2(终审复审):放行写入本身失败也必须转换为结果,维持「永不 throw」契约
|
|
584
|
+
let decision;
|
|
558
585
|
try {
|
|
559
|
-
|
|
586
|
+
decision = resolveBuilds(profileDir);
|
|
560
587
|
}
|
|
561
588
|
catch (err) {
|
|
562
589
|
const text = errText(err);
|
|
@@ -564,7 +591,7 @@ export function makeAddViaLadder(deps) {
|
|
|
564
591
|
}
|
|
565
592
|
try {
|
|
566
593
|
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
567
|
-
return { class: 'ok', output: truncateOutput(output),
|
|
594
|
+
return { class: 'ok', output: truncateOutput(output), buildApprovals: decision.approvals, fallbackAllBuilds: decision.fallbackAll };
|
|
568
595
|
}
|
|
569
596
|
catch (retryErr) {
|
|
570
597
|
return { ...classifyPnpmError(errText(retryErr)), output: truncateOutput(errText(retryErr)) };
|
|
@@ -572,7 +599,7 @@ export function makeAddViaLadder(deps) {
|
|
|
572
599
|
};
|
|
573
600
|
try {
|
|
574
601
|
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
575
|
-
return { class: 'ok', output: truncateOutput(output),
|
|
602
|
+
return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
|
|
576
603
|
}
|
|
577
604
|
catch (err) {
|
|
578
605
|
const text = errText(err);
|
|
@@ -586,7 +613,7 @@ export function makeAddViaLadder(deps) {
|
|
|
586
613
|
}
|
|
587
614
|
try {
|
|
588
615
|
const output = await run(WEB_PROFILE, ['add', source], opts(signal));
|
|
589
|
-
return { class: 'ok', output: truncateOutput(output),
|
|
616
|
+
return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
|
|
590
617
|
}
|
|
591
618
|
catch (retryErr) {
|
|
592
619
|
const retryText = errText(retryErr);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isSingleRowPlugin } from './patch-yaml.js';
|
|
2
|
+
/** 官方 Cordis FiberState 数字枚举(跨包 const enum 的运行时镜像,inventory 同款)。 */
|
|
3
|
+
const FIBER_PHASE = {
|
|
4
|
+
0: 'pending',
|
|
5
|
+
1: 'loading',
|
|
6
|
+
2: 'active',
|
|
7
|
+
3: 'failed',
|
|
8
|
+
5: 'unloading',
|
|
9
|
+
};
|
|
10
|
+
export const PROTECTED_MODULES = [
|
|
11
|
+
'dsh-m',
|
|
12
|
+
'@deepseek-ai/dsh-plugin-manager',
|
|
13
|
+
'@deepseek-ai/cordis-plugin-loader',
|
|
14
|
+
'@deepseek-ai/cordis-plugin-include',
|
|
15
|
+
'@deepseek-ai/dsh-api-gateway',
|
|
16
|
+
'@deepseek-ai/dsh-host-webserver',
|
|
17
|
+
'@deepseek-ai/dsh-client-modules',
|
|
18
|
+
'@deepseek-ai/dsh-client-ui-settings-plugin-inventory',
|
|
19
|
+
'@deepseek-ai/dsh-client-ui-plugin-manager',
|
|
20
|
+
'@deepseek-ai/dsh-host-plugin-inventory',
|
|
21
|
+
'@deepseek-ai/dsh-typert-registry',
|
|
22
|
+
'@deepseek-ai/dsh-api-remotes',
|
|
23
|
+
'@deepseek-ai/cordis-plugin-timer',
|
|
24
|
+
'@deepseek-ai/dsh-client-connection',
|
|
25
|
+
'@deepseek-ai/dsh-host-frontend-static',
|
|
26
|
+
'@deepseek-ai/dsh-tools',
|
|
27
|
+
'@deepseek-ai/dsh-hmr',
|
|
28
|
+
];
|
|
29
|
+
function mapPhase(fiber) {
|
|
30
|
+
if (fiber === undefined || fiber === null)
|
|
31
|
+
return null;
|
|
32
|
+
const state = typeof fiber.state === 'string' ? Number(fiber.state) : fiber.state;
|
|
33
|
+
if (typeof state !== 'number' || !Number.isInteger(state))
|
|
34
|
+
return null;
|
|
35
|
+
return FIBER_PHASE[state] ?? null;
|
|
36
|
+
}
|
|
37
|
+
/** 按模块名在 loader 条目中找插件的第一条非 group 条目(官方 moduleName 匹配同款)。 */
|
|
38
|
+
function findLoaderEntry(entries, pkg) {
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry && entry.options?.name === pkg)
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 已装清单 → 每包 { enabled, phase, granularity, toggleable, lockReason }。
|
|
47
|
+
* toggleable=false 当且仅当:命中保护名单(protected/self)或「不在 bundles 且
|
|
48
|
+
* loader 无 entry」(no-entry——未装载不是被关)。粒度由包内补丁层行枚举决定。
|
|
49
|
+
*/
|
|
50
|
+
export function composeEnablement(input) {
|
|
51
|
+
const { items, loaderEntries, bundles, profileOverrides } = input;
|
|
52
|
+
const result = new Map();
|
|
53
|
+
const overrideIndex = new Map(profileOverrides.map((row) => [row.id, row.disabled]));
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const pkg = item.pkg;
|
|
56
|
+
const entry = loaderEntries !== null ? findLoaderEntry(loaderEntries, pkg) : null;
|
|
57
|
+
const inBundles = bundles.includes(pkg);
|
|
58
|
+
const rowIds = item.patchRows?.inserts.map((row) => row.id) ?? [];
|
|
59
|
+
let enabled;
|
|
60
|
+
let phase = null;
|
|
61
|
+
if (loaderEntries !== null) {
|
|
62
|
+
// loader 是活体权威:无 entry = 未运行(bundle 未选 / 树缺条目),enabled:false
|
|
63
|
+
enabled = entry !== null && !entry.disabled;
|
|
64
|
+
if (entry !== null)
|
|
65
|
+
phase = mapPhase(entry.fiber);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// CLI 无 loader 形态:在 bundles 且其补丁行无 disabled 覆盖 → 视为启用
|
|
69
|
+
const disabledByOverride = rowIds.some((id) => overrideIndex.get(id) === true);
|
|
70
|
+
enabled = inBundles && !disabledByOverride;
|
|
71
|
+
}
|
|
72
|
+
let lockReason;
|
|
73
|
+
// 有补丁层却不在 bundles = Bundle 退选(dsh-m / 官方插件页可再开),不是纯依赖
|
|
74
|
+
const hasPatchLayer = Boolean(item.patchRows?.readable && item.patchRows.inserts.length);
|
|
75
|
+
if (pkg === 'dsh-m')
|
|
76
|
+
lockReason = 'self';
|
|
77
|
+
else if (PROTECTED_MODULES.includes(pkg))
|
|
78
|
+
lockReason = 'protected';
|
|
79
|
+
else if (entry === null && !inBundles && !hasPatchLayer)
|
|
80
|
+
lockReason = 'no-entry';
|
|
81
|
+
const granularity = item.patchRows && isSingleRowPlugin(item.patchRows) ? 'row' : 'bundle';
|
|
82
|
+
result.set(pkg, {
|
|
83
|
+
pkg,
|
|
84
|
+
enabled,
|
|
85
|
+
phase,
|
|
86
|
+
granularity,
|
|
87
|
+
toggleable: lockReason === undefined,
|
|
88
|
+
...(lockReason === undefined ? {} : { lockReason }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
package/lib/core/host-api.js
CHANGED
|
@@ -16,6 +16,8 @@ import { RegistryConfigError } from './registry-controller.js';
|
|
|
16
16
|
import { checkRegistryEntries } from './registry-check.js';
|
|
17
17
|
import { CATEGORIES } from './registry.js';
|
|
18
18
|
import { servingPort, scheduleRestart, trustedRestartRequest } from './restart.js';
|
|
19
|
+
import { togglePlugin, ToggleError } from './toggle.js';
|
|
20
|
+
import { IncompatibleError } from './compat-check.js';
|
|
19
21
|
export class BadJsonError extends Error {
|
|
20
22
|
}
|
|
21
23
|
export class BodyTooLargeError extends Error {
|
|
@@ -131,6 +133,14 @@ function errorStatus(err) {
|
|
|
131
133
|
}
|
|
132
134
|
if (err instanceof ApiProtocolError)
|
|
133
135
|
return { status: err.status, payload: { ok: false, error: err.message } };
|
|
136
|
+
if (err instanceof ToggleError) {
|
|
137
|
+
const status = err.code === 'protected' ? 403 : err.code === 'not-installed' ? 404 : 409;
|
|
138
|
+
return { status, payload: { ok: false, error: err.message, code: err.code } };
|
|
139
|
+
}
|
|
140
|
+
if (err instanceof IncompatibleError) {
|
|
141
|
+
// 结构化 issue:GUI 据此弹「仍要安装」确认(forceIncompatible 重试)
|
|
142
|
+
return { status: 409, payload: { ok: false, error: err.message, issue: err.issue } };
|
|
143
|
+
}
|
|
134
144
|
if (err instanceof TransactionError) {
|
|
135
145
|
// detail 白名单投影:结构化事实,不含 raw output(GUI 只读 error,零改动)
|
|
136
146
|
const r = err.result;
|
|
@@ -164,6 +174,8 @@ export function createApiDispatcher(ctx) {
|
|
|
164
174
|
runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
|
|
165
175
|
scheduleRestart: ctx.deps?.scheduleRestart ?? scheduleRestart,
|
|
166
176
|
resolveDshVersion: ctx.deps?.resolveDshVersion ?? resolveDshVersion,
|
|
177
|
+
getService: ctx.deps?.getService,
|
|
178
|
+
toggle: ctx.deps?.togglePlugin ?? togglePlugin,
|
|
167
179
|
};
|
|
168
180
|
const cfg = () => ctx.controller.config;
|
|
169
181
|
// DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
|
|
@@ -210,7 +222,8 @@ export function createApiDispatcher(ctx) {
|
|
|
210
222
|
payload = {
|
|
211
223
|
pkg: ctx.pkg.name,
|
|
212
224
|
version: latest.version,
|
|
213
|
-
|
|
225
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
226
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
214
227
|
needsRestart: true,
|
|
215
228
|
};
|
|
216
229
|
break;
|
|
@@ -266,7 +279,12 @@ export function createApiDispatcher(ctx) {
|
|
|
266
279
|
if (!id)
|
|
267
280
|
throw new ApiProtocolError(400, '缺少 id');
|
|
268
281
|
const version = typeof body.version === 'string' ? body.version : undefined;
|
|
269
|
-
const result = await d.installFromRegistry(id, cfg(), {
|
|
282
|
+
const result = await d.installFromRegistry(id, cfg(), {
|
|
283
|
+
version,
|
|
284
|
+
forceIncompatible: boolArg(body.forceIncompatible),
|
|
285
|
+
namespace: 'host',
|
|
286
|
+
signal,
|
|
287
|
+
});
|
|
270
288
|
payload = { ...result };
|
|
271
289
|
break;
|
|
272
290
|
}
|
|
@@ -278,11 +296,25 @@ export function createApiDispatcher(ctx) {
|
|
|
278
296
|
payload = { ...result };
|
|
279
297
|
break;
|
|
280
298
|
}
|
|
299
|
+
case 'set-enabled': {
|
|
300
|
+
const target = strArg(body, 'pkg');
|
|
301
|
+
if (!target)
|
|
302
|
+
throw new ApiProtocolError(400, '缺少 pkg');
|
|
303
|
+
if (typeof body.enabled !== 'boolean')
|
|
304
|
+
throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
|
|
305
|
+
const result = await d.toggle(target, body.enabled, { getService: d.getService });
|
|
306
|
+
payload = { ...result };
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
281
309
|
case 'upgrade': {
|
|
282
310
|
const target = strArg(body, 'pkg');
|
|
283
311
|
if (!target)
|
|
284
312
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
285
|
-
const result = await d.upgradePlugin(target, cfg(), {
|
|
313
|
+
const result = await d.upgradePlugin(target, cfg(), {
|
|
314
|
+
forceIncompatible: boolArg(body.forceIncompatible),
|
|
315
|
+
namespace: 'host',
|
|
316
|
+
signal,
|
|
317
|
+
});
|
|
286
318
|
payload = { ...result };
|
|
287
319
|
break;
|
|
288
320
|
}
|
package/lib/core/installed.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { open, readFile } from 'node:fs/promises';
|
|
13
13
|
import { join, resolve } from 'node:path';
|
|
14
14
|
import { webProfileDir } from './env.js';
|
|
15
|
+
import { readBundlePatchRows } from './patch-yaml.js';
|
|
15
16
|
const PKG_NAME_RE = /^(@[A-Za-z0-9-*~][A-Za-z0-9-*._~]*\/)?[A-Za-z0-9-._~]+$/;
|
|
16
17
|
export function isSafePkgName(raw) {
|
|
17
18
|
const name = String(raw || '').trim();
|
|
@@ -172,6 +173,11 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
|
172
173
|
continue;
|
|
173
174
|
}
|
|
174
175
|
const info = sanitizePkgJson(raw, pkg);
|
|
176
|
+
// 包内补丁层行枚举(开关粒度判定原料):缺失 → readable:false(按多行 → Bundle 级处理)
|
|
177
|
+
const patchText = await readTextLimited(join(dir, 'cordis.patch.yml'), PATCH_MAX_BYTES);
|
|
178
|
+
const patchRows = patchText
|
|
179
|
+
? readBundlePatchRows(patchText.text)
|
|
180
|
+
: { inserts: [], configRows: 0, readable: false };
|
|
175
181
|
items.push({
|
|
176
182
|
pkg,
|
|
177
183
|
name: info.name,
|
|
@@ -183,6 +189,7 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
|
183
189
|
dsh: true,
|
|
184
190
|
path: dir,
|
|
185
191
|
githubRepo: githubRepoFromRepository(raw.repository),
|
|
192
|
+
patchRows,
|
|
186
193
|
});
|
|
187
194
|
}
|
|
188
195
|
return { items, others, complete, profileDir: root };
|
|
@@ -190,6 +197,8 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
|
|
|
190
197
|
// ---------- README 预览(借鉴 skillhub,64KB 截断) ----------
|
|
191
198
|
const README_MAX_BYTES = 64 * 1024;
|
|
192
199
|
const README_FILES = ['README.md', 'README.markdown', 'README'];
|
|
200
|
+
/** 包内 cordis.patch.yml 读取上限(补丁层是小文件,256KB 远超正常体积)。 */
|
|
201
|
+
const PATCH_MAX_BYTES = 256 * 1024;
|
|
193
202
|
/** 限量读取文本文件:只读前 limit 字节,超限标记 truncated。 */
|
|
194
203
|
async function readTextLimited(path, limit) {
|
|
195
204
|
let fh;
|
package/lib/core/market.js
CHANGED
|
@@ -8,12 +8,49 @@
|
|
|
8
8
|
* 新发布后 NO_MATCHING_VERSION 的退避重试 + packument 预热(B3)。
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
11
12
|
import { join } from 'node:path';
|
|
12
13
|
import { dshHome } from './env.js';
|
|
13
14
|
import { runProfileTransaction, makeNpmWarmPackument, TransactionError, } from './profile-transaction.js';
|
|
14
15
|
import { listInstalledPlugins as defaultListInstalledPlugins, } from './installed.js';
|
|
16
|
+
import { loaderHost } from './live-plugin.js';
|
|
17
|
+
import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
|
|
18
|
+
import { PROTECTED_MODULES, composeEnablement } from './enablement.js';
|
|
19
|
+
import { readProfileOverrides } from './patch-yaml.js';
|
|
15
20
|
import { loadRegistry as defaultLoadRegistry, } from './registry.js';
|
|
16
21
|
import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
|
|
22
|
+
/** profile package.json 的 dsh.profile.bundles 数组(读不到 → [];Task 13)。 */
|
|
23
|
+
async function readProfileBundles(profileDir) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
|
|
26
|
+
const bundles = raw?.dsh?.profile?.bundles;
|
|
27
|
+
return Array.isArray(bundles) ? bundles.filter((name) => typeof name === 'string') : [];
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** profile cordis.patch.yml 覆盖行(读不到 → [];Task 13)。 */
|
|
34
|
+
async function readProfileOverrideRows(profileDir) {
|
|
35
|
+
try {
|
|
36
|
+
return readProfileOverrides(await readFile(join(profileDir, 'cordis.patch.yml'), 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** 宿主内 loader entries(CLI/测试进程 → null;Task 13 读路径始终自读)。 */
|
|
43
|
+
function loaderEntriesOrNull() {
|
|
44
|
+
const entries = loaderHost()?.loader?.entries;
|
|
45
|
+
if (typeof entries !== 'function')
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
return [...entries.call(loaderHost().loader)];
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
17
54
|
// ---------- 通用工具 ----------
|
|
18
55
|
const DEFAULT_DEADLINE_MS = 60_000;
|
|
19
56
|
const WITH_LATEST_MAX = 50;
|
|
@@ -329,7 +366,19 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
329
366
|
const installedPromise = d.listInstalledPlugins();
|
|
330
367
|
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
|
|
331
368
|
const installed = await installedPromise;
|
|
332
|
-
|
|
369
|
+
// enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
|
|
370
|
+
const compose = d.composeEnablement ?? composeEnablement;
|
|
371
|
+
const enablement = await compose({
|
|
372
|
+
items: installed.items,
|
|
373
|
+
loaderEntries: loaderEntriesOrNull(),
|
|
374
|
+
bundles: await readProfileBundles(installed.profileDir),
|
|
375
|
+
profileOverrides: await readProfileOverrideRows(installed.profileDir),
|
|
376
|
+
});
|
|
377
|
+
const items = installed.items.map((it) => ({
|
|
378
|
+
...it,
|
|
379
|
+
outdated: false,
|
|
380
|
+
...(enablement.get(it.pkg) ?? { pkg: it.pkg, enabled: true, phase: null, granularity: 'bundle', toggleable: false, lockReason: 'no-entry' }),
|
|
381
|
+
}));
|
|
333
382
|
let loaded;
|
|
334
383
|
try {
|
|
335
384
|
loaded = await deadlineRace(registryTask, remaining());
|
|
@@ -432,6 +481,20 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
432
481
|
}
|
|
433
482
|
if (!expectedIntegrity)
|
|
434
483
|
throw new Error(`npm metadata 缺少 dist integrity:${pkg}@${version},拒绝安装`);
|
|
484
|
+
// peer 兼容预检(Task 11,事务外 metadata 阶段;force = 用户已确认风险)。
|
|
485
|
+
// 预检自身的元数据读取失败(网络错/404)不拦安装——视为未检,安装事务自有失败语义。
|
|
486
|
+
let compat = null;
|
|
487
|
+
try {
|
|
488
|
+
compat = await (deps?.precheck ?? precheckNpmCompat)(pkg, version, {
|
|
489
|
+
timeoutMs,
|
|
490
|
+
signal: opts.signal,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
compat = null;
|
|
495
|
+
}
|
|
496
|
+
if (compat !== null && opts.forceIncompatible !== true)
|
|
497
|
+
throw new IncompatibleError(compat);
|
|
435
498
|
// 生产预热绑定:未注入时 B3 用 makeNpmWarmPackument(保留 timeout/signal、失败吞错)
|
|
436
499
|
const result = await runProfileTransaction({ kind: 'install-npm', pkg, version, integrity: expectedIntegrity, signal: opts.signal }, {
|
|
437
500
|
...deps?.transaction,
|
|
@@ -445,7 +508,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
445
508
|
pkg: result.pkg ?? pkg,
|
|
446
509
|
spec: result.spec ?? `${pkg}@${version}`,
|
|
447
510
|
version: result.version ?? version,
|
|
448
|
-
|
|
511
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
512
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
513
|
+
...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
|
|
514
|
+
compat: compat ?? null,
|
|
449
515
|
needsRestart: true,
|
|
450
516
|
output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
|
|
451
517
|
healActions: result.healActions,
|
|
@@ -464,7 +530,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
464
530
|
spec: result.spec ?? `github:${entry.github}#${sha}`,
|
|
465
531
|
sha: result.sha ?? sha,
|
|
466
532
|
tag: result.tag ?? tag,
|
|
467
|
-
|
|
533
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
534
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
535
|
+
...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
|
|
536
|
+
compatSkipped: 'github-source',
|
|
468
537
|
needsRestart: true,
|
|
469
538
|
output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
|
|
470
539
|
healActions: result.healActions,
|
|
@@ -474,6 +543,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
474
543
|
}
|
|
475
544
|
/** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
|
|
476
545
|
export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
|
|
546
|
+
// 保护门(Task 12,grilling Q3):dsh-m 自身与官方宿主命脉拒绝卸载(升级不受影响)
|
|
547
|
+
if (pkg === 'dsh-m' || PROTECTED_MODULES.includes(pkg)) {
|
|
548
|
+
throw new Error(`拒绝卸载受保护插件: ${pkg}(dsh-m 自身或官方宿主命脉)。如确需移除,请使用官方插件管理页或 dsh plugin CLI`);
|
|
549
|
+
}
|
|
477
550
|
const result = await runProfileTransaction({ kind: 'uninstall', pkg, signal: opts.signal }, deps.transaction ?? {});
|
|
478
551
|
if (!result.ok)
|
|
479
552
|
throw new TransactionError(result);
|