dsh-m 0.8.5 → 0.9.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 +24 -5
- package/README.md +24 -5
- package/docs/DESIGN.md +1 -1
- package/lib/cli.js +12 -0
- package/lib/client.js +18 -6
- 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/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
|
}
|
package/lib/core/registry.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createRequire } from 'node:module';
|
|
|
8
8
|
import { isAbsolute, join, normalize } from 'node:path';
|
|
9
9
|
import { constants as fsConstants, mkdirSync } from 'node:fs';
|
|
10
10
|
import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
11
|
-
import {
|
|
11
|
+
import { WEB_PROFILE, cacheRoot } from './env.js';
|
|
12
12
|
import { decodeUtf8Fatal, fetchJsonLimitedMeta, describeFetchFailure } from './httpx.js';
|
|
13
13
|
export const CATEGORIES = ['market', 'tools', 'ui', 'search', 'other'];
|
|
14
14
|
/** 容量上限:2 MiB 是原始 UTF-8 bytes(不是字符数);条目超限拒绝整份清单。 */
|
|
@@ -367,14 +367,14 @@ export async function readRegistryFile(path, hooks = {}) {
|
|
|
367
367
|
const CACHE_FILE_VERSION = 2;
|
|
368
368
|
const DEFAULT_CACHE_KEY = 'default';
|
|
369
369
|
const ACTIVE_SOURCE_FILE = 'active-source.json';
|
|
370
|
-
function nsDir(namespace) {
|
|
371
|
-
return join(
|
|
370
|
+
function nsDir(namespace, profile = WEB_PROFILE) {
|
|
371
|
+
return join(cacheRoot(profile), namespace);
|
|
372
372
|
}
|
|
373
|
-
function cacheFilePath(namespace, cacheKey) {
|
|
374
|
-
return join(nsDir(namespace), `${cacheKey}.json`);
|
|
373
|
+
function cacheFilePath(namespace, cacheKey, profile = WEB_PROFILE) {
|
|
374
|
+
return join(nsDir(namespace, profile), `${cacheKey}.json`);
|
|
375
375
|
}
|
|
376
|
-
function cacheDirSyncMode() {
|
|
377
|
-
mkdirSync(
|
|
376
|
+
function cacheDirSyncMode(profile = WEB_PROFILE) {
|
|
377
|
+
mkdirSync(cacheRoot(profile), { recursive: true, mode: 0o700 });
|
|
378
378
|
}
|
|
379
379
|
/** 同 key 写锁:进程内串行化同一 cache 文件的写入。 */
|
|
380
380
|
const writeLocks = new Map();
|
|
@@ -390,11 +390,11 @@ function withKeyLock(key, task) {
|
|
|
390
390
|
return next;
|
|
391
391
|
}
|
|
392
392
|
/** 原子写:0600 临时文件(O_CREAT|O_EXCL)→ fsync → rename。目标为 symlink 时拒绝。 */
|
|
393
|
-
async function atomicWriteJson(target, dir, value) {
|
|
393
|
+
async function atomicWriteJson(target, dir, value, profile = WEB_PROFILE) {
|
|
394
394
|
return withKeyLock(target, async () => {
|
|
395
395
|
try {
|
|
396
396
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
397
|
-
cacheDirSyncMode();
|
|
397
|
+
cacheDirSyncMode(profile);
|
|
398
398
|
let existing = null;
|
|
399
399
|
try {
|
|
400
400
|
existing = await lstat(target);
|
|
@@ -421,12 +421,12 @@ async function atomicWriteJson(target, dir, value) {
|
|
|
421
421
|
}
|
|
422
422
|
});
|
|
423
423
|
}
|
|
424
|
-
async function writeCacheFile(file) {
|
|
425
|
-
return atomicWriteJson(cacheFilePath(file.namespace, file.cacheKey), nsDir(file.namespace), file);
|
|
424
|
+
async function writeCacheFile(file, profile = WEB_PROFILE) {
|
|
425
|
+
return atomicWriteJson(cacheFilePath(file.namespace, file.cacheKey, profile), nsDir(file.namespace, profile), file, profile);
|
|
426
426
|
}
|
|
427
|
-
async function readCacheFile(namespace, cacheKey) {
|
|
427
|
+
async function readCacheFile(namespace, cacheKey, profile = WEB_PROFILE) {
|
|
428
428
|
try {
|
|
429
|
-
const raw = JSON.parse(await readFile(cacheFilePath(namespace, cacheKey), 'utf8'));
|
|
429
|
+
const raw = JSON.parse(await readFile(cacheFilePath(namespace, cacheKey, profile), 'utf8'));
|
|
430
430
|
if (!raw || typeof raw !== 'object')
|
|
431
431
|
return null;
|
|
432
432
|
if (raw.version !== CACHE_FILE_VERSION || raw.namespace !== namespace || raw.cacheKey !== cacheKey)
|
|
@@ -449,9 +449,9 @@ function cacheFresh(file, ttlMin) {
|
|
|
449
449
|
return Date.now() - t < ttlMin * 60_000;
|
|
450
450
|
}
|
|
451
451
|
/** 清理 namespace 内非 default、非当前 custom 的 cache 文件(不动 metadata)。 */
|
|
452
|
-
async function pruneCaches(namespace, keepCustomKey) {
|
|
452
|
+
async function pruneCaches(namespace, keepCustomKey, profile = WEB_PROFILE) {
|
|
453
453
|
try {
|
|
454
|
-
const dir = nsDir(namespace);
|
|
454
|
+
const dir = nsDir(namespace, profile);
|
|
455
455
|
const entries = await readdir(dir).catch(() => []);
|
|
456
456
|
const keep = new Set([`${DEFAULT_CACHE_KEY}.json`, ACTIVE_SOURCE_FILE]);
|
|
457
457
|
if (keepCustomKey)
|
|
@@ -474,7 +474,7 @@ async function pruneCaches(namespace, keepCustomKey) {
|
|
|
474
474
|
* 再 prune 非当前 custom cache。metadata 失败 → 不 prune;prune 失败 → 保留旧 cache;
|
|
475
475
|
* 都只返回 warning,不反向撤销已成功的 settings update。
|
|
476
476
|
*/
|
|
477
|
-
export async function commitActiveSource(address, namespace) {
|
|
477
|
+
export async function commitActiveSource(address, namespace, profile = WEB_PROFILE) {
|
|
478
478
|
let metadataCommitted = true;
|
|
479
479
|
if (namespace === 'host') {
|
|
480
480
|
const meta = {
|
|
@@ -484,12 +484,12 @@ export async function commitActiveSource(address, namespace) {
|
|
|
484
484
|
cacheKey: address.cacheKey,
|
|
485
485
|
savedAt: new Date().toISOString(),
|
|
486
486
|
};
|
|
487
|
-
metadataCommitted = await atomicWriteJson(join(nsDir('host'), ACTIVE_SOURCE_FILE), nsDir('host'), meta);
|
|
487
|
+
metadataCommitted = await atomicWriteJson(join(nsDir('host', profile), ACTIVE_SOURCE_FILE), nsDir('host', profile), meta, profile);
|
|
488
488
|
}
|
|
489
489
|
if (!metadataCommitted) {
|
|
490
490
|
return { metadataCommitted: false, pruned: false, warning: 'accepted-source 元数据写入失败,已跳过旧 cache 清理(新配置仍生效)' };
|
|
491
491
|
}
|
|
492
|
-
const pruned = await pruneCaches(namespace, address.kind === 'default' ? null : address.cacheKey);
|
|
492
|
+
const pruned = await pruneCaches(namespace, address.kind === 'default' ? null : address.cacheKey, profile);
|
|
493
493
|
if (!pruned) {
|
|
494
494
|
return { metadataCommitted: true, pruned: false, warning: '旧来源 cache 清理失败(不影响新配置生效)' };
|
|
495
495
|
}
|
|
@@ -588,7 +588,7 @@ async function loadDefaultChain(cfg, opts, chain) {
|
|
|
588
588
|
source: candidate.source,
|
|
589
589
|
fetchedAt,
|
|
590
590
|
registry,
|
|
591
|
-
});
|
|
591
|
+
}, chain.profile);
|
|
592
592
|
return {
|
|
593
593
|
...buildState({
|
|
594
594
|
configuredAddress: '',
|
|
@@ -606,7 +606,7 @@ async function loadDefaultChain(cfg, opts, chain) {
|
|
|
606
606
|
errors.push(describeFetchFailure({ label: candidate.source, url: candidate.url, err, elapsedMs: Date.now() - startedAt }));
|
|
607
607
|
}
|
|
608
608
|
}
|
|
609
|
-
const cached = await readCacheFile(chain.namespace, DEFAULT_CACHE_KEY);
|
|
609
|
+
const cached = await readCacheFile(chain.namespace, DEFAULT_CACHE_KEY, chain.profile);
|
|
610
610
|
if (cached)
|
|
611
611
|
return loadedFromCacheFile(cached, '', errors);
|
|
612
612
|
if (chain.includeBundled) {
|
|
@@ -656,7 +656,7 @@ async function loadCustomChain(address, cfg, opts, chain) {
|
|
|
656
656
|
source,
|
|
657
657
|
fetchedAt,
|
|
658
658
|
registry: result.registry,
|
|
659
|
-
});
|
|
659
|
+
}, chain.profile);
|
|
660
660
|
return {
|
|
661
661
|
...buildState({
|
|
662
662
|
configuredAddress: address.normalized,
|
|
@@ -674,7 +674,7 @@ async function loadCustomChain(address, cfg, opts, chain) {
|
|
|
674
674
|
errors.push(describeFetchFailure({ label: source, url: address.normalized, err, elapsedMs: Date.now() - startedAt }));
|
|
675
675
|
}
|
|
676
676
|
if (chain.allowCacheFallback) {
|
|
677
|
-
const cached = await readCacheFile(chain.namespace, address.cacheKey);
|
|
677
|
+
const cached = await readCacheFile(chain.namespace, address.cacheKey, chain.profile);
|
|
678
678
|
if (cached)
|
|
679
679
|
return loadedFromCacheFile(cached, address.normalized, errors);
|
|
680
680
|
}
|
|
@@ -694,42 +694,45 @@ async function loadCustomChain(address, cfg, opts, chain) {
|
|
|
694
694
|
/** active 读取:default/custom 各自 fallback;网络成功后可在本 namespace 内 prune。 */
|
|
695
695
|
export async function loadRegistry(cfg = {}, opts = {}) {
|
|
696
696
|
const namespace = opts.namespace ?? 'host';
|
|
697
|
+
const profile = opts.profile ?? WEB_PROFILE;
|
|
697
698
|
const address = parseRegistryAddress(cfg.registryUrl);
|
|
698
699
|
const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
|
|
699
700
|
if (!opts.force) {
|
|
700
701
|
const cacheKey = address.kind === 'default' ? DEFAULT_CACHE_KEY : address.cacheKey;
|
|
701
|
-
const cached = await readCacheFile(namespace, cacheKey);
|
|
702
|
+
const cached = await readCacheFile(namespace, cacheKey, profile);
|
|
702
703
|
if (cached && cacheFresh(cached, ttlMin))
|
|
703
704
|
return loadedFromCacheFile(cached, address.normalized, []);
|
|
704
705
|
}
|
|
705
706
|
if (address.kind === 'default') {
|
|
706
|
-
const loaded = await loadDefaultChain(cfg, opts, { namespace, includeBundled: true });
|
|
707
|
+
const loaded = await loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
|
|
707
708
|
if (opts.prune !== false && loaded.status === 'ready')
|
|
708
|
-
await pruneCaches(namespace, null);
|
|
709
|
+
await pruneCaches(namespace, null, profile);
|
|
709
710
|
return loaded;
|
|
710
711
|
}
|
|
711
|
-
const loaded = await loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true });
|
|
712
|
+
const loaded = await loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true, profile });
|
|
712
713
|
if (opts.prune !== false && loaded.status === 'ready')
|
|
713
|
-
await pruneCaches(namespace, address.cacheKey);
|
|
714
|
+
await pruneCaches(namespace, address.cacheKey, profile);
|
|
714
715
|
return loaded;
|
|
715
716
|
}
|
|
716
717
|
/** candidate 读取:apply 前校验专用。只写候选 cache、绝不 prune;default 不落 bundled。 */
|
|
717
718
|
export async function loadRegistryCandidate(cfg, opts = {}) {
|
|
718
719
|
const namespace = opts.namespace ?? 'host';
|
|
720
|
+
const profile = opts.profile ?? WEB_PROFILE;
|
|
719
721
|
const address = parseRegistryAddress(cfg.registryUrl);
|
|
720
722
|
if (address.kind === 'default') {
|
|
721
|
-
return loadDefaultChain(cfg, opts, { namespace, includeBundled: false });
|
|
723
|
+
return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: false });
|
|
722
724
|
}
|
|
723
|
-
return loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true });
|
|
725
|
+
return loadCustomChain(address, cfg, opts, { namespace, allowCacheFallback: true, profile });
|
|
724
726
|
}
|
|
725
727
|
/** default 显式加载(下载/主动刷新)。写 default cache,从不 prune。 */
|
|
726
728
|
export async function loadDefaultRegistry(cfg = {}, opts = {}) {
|
|
727
729
|
const namespace = opts.namespace ?? 'host';
|
|
730
|
+
const profile = opts.profile ?? WEB_PROFILE;
|
|
728
731
|
const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
|
|
729
732
|
if (!opts.force) {
|
|
730
|
-
const cached = await readCacheFile(namespace, DEFAULT_CACHE_KEY);
|
|
733
|
+
const cached = await readCacheFile(namespace, DEFAULT_CACHE_KEY, profile);
|
|
731
734
|
if (cached && cacheFresh(cached, ttlMin))
|
|
732
735
|
return loadedFromCacheFile(cached, '', []);
|
|
733
736
|
}
|
|
734
|
-
return loadDefaultChain(cfg, opts, { namespace, includeBundled: true });
|
|
737
|
+
return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
|
|
735
738
|
}
|
package/lib/core/restart.js
CHANGED
|
@@ -32,17 +32,6 @@ function isLoopbackHost(host) {
|
|
|
32
32
|
return false;
|
|
33
33
|
return parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1';
|
|
34
34
|
}
|
|
35
|
-
function hostsMatch(originHost, candidate) {
|
|
36
|
-
const a = parseHost(originHost);
|
|
37
|
-
const b = parseHost(candidate);
|
|
38
|
-
if (a === null || b === null)
|
|
39
|
-
return false;
|
|
40
|
-
if (a.hostname !== b.hostname)
|
|
41
|
-
return false;
|
|
42
|
-
if (a.port !== '' && b.port !== '' && a.port !== b.port)
|
|
43
|
-
return false;
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
35
|
export function servingPort(request) {
|
|
47
36
|
const host = headerString(request.headers.host);
|
|
48
37
|
if (host === undefined || !isLoopbackHost(host))
|
|
@@ -53,25 +42,9 @@ export function servingPort(request) {
|
|
|
53
42
|
const port = Number(match[1]);
|
|
54
43
|
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
55
44
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return false;
|
|
60
|
-
let from;
|
|
61
|
-
try {
|
|
62
|
-
const parsed = new URL(origin);
|
|
63
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
64
|
-
return false;
|
|
65
|
-
from = parsed.host;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
const host = headerString(request.headers.host);
|
|
71
|
-
const forwardedHost = headerString(request.headers['x-forwarded-host']);
|
|
72
|
-
const candidates = [host, forwardedHost].filter((value) => value !== undefined);
|
|
73
|
-
return candidates.some((candidate) => hostsMatch(from, candidate));
|
|
74
|
-
}
|
|
45
|
+
// 0.9.0 起trustedRestartRequest 已删除:/dshm 入口信任检查改委派官方
|
|
46
|
+
// `connection.requestRejection()`(ADR-0005,语义含 trustedHosts/cross-site/Origin:null),
|
|
47
|
+
// 本模块只剩 servingPort / 重启实现。
|
|
75
48
|
export function readProcCgroup(readFile = (path, encoding) => readFileSync(path, encoding)) {
|
|
76
49
|
try {
|
|
77
50
|
return readFile('/proc/self/cgroup', 'utf8');
|