dsh-m 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +19 -6
- package/README.md +19 -6
- package/docs/DESIGN.md +11 -1
- package/lib/cli.js +41 -8
- package/lib/client.js +317 -15
- 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 +74 -6
- package/lib/core/installed.js +9 -0
- package/lib/core/market.js +100 -8
- package/lib/core/patch-yaml.js +143 -0
- package/lib/core/profile-transaction.js +30 -2
- package/lib/core/registry-probe.js +82 -0
- package/lib/core/registry.js +32 -1
- package/lib/core/settings-compat.js +14 -0
- package/lib/core/toggle.js +220 -0
- package/lib/core/versions.js +18 -5
- package/lib/host.js +53 -2
- package/lib/tools.js +69 -5
- package/package.json +7 -1
- package/registry.json +128 -22
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,7 +174,15 @@ 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,
|
|
179
|
+
probe: ctx.deps?.probe,
|
|
180
|
+
probeSnapshot: ctx.deps?.probeSnapshot,
|
|
181
|
+
probeConfigGet: ctx.deps?.probeConfigGet,
|
|
182
|
+
probeConfigSet: ctx.deps?.probeConfigSet,
|
|
167
183
|
};
|
|
184
|
+
// 元数据源探测注入(Task 8/14):市场/安装/升级/已装的元数据读取共用
|
|
185
|
+
const probeDeps = d.probe !== undefined ? { probe: d.probe } : {};
|
|
168
186
|
const cfg = () => ctx.controller.config;
|
|
169
187
|
// DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
|
|
170
188
|
const dshVersionP = Promise.resolve()
|
|
@@ -210,7 +228,8 @@ export function createApiDispatcher(ctx) {
|
|
|
210
228
|
payload = {
|
|
211
229
|
pkg: ctx.pkg.name,
|
|
212
230
|
version: latest.version,
|
|
213
|
-
|
|
231
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
232
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
214
233
|
needsRestart: true,
|
|
215
234
|
};
|
|
216
235
|
break;
|
|
@@ -240,13 +259,13 @@ export function createApiDispatcher(ctx) {
|
|
|
240
259
|
withLatest: true,
|
|
241
260
|
namespace: 'host',
|
|
242
261
|
signal,
|
|
243
|
-
});
|
|
262
|
+
}, probeDeps);
|
|
244
263
|
payload = { ...result };
|
|
245
264
|
break;
|
|
246
265
|
}
|
|
247
266
|
case 'installed': {
|
|
248
267
|
await ctx.controller.ensureReady();
|
|
249
|
-
const result = await d.listInstalledWithMeta(cfg(), { namespace: 'host', signal });
|
|
268
|
+
const result = await d.listInstalledWithMeta(cfg(), { namespace: 'host', signal }, probeDeps);
|
|
250
269
|
payload = { ...result };
|
|
251
270
|
break;
|
|
252
271
|
}
|
|
@@ -259,14 +278,49 @@ export function createApiDispatcher(ctx) {
|
|
|
259
278
|
break;
|
|
260
279
|
}
|
|
261
280
|
case 'status':
|
|
262
|
-
payload = { ...publicInstallStatus() };
|
|
281
|
+
payload = { ...publicInstallStatus(), probe: d.probeSnapshot?.() ?? null };
|
|
282
|
+
break;
|
|
283
|
+
case 'probe-config': {
|
|
284
|
+
if (!d.probeConfigGet)
|
|
285
|
+
throw new ApiProtocolError(501, 'probe 配置读取不可用');
|
|
286
|
+
payload = { ...d.probeConfigGet(), writable: typeof d.probeConfigSet === 'function' };
|
|
263
287
|
break;
|
|
288
|
+
}
|
|
289
|
+
case 'probe-config-set': {
|
|
290
|
+
if (!d.probeConfigGet || !d.probeConfigSet)
|
|
291
|
+
throw new ApiProtocolError(501, 'probe 配置写入不可用(设置服务缺席)');
|
|
292
|
+
const patch = {};
|
|
293
|
+
if (body.probeEnabled !== undefined)
|
|
294
|
+
patch.probeEnabled = boolArg(body.probeEnabled);
|
|
295
|
+
if (body.probeTimeoutMs !== undefined) {
|
|
296
|
+
const n = Number(body.probeTimeoutMs);
|
|
297
|
+
if (!Number.isFinite(n) || n < 100 || n > 30_000)
|
|
298
|
+
throw new ApiProtocolError(400, 'probeTimeoutMs 须在 100..30000 之间');
|
|
299
|
+
patch.probeTimeoutMs = Math.floor(n);
|
|
300
|
+
}
|
|
301
|
+
if (body.probeCacheTtlMin !== undefined) {
|
|
302
|
+
const n = Number(body.probeCacheTtlMin);
|
|
303
|
+
if (!Number.isFinite(n) || n < 0 || n > 1440)
|
|
304
|
+
throw new ApiProtocolError(400, 'probeCacheTtlMin 须在 0..1440 之间');
|
|
305
|
+
patch.probeCacheTtlMin = Math.floor(n);
|
|
306
|
+
}
|
|
307
|
+
if (Object.keys(patch).length === 0)
|
|
308
|
+
throw new ApiProtocolError(400, '缺少可更新字段');
|
|
309
|
+
await d.probeConfigSet(patch);
|
|
310
|
+
payload = { ...d.probeConfigGet(), writable: true };
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
264
313
|
case 'install': {
|
|
265
314
|
const id = strArg(body, 'id');
|
|
266
315
|
if (!id)
|
|
267
316
|
throw new ApiProtocolError(400, '缺少 id');
|
|
268
317
|
const version = typeof body.version === 'string' ? body.version : undefined;
|
|
269
|
-
const result = await d.installFromRegistry(id, cfg(), {
|
|
318
|
+
const result = await d.installFromRegistry(id, cfg(), {
|
|
319
|
+
version,
|
|
320
|
+
forceIncompatible: boolArg(body.forceIncompatible),
|
|
321
|
+
namespace: 'host',
|
|
322
|
+
signal,
|
|
323
|
+
}, probeDeps);
|
|
270
324
|
payload = { ...result };
|
|
271
325
|
break;
|
|
272
326
|
}
|
|
@@ -278,11 +332,25 @@ export function createApiDispatcher(ctx) {
|
|
|
278
332
|
payload = { ...result };
|
|
279
333
|
break;
|
|
280
334
|
}
|
|
335
|
+
case 'set-enabled': {
|
|
336
|
+
const target = strArg(body, 'pkg');
|
|
337
|
+
if (!target)
|
|
338
|
+
throw new ApiProtocolError(400, '缺少 pkg');
|
|
339
|
+
if (typeof body.enabled !== 'boolean')
|
|
340
|
+
throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
|
|
341
|
+
const result = await d.toggle(target, body.enabled, { getService: d.getService });
|
|
342
|
+
payload = { ...result };
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
281
345
|
case 'upgrade': {
|
|
282
346
|
const target = strArg(body, 'pkg');
|
|
283
347
|
if (!target)
|
|
284
348
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
285
|
-
const result = await d.upgradePlugin(target, cfg(), {
|
|
349
|
+
const result = await d.upgradePlugin(target, cfg(), {
|
|
350
|
+
forceIncompatible: boolArg(body.forceIncompatible),
|
|
351
|
+
namespace: 'host',
|
|
352
|
+
signal,
|
|
353
|
+
}, probeDeps);
|
|
286
354
|
payload = { ...result };
|
|
287
355
|
break;
|
|
288
356
|
}
|
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,65 @@
|
|
|
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
|
+
}
|
|
54
|
+
/** 探测结果 → versions.ts 的 registry base;探测失败/禁用 → undefined(默认 npmjs)。 */
|
|
55
|
+
async function probeRegistryBase(probe) {
|
|
56
|
+
if (probe === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const source = await probe.fastest();
|
|
60
|
+
if (source === 'npmjs')
|
|
61
|
+
return 'https://registry.npmjs.org';
|
|
62
|
+
if (source === 'npmmirror')
|
|
63
|
+
return 'https://registry.npmmirror.com';
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* 探测失败 → 默认源 */
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
17
70
|
// ---------- 通用工具 ----------
|
|
18
71
|
const DEFAULT_DEADLINE_MS = 60_000;
|
|
19
72
|
const WITH_LATEST_MAX = 50;
|
|
@@ -115,9 +168,9 @@ function writeLatestCache(key, value) {
|
|
|
115
168
|
}
|
|
116
169
|
latestCache.set(key, { at: Date.now(), value });
|
|
117
170
|
}
|
|
118
|
-
function probeTask(item, deps, timeoutMs, signal) {
|
|
171
|
+
function probeTask(item, deps, timeoutMs, signal, registry) {
|
|
119
172
|
if (item.source === 'npm' && item.npm) {
|
|
120
|
-
return deps.npmLatest(item.npm, timeoutMs, signal).then((r) => ({ version: r.version }));
|
|
173
|
+
return deps.npmLatest(item.npm, timeoutMs, signal, registry).then((r) => ({ version: r.version }));
|
|
121
174
|
}
|
|
122
175
|
return deps.githubLatestTag(item.github, timeoutMs, signal).then((r) => ({ tag: r.tag, sha: r.sha }));
|
|
123
176
|
}
|
|
@@ -278,9 +331,10 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
278
331
|
latestTimedOut = true;
|
|
279
332
|
}
|
|
280
333
|
else {
|
|
334
|
+
const probeRegistry = await probeRegistryBase(d.probe);
|
|
281
335
|
await mapWithConcurrency(todo, LATEST_WORKERS, async (item) => {
|
|
282
336
|
const perBudget = Math.max(1, remaining());
|
|
283
|
-
const task = probeTask(item, d, Math.min(cfg.timeoutMs ?? 20_000, perBudget), signal);
|
|
337
|
+
const task = probeTask(item, d, Math.min(cfg.timeoutMs ?? 20_000, perBudget), signal, probeRegistry);
|
|
284
338
|
const outcome = await probeWithBudget(task, perBudget, signal);
|
|
285
339
|
if (outcome.ok && outcome.value) {
|
|
286
340
|
applyProbe(item, outcome.value);
|
|
@@ -329,7 +383,19 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
329
383
|
const installedPromise = d.listInstalledPlugins();
|
|
330
384
|
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
|
|
331
385
|
const installed = await installedPromise;
|
|
332
|
-
|
|
386
|
+
// enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
|
|
387
|
+
const compose = d.composeEnablement ?? composeEnablement;
|
|
388
|
+
const enablement = await compose({
|
|
389
|
+
items: installed.items,
|
|
390
|
+
loaderEntries: loaderEntriesOrNull(),
|
|
391
|
+
bundles: await readProfileBundles(installed.profileDir),
|
|
392
|
+
profileOverrides: await readProfileOverrideRows(installed.profileDir),
|
|
393
|
+
});
|
|
394
|
+
const items = installed.items.map((it) => ({
|
|
395
|
+
...it,
|
|
396
|
+
outdated: false,
|
|
397
|
+
...(enablement.get(it.pkg) ?? { pkg: it.pkg, enabled: true, phase: null, granularity: 'bundle', toggleable: false, lockReason: 'no-entry' }),
|
|
398
|
+
}));
|
|
333
399
|
let loaded;
|
|
334
400
|
try {
|
|
335
401
|
loaded = await deadlineRace(registryTask, remaining());
|
|
@@ -414,6 +480,7 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
414
480
|
const d = {
|
|
415
481
|
npmLatest: deps?.npmLatest ?? defaultNpmLatest,
|
|
416
482
|
npmVersion: deps?.npmVersion ?? npmVersion,
|
|
483
|
+
registry: await probeRegistryBase(deps?.probe),
|
|
417
484
|
};
|
|
418
485
|
if (entry.source === 'npm' && entry.npm) {
|
|
419
486
|
const pkg = entry.npm;
|
|
@@ -421,17 +488,32 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
421
488
|
let version;
|
|
422
489
|
let expectedIntegrity;
|
|
423
490
|
if (opts.version) {
|
|
424
|
-
const meta = await d.npmVersion(pkg, opts.version, timeoutMs, opts.signal);
|
|
491
|
+
const meta = await d.npmVersion(pkg, opts.version, timeoutMs, opts.signal, d.registry);
|
|
425
492
|
version = meta.version;
|
|
426
493
|
expectedIntegrity = meta.integrity;
|
|
427
494
|
}
|
|
428
495
|
else {
|
|
429
|
-
const latest = await d.npmLatest(pkg, timeoutMs, opts.signal);
|
|
496
|
+
const latest = await d.npmLatest(pkg, timeoutMs, opts.signal, d.registry);
|
|
430
497
|
version = latest.version;
|
|
431
498
|
expectedIntegrity = latest.integrity;
|
|
432
499
|
}
|
|
433
500
|
if (!expectedIntegrity)
|
|
434
501
|
throw new Error(`npm metadata 缺少 dist integrity:${pkg}@${version},拒绝安装`);
|
|
502
|
+
// peer 兼容预检(Task 11,事务外 metadata 阶段;force = 用户已确认风险)。
|
|
503
|
+
// 预检自身的元数据读取失败(网络错/404)不拦安装——视为未检,安装事务自有失败语义。
|
|
504
|
+
let compat = null;
|
|
505
|
+
try {
|
|
506
|
+
compat = await (deps?.precheck ?? precheckNpmCompat)(pkg, version, {
|
|
507
|
+
timeoutMs,
|
|
508
|
+
signal: opts.signal,
|
|
509
|
+
registry: d.registry,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
compat = null;
|
|
514
|
+
}
|
|
515
|
+
if (compat !== null && opts.forceIncompatible !== true)
|
|
516
|
+
throw new IncompatibleError(compat);
|
|
435
517
|
// 生产预热绑定:未注入时 B3 用 makeNpmWarmPackument(保留 timeout/signal、失败吞错)
|
|
436
518
|
const result = await runProfileTransaction({ kind: 'install-npm', pkg, version, integrity: expectedIntegrity, signal: opts.signal }, {
|
|
437
519
|
...deps?.transaction,
|
|
@@ -445,7 +527,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
445
527
|
pkg: result.pkg ?? pkg,
|
|
446
528
|
spec: result.spec ?? `${pkg}@${version}`,
|
|
447
529
|
version: result.version ?? version,
|
|
448
|
-
|
|
530
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
531
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
532
|
+
...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
|
|
533
|
+
compat: compat ?? null,
|
|
449
534
|
needsRestart: true,
|
|
450
535
|
output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
|
|
451
536
|
healActions: result.healActions,
|
|
@@ -464,7 +549,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
464
549
|
spec: result.spec ?? `github:${entry.github}#${sha}`,
|
|
465
550
|
sha: result.sha ?? sha,
|
|
466
551
|
tag: result.tag ?? tag,
|
|
467
|
-
|
|
552
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
553
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
554
|
+
...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
|
|
555
|
+
compatSkipped: 'github-source',
|
|
468
556
|
needsRestart: true,
|
|
469
557
|
output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
|
|
470
558
|
healActions: result.healActions,
|
|
@@ -474,6 +562,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
474
562
|
}
|
|
475
563
|
/** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
|
|
476
564
|
export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
|
|
565
|
+
// 保护门(Task 12,grilling Q3):dsh-m 自身与官方宿主命脉拒绝卸载(升级不受影响)
|
|
566
|
+
if (pkg === 'dsh-m' || PROTECTED_MODULES.includes(pkg)) {
|
|
567
|
+
throw new Error(`拒绝卸载受保护插件: ${pkg}(dsh-m 自身或官方宿主命脉)。如确需移除,请使用官方插件管理页或 dsh plugin CLI`);
|
|
568
|
+
}
|
|
477
569
|
const result = await runProfileTransaction({ kind: 'uninstall', pkg, signal: opts.signal }, deps.transaction ?? {});
|
|
478
570
|
if (!result.ok)
|
|
479
571
|
throw new TransactionError(result);
|