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.
@@ -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
- export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
722
- const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
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);
@@ -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
- * 供「单行/多行插件」粒度判定(CONTEXT.md:行覆盖 vs Bundle 选择);
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 { cacheDir } from './env.js';
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(cacheDir(), 'host', ACCEPTED_SOURCE_FILE), 'utf8'));
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
  }
@@ -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 { cacheDir } from './env.js';
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(cacheDir(), namespace);
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(cacheDir(), { recursive: true, mode: 0o700 });
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
  }
@@ -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
- export function trustedRestartRequest(request) {
57
- const origin = headerString(request.headers.origin);
58
- if (origin === undefined)
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');