dsh-m 0.8.5 → 0.9.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 +30 -5
- package/README.md +30 -5
- package/docs/DESIGN.md +1 -1
- package/lib/cli.js +12 -0
- package/lib/client.js +159 -9
- package/lib/core/active-profile.js +73 -0
- package/lib/core/community.js +6 -5
- package/lib/core/env.js +14 -2
- package/lib/core/host-api.js +66 -10
- package/lib/core/market.js +21 -13
- package/lib/core/patch-yaml.js +1 -1
- package/lib/core/profile-ops.js +158 -0
- package/lib/core/registry-controller.js +15 -13
- package/lib/core/registry.js +34 -31
- package/lib/core/restart.js +3 -30
- package/lib/host.js +41 -3
- package/lib/tools.js +27 -3
- package/package.json +1 -1
package/lib/core/host-api.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* /dshm Host API dispatcher(DESIGN.md §4/§5):可注入的 method 分发 + 请求防护 +
|
|
3
3
|
* HTTP 状态映射,供 host.ts 与契约测试共用。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* 解析顺序固定:宿主信任检查(0.9.0 起委派官方 `connection.requestRejection`,
|
|
6
|
+
* 全 method 含 ping 与未知 method,任何 body/side effect 之前,fail-closed)→
|
|
7
|
+
* 只接受 POST → JSON Content-Type → 有上限读取并 drain body →
|
|
8
|
+
* 顶层必须是非 null/非数组对象且有 method → typed method/业务错误映射 → 其他 500。
|
|
8
9
|
*/
|
|
9
10
|
import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
|
|
10
11
|
import { resolveDshVersion } from './dsh-version.js';
|
|
@@ -16,9 +17,11 @@ import { getCommunitySummary } from './community.js';
|
|
|
16
17
|
import { RegistryConfigError } from './registry-controller.js';
|
|
17
18
|
import { checkRegistryEntries } from './registry-check.js';
|
|
18
19
|
import { CATEGORIES } from './registry.js';
|
|
19
|
-
import { servingPort, scheduleRestart
|
|
20
|
+
import { servingPort, scheduleRestart } from './restart.js';
|
|
20
21
|
import { togglePlugin, ToggleError } from './toggle.js';
|
|
21
22
|
import { IncompatibleError } from './compat-check.js';
|
|
23
|
+
import { ProfileUnsupportedError, assertWriteAllowed } from './active-profile.js';
|
|
24
|
+
import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle } from './profile-ops.js';
|
|
22
25
|
export class BadJsonError extends Error {
|
|
23
26
|
}
|
|
24
27
|
export class BodyTooLargeError extends Error {
|
|
@@ -66,7 +69,20 @@ function readBody(req, maxBytes = BODY_MAX_BYTES) {
|
|
|
66
69
|
});
|
|
67
70
|
});
|
|
68
71
|
}
|
|
69
|
-
async function parseRequest(req) {
|
|
72
|
+
async function parseRequest(req, rejectRequest) {
|
|
73
|
+
// 宿主信任检查第一位(0.9.0 委派官方 requestRejection):先于 POST/Content-Type/body——
|
|
74
|
+
// 被拒请求零 body 消耗、零业务调用(报告 §3.2/§8)。返回 401/403 原样投影。
|
|
75
|
+
let rejection;
|
|
76
|
+
try {
|
|
77
|
+
rejection = rejectRequest(req);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// 宿主检查异常一律 fail-closed,异常细节不外泄(报告 §3.2)
|
|
81
|
+
rejection = 403;
|
|
82
|
+
}
|
|
83
|
+
if (rejection !== undefined) {
|
|
84
|
+
throw new ApiProtocolError(rejection, rejection === 401 ? '请求未通过宿主认证(未登录或凭据无效)' : '请求未通过宿主信任检查(不可信 Host/Origin 或跨站)');
|
|
85
|
+
}
|
|
70
86
|
if ((req.method || 'GET').toUpperCase() !== 'POST') {
|
|
71
87
|
throw new ApiProtocolError(405, '只接受 POST');
|
|
72
88
|
}
|
|
@@ -91,9 +107,6 @@ async function parseRequest(req) {
|
|
|
91
107
|
const method = typeof body.method === 'string' ? body.method.trim() : '';
|
|
92
108
|
if (!method)
|
|
93
109
|
throw new BadJsonError('缺少 method');
|
|
94
|
-
if (method !== 'ping' && !trustedRestartRequest(req)) {
|
|
95
|
-
throw new ApiProtocolError(403, '拒绝跨源请求');
|
|
96
|
-
}
|
|
97
110
|
return { method, body };
|
|
98
111
|
}
|
|
99
112
|
function strArg(body, key) {
|
|
@@ -139,6 +152,14 @@ function errorStatus(err) {
|
|
|
139
152
|
}
|
|
140
153
|
if (err instanceof ApiProtocolError)
|
|
141
154
|
return { status: err.status, payload: { ok: false, error: err.message } };
|
|
155
|
+
if (err instanceof ProfileUnsupportedError) {
|
|
156
|
+
// 0.9.0:Desktop/未知 profile 的能力表拒绝——结构化 code + 官方入口指引
|
|
157
|
+
return { status: 409, payload: { ok: false, error: err.message, code: err.code, action: err.action, profile: err.profile, guidance: err.guidance } };
|
|
158
|
+
}
|
|
159
|
+
if (err instanceof DesktopOpsError) {
|
|
160
|
+
// 0.9.0:Desktop adapter 失败(no-manager/install-refused/enable-failed/verify-failed)
|
|
161
|
+
return { status: 409, payload: { ok: false, error: err.message, code: err.code } };
|
|
162
|
+
}
|
|
142
163
|
if (err instanceof ToggleError) {
|
|
143
164
|
const status = err.code === 'protected' ? 403 : err.code === 'not-installed' ? 404 : 409;
|
|
144
165
|
return { status, payload: { ok: false, error: err.message, code: err.code } };
|
|
@@ -200,8 +221,14 @@ export function createApiDispatcher(ctx) {
|
|
|
200
221
|
resolveDshVersion: ctx.deps?.resolveDshVersion ?? resolveDshVersion,
|
|
201
222
|
getService: ctx.deps?.getService,
|
|
202
223
|
toggle: ctx.deps?.togglePlugin ?? togglePlugin,
|
|
224
|
+
desktopInstall: ctx.deps?.desktopInstall ?? desktopInstallFromRegistry,
|
|
225
|
+
desktopToggle: ctx.deps?.desktopToggle ?? desktopToggle,
|
|
203
226
|
};
|
|
204
227
|
const cfg = () => ctx.controller.config;
|
|
228
|
+
// 0.9.0:宿主信任检查委派(含 ping 与未知 method,fail-closed);
|
|
229
|
+
// profile 为 host 生命周期内不可变对象(host.ts apply 期解析一次)
|
|
230
|
+
const rejectRequest = ctx.deps?.rejectRequest ?? (() => 403);
|
|
231
|
+
const profile = ctx.profile;
|
|
205
232
|
// DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
|
|
206
233
|
const dshVersionP = Promise.resolve()
|
|
207
234
|
.then(() => d.resolveDshVersion())
|
|
@@ -210,7 +237,7 @@ export function createApiDispatcher(ctx) {
|
|
|
210
237
|
const abort = new AbortController();
|
|
211
238
|
res.once('close', () => abort.abort());
|
|
212
239
|
try {
|
|
213
|
-
const { method, body } = await parseRequest(req);
|
|
240
|
+
const { method, body } = await parseRequest(req, rejectRequest);
|
|
214
241
|
const signal = abort.signal;
|
|
215
242
|
let payload;
|
|
216
243
|
switch (method) {
|
|
@@ -222,6 +249,8 @@ export function createApiDispatcher(ctx) {
|
|
|
222
249
|
boot: BOOT_ID,
|
|
223
250
|
// DSH 运行版本(头部 chip 数据源);解析失败 → undefined → JSON 序列化时字段缺席
|
|
224
251
|
dshVersion: (await dshVersionP) ?? undefined,
|
|
252
|
+
// 0.9.0 双 profile:GUI/工具展示当前 profile 与能力边界
|
|
253
|
+
profile: { name: profile.name, kind: profile.kind, source: profile.source },
|
|
225
254
|
};
|
|
226
255
|
break;
|
|
227
256
|
case 'self-check': {
|
|
@@ -238,6 +267,8 @@ export function createApiDispatcher(ctx) {
|
|
|
238
267
|
break;
|
|
239
268
|
}
|
|
240
269
|
case 'self-upgrade': {
|
|
270
|
+
// 0.9.0:Desktop 结构化拒绝(能力表),Web 走既有路径
|
|
271
|
+
assertWriteAllowed(profile, 'self-upgrade');
|
|
241
272
|
// M2 Task 3:self-upgrade 收编 market.selfUpgrade(统一 mutation session + 守卫,直调事务旁路封死)
|
|
242
273
|
const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal });
|
|
243
274
|
payload = {
|
|
@@ -327,7 +358,8 @@ export function createApiDispatcher(ctx) {
|
|
|
327
358
|
const target = strArg(body, 'pkg');
|
|
328
359
|
if (!target)
|
|
329
360
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
330
|
-
|
|
361
|
+
// 0.9.0 双 profile:README 只读当前 profile 的 node_modules
|
|
362
|
+
const result = await readInstalledPluginReadme(target, profile.dir);
|
|
331
363
|
payload = { ...result };
|
|
332
364
|
break;
|
|
333
365
|
}
|
|
@@ -338,6 +370,18 @@ export function createApiDispatcher(ctx) {
|
|
|
338
370
|
const id = strArg(body, 'id');
|
|
339
371
|
if (!id)
|
|
340
372
|
throw new ApiProtocolError(400, '缺少 id');
|
|
373
|
+
// 0.9.0:desktop → 官方 pluginManager 委派(新包);web → 既有事务
|
|
374
|
+
if (profile.kind !== 'web') {
|
|
375
|
+
assertWriteAllowed(profile, 'install');
|
|
376
|
+
payload = { ...(await d.desktopInstall(id, cfg(), {
|
|
377
|
+
version: typeof body.version === 'string' ? body.version : undefined,
|
|
378
|
+
forceIncompatible: boolArg(body.forceIncompatible),
|
|
379
|
+
namespace: 'host',
|
|
380
|
+
profile: profile.name,
|
|
381
|
+
signal,
|
|
382
|
+
})) };
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
341
385
|
const version = typeof body.version === 'string' ? body.version : undefined;
|
|
342
386
|
const result = await d.installFromRegistry(id, cfg(), {
|
|
343
387
|
version,
|
|
@@ -352,6 +396,8 @@ export function createApiDispatcher(ctx) {
|
|
|
352
396
|
const target = strArg(body, 'pkg');
|
|
353
397
|
if (!target)
|
|
354
398
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
399
|
+
// 0.9.0:Desktop 结构化拒绝(官方入口指引)
|
|
400
|
+
assertWriteAllowed(profile, 'uninstall');
|
|
355
401
|
const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
|
|
356
402
|
payload = { ...result };
|
|
357
403
|
break;
|
|
@@ -362,6 +408,12 @@ export function createApiDispatcher(ctx) {
|
|
|
362
408
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
363
409
|
if (typeof body.enabled !== 'boolean')
|
|
364
410
|
throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
|
|
411
|
+
// 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝,禁 fallback 文件写)
|
|
412
|
+
if (profile.kind !== 'web') {
|
|
413
|
+
assertWriteAllowed(profile, 'set-enabled');
|
|
414
|
+
payload = { ...(await d.desktopToggle(target, body.enabled, { getService: d.getService, profileDir: profile.dir })) };
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
365
417
|
const result = await d.toggle(target, body.enabled, { getService: d.getService });
|
|
366
418
|
payload = { ...result };
|
|
367
419
|
break;
|
|
@@ -370,6 +422,8 @@ export function createApiDispatcher(ctx) {
|
|
|
370
422
|
const target = strArg(body, 'pkg');
|
|
371
423
|
if (!target)
|
|
372
424
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
425
|
+
// 0.9.0:Desktop 结构化拒绝(无官方 upgrade API,指引 Settings → Plugins)
|
|
426
|
+
assertWriteAllowed(profile, 'upgrade');
|
|
373
427
|
const result = await d.upgradePlugin(target, cfg(), {
|
|
374
428
|
forceIncompatible: boolArg(body.forceIncompatible),
|
|
375
429
|
namespace: 'host',
|
|
@@ -379,6 +433,8 @@ export function createApiDispatcher(ctx) {
|
|
|
379
433
|
break;
|
|
380
434
|
}
|
|
381
435
|
case 'restart': {
|
|
436
|
+
// 0.9.0:Desktop 不触发旧 helper(Electron 生命周期归官方;杀子进程=红线)
|
|
437
|
+
assertWriteAllowed(profile, 'restart');
|
|
382
438
|
const result = d.scheduleRestart(servingPort(req));
|
|
383
439
|
payload = { ...result };
|
|
384
440
|
break;
|
package/lib/core/market.js
CHANGED
|
@@ -447,11 +447,11 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
447
447
|
const maxLimit = withLatest ? WITH_LATEST_MAX : METADATA_ONLY_MAX;
|
|
448
448
|
const signal = opts.signal;
|
|
449
449
|
const remaining = () => deadlineAt - Date.now();
|
|
450
|
-
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
|
|
451
|
-
const installedTask = d.listInstalledPlugins().catch(() => null);
|
|
450
|
+
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs, profile: opts.profile });
|
|
451
|
+
const installedTask = d.listInstalledPlugins(opts.profileDir).catch(() => null);
|
|
452
452
|
// 社区 flight 并发启动(source=primary 零调用;0.7.0 Task 7:primaryOnly 字段已删除);共享 loader 不接收调用者 deadline——
|
|
453
453
|
// listMarket 作为 waiter 在 communityOutcome 内 race 自己的剩余 deadline/signal(v10 契约)
|
|
454
|
-
const communityTask = source === 'primary' ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
|
|
454
|
+
const communityTask = source === 'primary' ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force, profile: opts.profile });
|
|
455
455
|
let loaded;
|
|
456
456
|
try {
|
|
457
457
|
loaded = await deadlineRace(registryTask, remaining());
|
|
@@ -606,10 +606,10 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
606
606
|
const namespace = opts.namespace ?? 'host';
|
|
607
607
|
const signal = opts.signal;
|
|
608
608
|
const remaining = () => deadlineAt - Date.now();
|
|
609
|
-
const installedPromise = d.listInstalledPlugins();
|
|
610
|
-
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
|
|
609
|
+
const installedPromise = d.listInstalledPlugins(opts.profileDir);
|
|
610
|
+
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs, profile: opts.profile });
|
|
611
611
|
// 社区 flight 并发启动 + request-scoped GitHub 预算(本次检查 ≤25 个 wire 请求、宿主滚动 50/h)
|
|
612
|
-
const communityTask = d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
|
|
612
|
+
const communityTask = d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force, profile: opts.profile });
|
|
613
613
|
const githubBudget = createGithubRequestBudget();
|
|
614
614
|
const installed = await installedPromise;
|
|
615
615
|
// enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
|
|
@@ -718,8 +718,12 @@ async function probeLatest(items, ctx, d, rt) {
|
|
|
718
718
|
* 0.7.1 修复:合并市场展示的社区条目 id 不在主清单(loadRegistry 只装载主清单,
|
|
719
719
|
* 社区层只合入 listMarket 展示)——主清单 miss 时按收录 id 查社区目录再装,
|
|
720
720
|
* 与 0.5.1 升级路径(findCommunityUpgradeEntry)同构。 */
|
|
721
|
-
|
|
722
|
-
|
|
721
|
+
/**
|
|
722
|
+
* 按收录 id 解析可安装条目(0.9.0 Task 5 抽出):主清单优先,社区目录兜底。
|
|
723
|
+
* installFromRegistry 与 Desktop adapter(profile-ops)共用同一解析与错误文案。
|
|
724
|
+
*/
|
|
725
|
+
export async function resolveRegistryEntry(id, cfg = {}, opts = {}, deps) {
|
|
726
|
+
const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host', profile: opts.profile });
|
|
723
727
|
if (loaded.status === 'unavailable') {
|
|
724
728
|
throw new Error(`收录清单不可用,无法安装 ${id};请检查 registry 配置或网络后重试`);
|
|
725
729
|
}
|
|
@@ -727,6 +731,10 @@ export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
|
|
|
727
731
|
(await findCommunityInstallEntry(id, cfg, opts, deps));
|
|
728
732
|
if (!entry)
|
|
729
733
|
throw new Error(`registry 中没有该条目: ${id}`);
|
|
734
|
+
return entry;
|
|
735
|
+
}
|
|
736
|
+
export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
|
|
737
|
+
const entry = await resolveRegistryEntry(id, cfg, opts, deps);
|
|
730
738
|
return installEntry(entry, cfg, opts, deps);
|
|
731
739
|
}
|
|
732
740
|
// ---------- M2 Task 3:统一 mutation session + 装后守卫 + 补偿接线 ----------
|
|
@@ -1025,7 +1033,7 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
1025
1033
|
* upgradePlugin 已持有 session,必须直调本函数——二次获取 session 会自死锁(session 非重入)。 */
|
|
1026
1034
|
async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
|
|
1027
1035
|
const timeoutMs = cfg.timeoutMs ?? 20_000;
|
|
1028
|
-
const profileDir = deps?.transaction?.profileDir ?? webProfileDir();
|
|
1036
|
+
const profileDir = deps?.transaction?.profileDir ?? deps?.profileDir ?? webProfileDir();
|
|
1029
1037
|
const d = {
|
|
1030
1038
|
npmLatest: deps?.npmLatest ?? defaultNpmLatest,
|
|
1031
1039
|
npmVersion: deps?.npmVersion ?? npmVersion,
|
|
@@ -1267,11 +1275,11 @@ export function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
|
|
|
1267
1275
|
return withMutationSession(() => upgradePluginLocked(pkg, cfg, opts, deps));
|
|
1268
1276
|
}
|
|
1269
1277
|
async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
|
|
1270
|
-
const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
|
|
1278
|
+
const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host', profile: opts.profile });
|
|
1271
1279
|
if (loaded.status === 'unavailable') {
|
|
1272
1280
|
throw new Error(`收录清单不可用,无法升级 ${pkg};请检查 registry 配置或网络后重试`);
|
|
1273
1281
|
}
|
|
1274
|
-
const { items: installed } = await (deps?.listInstalledPlugins ?? defaultListInstalledPlugins)();
|
|
1282
|
+
const { items: installed } = await (deps?.listInstalledPlugins ?? defaultListInstalledPlugins)(opts.profileDir);
|
|
1275
1283
|
const target = installed.find((it) => it.pkg === pkg);
|
|
1276
1284
|
if (!target)
|
|
1277
1285
|
throw new Error(`web profile 未安装该插件: ${pkg}`);
|
|
@@ -1293,7 +1301,7 @@ async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
|
|
|
1293
1301
|
*/
|
|
1294
1302
|
async function findCommunityInstallEntry(id, cfg, opts, deps) {
|
|
1295
1303
|
try {
|
|
1296
|
-
const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal });
|
|
1304
|
+
const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal, profile: opts.profile });
|
|
1297
1305
|
if (!loaded.catalog)
|
|
1298
1306
|
return undefined;
|
|
1299
1307
|
return adaptCommunityCatalog(loaded.catalog).entries.find((e) => e.id === id);
|
|
@@ -1308,7 +1316,7 @@ async function findCommunityInstallEntry(id, cfg, opts, deps) {
|
|
|
1308
1316
|
*/
|
|
1309
1317
|
async function findCommunityUpgradeEntry(target, cfg, opts, deps) {
|
|
1310
1318
|
try {
|
|
1311
|
-
const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal });
|
|
1319
|
+
const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal, profile: opts.profile });
|
|
1312
1320
|
if (!loaded.catalog)
|
|
1313
1321
|
return undefined;
|
|
1314
1322
|
const adapted = adaptCommunityCatalog(loaded.catalog);
|
package/lib/core/patch-yaml.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* cordis 补丁层 YAML 的三个纯函数(0.4.0 开关功能,DESIGN/plan Task 2):
|
|
3
3
|
*
|
|
4
4
|
* 1) readBundlePatchRows —— 包内 `cordis.patch.yml`(dsh.bundle.patch 层)行枚举,
|
|
5
|
-
* 供「单行/多行插件」粒度判定(
|
|
5
|
+
* 供「单行/多行插件」粒度判定(GLOSSARY.md:行覆盖 vs Bundle 选择);
|
|
6
6
|
* 2) planRowOverride —— 官方 `writePluginEnabled`(dsh-plugin-manager L881-916)的纯函数版:
|
|
7
7
|
* 在 profile `cordis.patch.yml` 文本上做保注释的 `disabled` 覆盖编辑(findLast 按 id 匹配、
|
|
8
8
|
* 跳过含 insert 的行、name 可选限定、已是目标态 changed:false、无匹配 append `{id, disabled}`;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileOperations 接缝的 Desktop adapter(0.9.0,ADR-0005)。
|
|
3
|
+
*
|
|
4
|
+
* Desktop profile 的包操作一律委派官方 pluginManager(`ctx.get('pluginManager')`,
|
|
5
|
+
* 鸭子类型投影,无静态依赖):`installBundle(spec, { enabled?, approvedBuilds? })`。
|
|
6
|
+
* dsh-m 保留自己的收录条目解析(resolveRegistryEntry)与 peer 兼容预检
|
|
7
|
+
* (precheckNpmCompat,web 同语义);完整性/锁/构建脚本审批/生效相位归官方管理器。
|
|
8
|
+
*
|
|
9
|
+
* 判定纪律(报告 §5.2,dsh-market #703/#772 实证):
|
|
10
|
+
* - 以 `application`/`stage` 为准,绝不只看 `packageResult.exitCode`——
|
|
11
|
+
* `stage:'enable'` + `application:'failed'` 可跟在 exitCode 0 之后;
|
|
12
|
+
* - `application:'overridden'` 是「成功但被更高层 patch 覆盖」,如实标注非失败;
|
|
13
|
+
* - 构建脚本:`build-blocked` + `pendingBuilds` → 结构化 needsBuildApproval 结果,
|
|
14
|
+
* 重试把 pending 名单原样传回 `approvedBuilds`(键格式由官方管理器自己写),
|
|
15
|
+
* dsh-m 绝不直接编辑 desktop profile 的 pnpm-workspace.yaml,绝不全量放行;
|
|
16
|
+
* - 成功判定 = 官方结果无 error + `listBundles()` 复读目标 bundle 在装,二者同时满足。
|
|
17
|
+
*
|
|
18
|
+
* 禁止事项:desktop 路径零 Web fallback——不做文件级装后守卫(app.asar 探测盲区)、
|
|
19
|
+
* 不写 allowBuilds、不跑 dsh-m 自实现 pnpm 编排、服务缺席一律结构化拒绝。
|
|
20
|
+
*/
|
|
21
|
+
import { withMutationSession, resolveRegistryEntry } from './market.js';
|
|
22
|
+
import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
|
|
23
|
+
import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTag } from './versions.js';
|
|
24
|
+
import { togglePlugin, ToggleError } from './toggle.js';
|
|
25
|
+
export class DesktopOpsError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'DesktopOpsError';
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function changeOf(raw) {
|
|
34
|
+
if (raw && typeof raw === 'object')
|
|
35
|
+
return raw;
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Desktop 安装(仅新包):收录条目解析(web 同源)→ spec 精确锁定 → compat 预检
|
|
40
|
+
* (npm 源;web 同语义)→ 官方 installBundle → ChangeResult 判定 → listBundles 复读。
|
|
41
|
+
* 全程 within mutation session(与 web 五入口同一 FIFO)。
|
|
42
|
+
*/
|
|
43
|
+
export async function desktopInstallFromRegistry(id, cfg = {}, opts = {}, deps = {}) {
|
|
44
|
+
return withMutationSession(() => desktopInstallLocked(id, cfg, opts, deps));
|
|
45
|
+
}
|
|
46
|
+
async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
47
|
+
const service = deps.getService?.();
|
|
48
|
+
if (!service || typeof service.installBundle !== 'function') {
|
|
49
|
+
throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 必须由官方管理器执行包操作);拒绝安装(fail-closed)');
|
|
50
|
+
}
|
|
51
|
+
const timeoutMs = cfg.timeoutMs ?? 20_000;
|
|
52
|
+
const entry = await (deps.resolveEntry ?? resolveRegistryEntry)(id, cfg, opts, { loadRegistry: deps.loadRegistry });
|
|
53
|
+
let spec;
|
|
54
|
+
let pkg;
|
|
55
|
+
let version;
|
|
56
|
+
let sha;
|
|
57
|
+
let tag;
|
|
58
|
+
let compat = null;
|
|
59
|
+
let compatSkipped;
|
|
60
|
+
if (entry.source === 'npm' && entry.npm) {
|
|
61
|
+
pkg = entry.npm;
|
|
62
|
+
if (opts.version) {
|
|
63
|
+
version = opts.version;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const latest = await (deps.npmLatest ?? defaultNpmLatest)(pkg, timeoutMs, opts.signal);
|
|
67
|
+
version = latest.version;
|
|
68
|
+
}
|
|
69
|
+
spec = `${pkg}@${version}`;
|
|
70
|
+
// peer 兼容预检(web 同语义):预检自身失败视为未检,不拦安装
|
|
71
|
+
try {
|
|
72
|
+
compat = await (deps.precheck ?? precheckNpmCompat)(pkg, version, { timeoutMs, signal: opts.signal });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
compat = null;
|
|
76
|
+
}
|
|
77
|
+
if (compat !== null && opts.forceIncompatible !== true)
|
|
78
|
+
throw new IncompatibleError(compat);
|
|
79
|
+
}
|
|
80
|
+
else if (entry.github) {
|
|
81
|
+
// github 源:锁 HEAD SHA(web 同语义),不做兼容预检(明示 compatSkipped)
|
|
82
|
+
const resolved = await (deps.githubLatestTag ?? defaultGithubLatestTag)(entry.github, timeoutMs, opts.signal);
|
|
83
|
+
sha = resolved.sha;
|
|
84
|
+
tag = resolved.tag;
|
|
85
|
+
pkg = entry.github.split('/')[1] ?? entry.github;
|
|
86
|
+
spec = `github:${entry.github}#${sha}`;
|
|
87
|
+
compatSkipped = 'github-source';
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
|
|
91
|
+
}
|
|
92
|
+
const raw = await service.installBundle(spec, {
|
|
93
|
+
enabled: true,
|
|
94
|
+
...(opts.approvedBuilds ? { approvedBuilds: opts.approvedBuilds } : {}),
|
|
95
|
+
});
|
|
96
|
+
const change = changeOf(raw);
|
|
97
|
+
if (change.application === 'cancelled') {
|
|
98
|
+
throw new DesktopOpsError('install-refused', `安装已被取消(${pkg}):desktop profile 文件已由官方管理器恢复,未安装`);
|
|
99
|
+
}
|
|
100
|
+
if (change.application === 'failed' || change.error) {
|
|
101
|
+
const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
|
|
102
|
+
const buildBlocked = change.packageResult?.kind === 'build-blocked';
|
|
103
|
+
if (buildBlocked && pending.length > 0) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
needsBuildApproval: true,
|
|
107
|
+
id: entry.id,
|
|
108
|
+
pkg,
|
|
109
|
+
spec,
|
|
110
|
+
pendingBuilds: pending,
|
|
111
|
+
message: `该插件需要执行构建脚本(${pending.join('、')})。确认后带同样的包名名单重试(approvedBuilds);dsh-m 不做全量放行(ADR-0002 同语义)`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (change.stage === 'enable') {
|
|
115
|
+
throw new DesktopOpsError('enable-failed', `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):包已写入但未能启用,请在官方 Desktop 插件页查看状态或重试`);
|
|
116
|
+
}
|
|
117
|
+
throw new DesktopOpsError('install-refused', `官方管理器安装失败(${pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
|
|
118
|
+
}
|
|
119
|
+
// 复读校验:listBundles 可用必须见目标在装;不可用(老管理器)至少要求官方宣称产出 bundle
|
|
120
|
+
const bundleName = change.bundle ?? pkg;
|
|
121
|
+
if (typeof service.listBundles === 'function') {
|
|
122
|
+
const bundles = await service.listBundles();
|
|
123
|
+
const found = (bundles ?? []).find((b) => b && b.name === bundleName);
|
|
124
|
+
if (!found || found.installed === false) {
|
|
125
|
+
throw new DesktopOpsError('verify-failed', `安装复读失败(${pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 未見目标 bundle 在装——不冒充安装成功`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (!change.bundle) {
|
|
129
|
+
throw new DesktopOpsError('verify-failed', `安装复读不可用(${pkg}):官方结果未携带 bundle 字段且 listBundles 缺席——不冒充安装成功`);
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
id: entry.id,
|
|
133
|
+
pkg,
|
|
134
|
+
spec,
|
|
135
|
+
...(version !== undefined ? { version } : {}),
|
|
136
|
+
...(sha !== undefined ? { sha } : {}),
|
|
137
|
+
...(tag !== undefined ? { tag } : {}),
|
|
138
|
+
buildApprovals: Array.isArray(change.approvedBuilds) ? change.approvedBuilds : [],
|
|
139
|
+
fallbackAllBuilds: false,
|
|
140
|
+
...(compat !== null ? { compat } : {}),
|
|
141
|
+
...(compatSkipped ? { compatSkipped } : {}),
|
|
142
|
+
needsRestart: true,
|
|
143
|
+
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
|
|
144
|
+
via: 'desktop-manager',
|
|
145
|
+
...(change.application === 'overridden' ? { overridden: true } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Desktop 开关:服务缺席 → 结构化拒绝(ToggleError/unaddressable,禁 fallback 文件写);
|
|
150
|
+
* 服务在用 → 既有 togglePlugin 委派路径(installed 校验按 profileDir,与 web 委派同代码)。
|
|
151
|
+
*/
|
|
152
|
+
export async function desktopToggle(pkg, enabled, deps = {}) {
|
|
153
|
+
const service = deps.getService?.();
|
|
154
|
+
if (!service) {
|
|
155
|
+
throw new ToggleError('unaddressable', '官方 pluginManager 服务不可用:desktop profile 的开关必须走官方管理器(dsh-m 不做文件级 fallback);请在官方 Desktop 插件页操作');
|
|
156
|
+
}
|
|
157
|
+
return togglePlugin(pkg, enabled, { getService: () => service, profileDir: deps.profileDir });
|
|
158
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
-
import {
|
|
11
|
+
import { WEB_PROFILE, cacheRoot } from './env.js';
|
|
12
12
|
import { commitActiveSource, loadDefaultRegistry, loadRegistry, loadRegistryCandidate, parseRegistryAddress, } from './registry.js';
|
|
13
13
|
export class RegistryConfigError extends Error {
|
|
14
14
|
errors;
|
|
@@ -19,10 +19,10 @@ export class RegistryConfigError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
const ACCEPTED_SOURCE_FILE = 'active-source.json';
|
|
22
|
-
/** 读取 host accepted-source metadata(损坏/缺失返回 null)。 */
|
|
23
|
-
export async function readAcceptedSourceMetadata() {
|
|
22
|
+
/** 读取 host accepted-source metadata(损坏/缺失返回 null;profile 决定缓存段,0.9.0 双 profile)。 */
|
|
23
|
+
export async function readAcceptedSourceMetadata(profile = WEB_PROFILE) {
|
|
24
24
|
try {
|
|
25
|
-
const raw = JSON.parse(await readFile(join(
|
|
25
|
+
const raw = JSON.parse(await readFile(join(cacheRoot(profile), 'host', ACCEPTED_SOURCE_FILE), 'utf8'));
|
|
26
26
|
if (!raw || typeof raw !== 'object')
|
|
27
27
|
return null;
|
|
28
28
|
if (raw.version !== 1 || raw.namespace !== 'host')
|
|
@@ -57,9 +57,11 @@ function placeholderLoaded(configuredAddress) {
|
|
|
57
57
|
function errorMessage(err) {
|
|
58
58
|
return err instanceof Error ? err.message : String(err);
|
|
59
59
|
}
|
|
60
|
-
export function createRegistryController(initial = {}) {
|
|
60
|
+
export function createRegistryController(initial = {}, opts = {}) {
|
|
61
61
|
// config 是共享对象:host.ts 把它传给 tools/API,controller 原地更新字段实现 live 生效
|
|
62
62
|
const config = { ...initial };
|
|
63
|
+
// profile 缓存段(0.9.0 双 profile):默认 web=旧路径,行为零漂移;desktop 落 `<root>/desktop` 段
|
|
64
|
+
const profile = opts.profile ?? WEB_PROFILE;
|
|
63
65
|
let activeConfigAddress = trimAddress(initial.registryUrl);
|
|
64
66
|
let loaded = placeholderLoaded(activeConfigAddress);
|
|
65
67
|
let pendingAddress = null;
|
|
@@ -120,7 +122,7 @@ export function createRegistryController(initial = {}) {
|
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
function candidateFor(rawAddress, signal) {
|
|
123
|
-
return loadRegistryCandidate({ ...config, registryUrl: rawAddress }, { namespace: 'host', signal });
|
|
125
|
+
return loadRegistryCandidate({ ...config, registryUrl: rawAddress }, { namespace: 'host', profile, signal });
|
|
124
126
|
}
|
|
125
127
|
const bootstrap = async () => {
|
|
126
128
|
await enqueue(async (gen) => {
|
|
@@ -130,7 +132,7 @@ export function createRegistryController(initial = {}) {
|
|
|
130
132
|
// 社区字段初始值(两代 store 同型:legacy/forms 的 get() 都经 unwrapConfig 透传)
|
|
131
133
|
syncCommunityFields(source);
|
|
132
134
|
const raw = trimAddress(source.registryUrl);
|
|
133
|
-
const attempt = await loadRegistry({ ...config, registryUrl: raw }, { namespace: 'host' });
|
|
135
|
+
const attempt = await loadRegistry({ ...config, registryUrl: raw }, { namespace: 'host', profile });
|
|
134
136
|
if (disposed || gen !== generation)
|
|
135
137
|
return;
|
|
136
138
|
if (attempt.status !== 'unavailable') {
|
|
@@ -139,9 +141,9 @@ export function createRegistryController(initial = {}) {
|
|
|
139
141
|
}
|
|
140
142
|
// 持久化 custom 不可达:先试 accepted address(含其 cache),无 accepted 则默认
|
|
141
143
|
configErrors = [...attempt.errors];
|
|
142
|
-
const accepted = await readAcceptedSourceMetadata();
|
|
144
|
+
const accepted = await readAcceptedSourceMetadata(profile);
|
|
143
145
|
const recoverAddress = accepted && accepted.configuredAddress !== raw ? accepted.configuredAddress : '';
|
|
144
|
-
const recovered = await loadRegistry({ ...config, registryUrl: recoverAddress }, { namespace: 'host', force: true });
|
|
146
|
+
const recovered = await loadRegistry({ ...config, registryUrl: recoverAddress }, { namespace: 'host', force: true, profile });
|
|
145
147
|
if (disposed || gen !== generation)
|
|
146
148
|
return;
|
|
147
149
|
if (recovered.status !== 'unavailable') {
|
|
@@ -204,7 +206,7 @@ export function createRegistryController(initial = {}) {
|
|
|
204
206
|
config.timeoutMs = nextConfig.timeoutMs;
|
|
205
207
|
config.cacheTtlMin = nextConfig.cacheTtlMin;
|
|
206
208
|
try {
|
|
207
|
-
const commit = await commitActiveSource(parseRegistryAddress(raw), 'host');
|
|
209
|
+
const commit = await commitActiveSource(parseRegistryAddress(raw), 'host', profile);
|
|
208
210
|
if (commit.warning)
|
|
209
211
|
warnings.push(commit.warning);
|
|
210
212
|
}
|
|
@@ -240,7 +242,7 @@ export function createRegistryController(initial = {}) {
|
|
|
240
242
|
await startBootstrap();
|
|
241
243
|
if (opts.force) {
|
|
242
244
|
await enqueue(async (gen) => {
|
|
243
|
-
const attempt = await loadRegistry(config, { namespace: 'host', force: true, signal: opts.signal });
|
|
245
|
+
const attempt = await loadRegistry(config, { namespace: 'host', force: true, signal: opts.signal, profile });
|
|
244
246
|
if (disposed || gen !== generation)
|
|
245
247
|
return;
|
|
246
248
|
loaded = attempt;
|
|
@@ -250,7 +252,7 @@ export function createRegistryController(initial = {}) {
|
|
|
250
252
|
return snapshotInternal();
|
|
251
253
|
}
|
|
252
254
|
function loadDefault(opts = {}) {
|
|
253
|
-
return loadDefaultRegistry(config, { namespace: 'host', force: opts?.force, signal: opts?.signal });
|
|
255
|
+
return loadDefaultRegistry(config, { namespace: 'host', force: opts?.force, signal: opts?.signal, profile });
|
|
254
256
|
}
|
|
255
257
|
function apply(rawAddress, opts = {}) {
|
|
256
258
|
return enqueue(async (gen) => {
|
|
@@ -300,7 +302,7 @@ export function createRegistryController(initial = {}) {
|
|
|
300
302
|
}
|
|
301
303
|
adopt(gen, trimmed, candidate);
|
|
302
304
|
try {
|
|
303
|
-
const commit = await commitActiveSource(address, 'host');
|
|
305
|
+
const commit = await commitActiveSource(address, 'host', profile);
|
|
304
306
|
if (commit.warning)
|
|
305
307
|
warnings.push(commit.warning);
|
|
306
308
|
}
|