@logictan/dsh-config-manager 0.1.60 → 0.1.61

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.
@@ -4,10 +4,16 @@
4
4
  */
5
5
  import test from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { PluginsAdapter, USER_PATCH_FILE } from './plugins.ts';
7
+ import { PluginsAdapter } from './plugins.ts';
8
8
  import { makeContext, makeImportContext } from './test-helpers.ts';
9
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey } from '../core/patch-layers.ts';
9
10
  import type { PlanItem } from '../core/types.ts';
10
11
 
12
+ /** 计划项 id:层限定复合键的投影(`patch:<file>#<lineId>`)。 */
13
+ function patchItemId(file: string, lineId: string): string {
14
+ return `patch:${patchLayerKey(file, lineId)}`;
15
+ }
16
+
11
17
  test('issue #35:patch 文件随分区迁移;目标缺失的 patchedDependencies 声明导入时剔除', async () => {
12
18
  const ws = [
13
19
  'allowBuilds:',
@@ -167,7 +173,9 @@ test('plugins: 导出清单与 patch 行', async () => {
167
173
  ctx.plugins.installed.set('@linxin666/dsh-ssh', { name: '@linxin666/dsh-ssh', version: '0.1.12', enabled: true, isBundle: true, inBundles: ['@linxin666/dsh-web-ui-all'] });
168
174
  ctx.plugins.installed.set('dsh-memory-evolve', { name: 'dsh-memory-evolve', version: '1.0.0', enabled: true, spec: 'github:csyangwen/dsh-memory-evolve' });
169
175
  ctx.plugins.installed.set('@deepseek-ai/dsh-base', { name: '@deepseek-ai/dsh-base', version: '0.1.0-rc.6', enabled: true });
170
- ctx.patchFile.lines.set('skill-badge', { lineId: 'skill-badge', raw: { id: 'skill-badge', disabled: true } });
176
+ // 层寻址契约:导出同时读 home profile 两层,单层的 MemPatch 对任何 file 都返回同一批行,
177
+ // 会让同一行被当成两层的两行。此处改用按层键控的门面,夹具才与真实门面同语义。
178
+ ctx.useLayeredPatch().set(HOME_PATCH_FILE, 'skill-badge', { id: 'skill-badge', disabled: true });
171
179
 
172
180
  const adapter = new PluginsAdapter();
173
181
  const out = await adapter.export(ctx, { includeSecrets: false });
@@ -235,11 +243,11 @@ test('plugins: 已装同版本 Skip / 未装 Install / 版本不同 Conflict / p
235
243
  const byId = new Map(items.map((i) => [i.id, i]));
236
244
  assert.equal(byId.get('plugin:pkg-a')?.kind, 'Skip');
237
245
  assert.equal(byId.get('plugin:pkg-b')?.kind, 'Conflict');
238
- assert.equal(byId.get('patch:my-line')?.kind, 'Create');
239
- assert.equal(byId.get('patch:my-line')?.target?.ref, 'my-line');
246
+ assert.equal(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))?.kind, 'Create');
247
+ assert.equal(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))?.target?.ref, patchLayerKey(HOME_PATCH_FILE, 'my-line'));
240
248
 
241
249
  // patch 行写入(Create)
242
- const r = await adapter.applyItem(byId.get('patch:my-line')!, makeImportContext(dst, sections));
250
+ const r = await adapter.applyItem(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))!, makeImportContext(dst, sections));
243
251
  assert.equal(r.ok, true);
244
252
  assert.equal(r.needsRestart, true);
245
253
  assert.deepEqual(dst.patchFile.lines.get('my-line')?.raw, { id: 'my-line', name: 'pkg-c', config: { x: 1 } });
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * plugins 分区 adapter(设计 §3.3/§8):
3
- * 数据源 = ctx.plugins.listInstalled()(插件清单)+ 用户 patch 层(profile cordis.patch.yml)。
3
+ * 数据源 = ctx.plugins.listInstalled()(插件清单)+ 两个 patch 层(home 与 profile cordis.patch.yml)。
4
4
  *
5
5
  * 安全不变量:绝不打包插件二进制;导入走 DSH 官方机制(dsh plugin CLI → needsRestart 提示)。
6
- * patch 行导入(用户自定义行:启用/禁用/插入)写回 cordis.patch.yml,同样 needsRestart。
6
+ * patch 行导入(用户自定义行:启用/禁用/插入)写回该行自带的层(home profile),同样 needsRestart。
7
7
  *
8
8
  * T1(本地源插件迁移):`link:` / `file:` 来源的插件指向本机路径,换机后必然不可达
9
9
  * (曾导致插件被静默丢失)。导出时经注入的 `localPack` 执行 `npm pack`,把 tarball 作为
@@ -19,6 +19,7 @@ import { msgOf, zhMsg } from '../core/messages.ts';
19
19
  import type { MsgFunc } from '../core/messages.ts';
20
20
  import { isPathSafe, normalizePath } from '../utils/paths.ts';
21
21
  import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.ts';
22
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey, parsePatchLayerKey } from '../core/patch-layers.ts';
22
23
  import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.ts';
23
24
  import type { LocalPluginTarball, PatchLine, PluginEntry, PluginsSection, PnpmPatchFile } from '../schema/types.ts';
24
25
  import type {
@@ -26,7 +27,6 @@ import type {
26
27
  ImportContext, PlanItem, ValidationResult,
27
28
  } from '../core/types.ts';
28
29
 
29
- export const USER_PATCH_FILE = 'cordis.patch.yml';
30
30
 
31
31
  /**
32
32
  * 本地源插件打包钩子(由宿主注入,见 src/index.ts createAdapters)。
@@ -170,12 +170,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
170
170
  } catch (err) {
171
171
  warnings.push(msgOf(ctx)('adapter.pluginListReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
172
172
  }
173
+ // 两层都读:home 层(全机偏好)与 profile 层(该 profile 专属)。缺 profile 层文件不是故障
174
+ // (readPatchLines 对不存在的文件返回空数组),只有真读失败才告警。
173
175
  const patch: PatchLine[] = [];
174
- try {
175
- const lines = await ctx.patchFile.readPatchLines(USER_PATCH_FILE);
176
- for (const l of lines) patch.push({ file: USER_PATCH_FILE, lineId: l.lineId, raw: l.raw });
177
- } catch (err) {
178
- warnings.push(msgOf(ctx)('adapter.patchReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
176
+ for (const file of [HOME_PATCH_FILE, PROFILE_PATCH_FILE]) {
177
+ try {
178
+ const lines = await ctx.patchFile.readPatchLines(file);
179
+ for (const l of lines) patch.push({ file, lineId: l.lineId, raw: l.raw });
180
+ } catch (err) {
181
+ warnings.push(msgOf(ctx)('adapter.patchReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
182
+ }
179
183
  }
180
184
  // pnpm-workspace.yaml(allowBuilds / minimumReleaseAgeExclude 等):随插件分区迁移,
181
185
  // 否则目标 profile 的 pnpm 可能因构建白名单/冷静期拒绝安装插件(§34.17 同款语义)。
@@ -414,16 +418,25 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
414
418
 
415
419
  // 用户 patch 行:lineId 唯一键;存在且同 → Skip;存在不同 → Conflict;不存在 → Create。
416
420
  // mcp-client 行与 systemPrompt/planMode 行由 mcp/prompts adapter 管理,此处跳过(避免重复写入覆盖)。
417
- const targetLines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
421
+ // 逐层读目标端:行自带的 file 决定比对哪一层(只读 home 层会让 profile 层行在目标机
422
+ // 已有同值时被误判成 Create,也会让目标机 profile 层的不同值判不出 Conflict)。
423
+ const targetLines = new Map<string, { lineId: string; raw: unknown }[]>();
424
+ for (const pl of data.patch) {
425
+ if (!targetLines.has(pl.file)) {
426
+ targetLines.set(pl.file, await ctx.target.patchFile.readPatchLines(pl.file));
427
+ }
428
+ }
418
429
  for (const pl of data.patch) {
419
430
  if (isManagedElsewhere(pl.raw)) continue;
420
- const id = `patch:${pl.lineId}`;
421
- const tl = targetLines.find((l) => l.lineId === pl.lineId);
431
+ // 层限定复合键:两层可存在同名 lineId,裸 lineId 无法指向「哪一层的哪一行」。
432
+ const ref = patchLayerKey(pl.file, pl.lineId);
433
+ const id = `patch:${ref}`;
434
+ const tl = targetLines.get(pl.file)?.find((l) => l.lineId === pl.lineId);
422
435
  if (!tl) {
423
436
  items.push({
424
437
  id, kind: 'Create', adapter: 'plugins',
425
438
  description: msg('adapter.patchLineCreate', { lineId: pl.lineId }), severity: 'info',
426
- target: { adapter: 'plugins', ref: pl.lineId },
439
+ target: { adapter: 'plugins', ref },
427
440
  });
428
441
  } else if (isDeepStrictEqual(tl.raw, pl.raw)) {
429
442
  items.push({ id, kind: 'Skip', adapter: 'plugins', description: msg('adapter.patchLineSame', { lineId: pl.lineId }), severity: 'info' });
@@ -431,7 +444,7 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
431
444
  items.push({
432
445
  id, kind: 'Conflict', adapter: 'plugins',
433
446
  description: msg('adapter.patchLineDiff', { lineId: pl.lineId }), severity: 'warning',
434
- target: { adapter: 'plugins', ref: pl.lineId },
447
+ target: { adapter: 'plugins', ref },
435
448
  });
436
449
  }
437
450
  }
@@ -561,13 +574,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
561
574
  // patch 行:Create → insert,Update/Conflict(useImported) → update
562
575
  const ref = item.target?.ref;
563
576
  if (!ref) return { ok: false, message: msg('adapter.missingTargetRef') };
577
+ // 复合键解析出层与 lineId:只按 lineId 查找会命中数组里先出现的 home 层行,
578
+ // 把 profile 层的行写进 home 层(回滚同理)。
579
+ const { file, lineId } = parsePatchLayerKey(ref);
564
580
  const data = ctx.sections.get('plugins') as PluginsSection | undefined;
565
- const pl = data?.patch.find((p) => p.lineId === ref);
581
+ const pl = data?.patch.find((p) => p.file === file && p.lineId === lineId);
566
582
  if (!pl) return { ok: false, message: msg('adapter.patchMissing', { ref }) };
567
- await ctx.target.patchFile.applyPatchChanges(pl.file, [
568
- { lineId: ref, raw: pl.raw, action: item.kind === 'Create' ? 'insert' : 'update' },
583
+ await ctx.target.patchFile.applyPatchChanges(file, [
584
+ { lineId, raw: pl.raw, action: item.kind === 'Create' ? 'insert' : 'update' },
569
585
  ]);
570
- return { ok: true, needsRestart: true, message: msg('adapter.patchWritten', { ref }) };
586
+ return { ok: true, needsRestart: true, message: msg('adapter.patchWritten', { ref: lineId }) };
571
587
  }
572
588
 
573
589
  async validate(data: PluginsSection, msg: MsgFunc = zhMsg): Promise<ValidationResult> {
@@ -17,7 +17,7 @@ import type {
17
17
  ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
18
18
  ImportContext, PlanItem, ValidationResult,
19
19
  } from '../core/types.ts';
20
- import { USER_PATCH_FILE } from './plugins.ts';
20
+ import { HOME_PATCH_FILE } from '../core/patch-layers.ts';
21
21
 
22
22
  /** 导出记录:PromptEntry 之外记录来源行名(导入需要重建行时使用) */
23
23
  export interface PromptExportEntry extends PromptEntry {
@@ -116,7 +116,7 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
116
116
  const warnings: string[] = [];
117
117
  let lines: { lineId: string; raw: unknown }[] = [];
118
118
  try {
119
- lines = await ctx.patchFile.readPatchLines(USER_PATCH_FILE);
119
+ lines = await ctx.patchFile.readPatchLines(HOME_PATCH_FILE);
120
120
  } catch (err) {
121
121
  warnings.push(msgOf(ctx)('adapter.patchReadFailedPrompts', { reason: err instanceof Error ? err.message : String(err) }));
122
122
  }
@@ -132,7 +132,7 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
132
132
  async analyzeImport(data: PromptsExportSection, ctx: ImportContext): Promise<PlanItem[]> {
133
133
  const msg = ctx.msg;
134
134
  const items: PlanItem[] = [];
135
- const targetLines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
135
+ const targetLines = await ctx.target.patchFile.readPatchLines(HOME_PATCH_FILE);
136
136
  const targetPrompts = extractPrompts(targetLines);
137
137
  for (const p of data.prompts) {
138
138
  const id = p.id;
@@ -192,17 +192,17 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
192
192
  // Create:目标无来源行 → 用记录的行名重建 patch 行(insert)
193
193
  if (item.kind === 'Create') {
194
194
  const raw = buildPromptLine(ref, prompt);
195
- await ctx.target.patchFile.applyPatchChanges(USER_PATCH_FILE, [
195
+ await ctx.target.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
196
196
  { lineId: ref, raw, action: 'insert' },
197
197
  ]);
198
198
  return { ok: true, needsRestart: true, message: msg('adapter.promptCreated', { name: prompt.name, ref }) };
199
199
  }
200
200
  // Update / Conflict(useImported):合并进目标行 config
201
- const lines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
201
+ const lines = await ctx.target.patchFile.readPatchLines(HOME_PATCH_FILE);
202
202
  const line = lines.find((l) => l.lineId === ref);
203
203
  if (!line) return { ok: false, message: msg('adapter.patchLineMissing', { ref }) };
204
204
  const newRaw = mergePromptIntoLine(line.raw, prompt);
205
- await ctx.target.patchFile.applyPatchChanges(USER_PATCH_FILE, [
205
+ await ctx.target.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
206
206
  { lineId: ref, raw: newRaw, action: 'update' },
207
207
  ]);
208
208
  return { ok: true, needsRestart: true, message: msg('adapter.promptWritten', { name: prompt.name, ref }) };
@@ -158,6 +158,46 @@ export class MemPatch implements PatchFileFacade {
158
158
  }
159
159
  }
160
160
 
161
+ /**
162
+ * 分层 patch 门面 mock:patch 行按 `file`(层 token)分桶。
163
+ * 单层的 `MemPatch` 无法表达「同名 lineId 分布在两层」——而那正是层寻址契约的核心,
164
+ * 故另立一个按层键控的实现,供 plugins 适配器与快照/回滚的契约测试使用。
165
+ */
166
+ export class MemLayeredPatch implements PatchFileFacade {
167
+ files = new Map<string, Map<string, { lineId: string; raw: unknown }>>();
168
+
169
+ /** 直接落一行(测试夹具;等价于目标机该层已存在该行)。 */
170
+ set(file: string, lineId: string, raw: unknown): void {
171
+ const layer = this.files.get(file) ?? new Map();
172
+ layer.set(lineId, { lineId, raw });
173
+ this.files.set(file, layer);
174
+ }
175
+
176
+ has(file: string, lineId: string): boolean {
177
+ return this.files.get(file)?.has(lineId) ?? false;
178
+ }
179
+
180
+ rawOf(file: string, lineId: string): unknown {
181
+ return this.files.get(file)?.get(lineId)?.raw;
182
+ }
183
+
184
+ async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
185
+ return [...(this.files.get(file)?.values() ?? [])];
186
+ }
187
+
188
+ async applyPatchChanges(
189
+ file: string,
190
+ changes: { lineId: string; raw: unknown; action: 'insert' | 'update' | 'remove' }[],
191
+ ): Promise<void> {
192
+ const layer = this.files.get(file) ?? new Map<string, { lineId: string; raw: unknown }>();
193
+ this.files.set(file, layer);
194
+ for (const c of changes) {
195
+ if (c.action === 'remove') layer.delete(c.lineId);
196
+ else layer.set(c.lineId, { lineId: c.lineId, raw: c.raw });
197
+ }
198
+ }
199
+ }
200
+
161
201
  export class MemSnapshotStore implements SnapshotStore {
162
202
  snapshots = new Map<string, Snapshot>();
163
203
  blobs = new Map<string, Uint8Array>();
@@ -203,6 +243,14 @@ export class MockHostContext implements HostContext {
203
243
  this.fs = new MemFs(homeDir);
204
244
  this.log = createLogger({ level: 'error', sink: () => {} });
205
245
  }
246
+
247
+ /** 换成按层键控的 patch 门面(层寻址契约测试用)。
248
+ * 字段的声明类型仍是单层的 MemPatch,以免既有夹具的 `.lines` 用法失型。 */
249
+ useLayeredPatch(): MemLayeredPatch {
250
+ const layered = new MemLayeredPatch();
251
+ this.patchFile = layered as unknown as MemPatch;
252
+ return layered;
253
+ }
206
254
  }
207
255
 
208
256
  export function makeContext(platform: string, homeDir: string, profile?: string): MockHostContext {
package/src/client/api.ts CHANGED
@@ -4,19 +4,10 @@
4
4
  */
5
5
  import { zhUiT, type UiT } from '../ui/i18n.ts'
6
6
 
7
- /** Host 半健康检查响应(plugin 版本 / DSH 版本 / 平台,用于主页横幅与兼容性说明) */
7
+ /** Host 半状态响应:设置页页脚版本行的两个字段(消费方只有 ConfigManagerSection) */
8
8
  export interface ServiceStatus {
9
- ready: boolean
10
9
  pluginVersion: string
11
10
  dshVersion: string
12
- platform: string
13
- arch: string
14
- homeDir?: string
15
- profile?: string
16
- profileManifestReadable?: boolean
17
- installedPluginCount?: number
18
- installedPluginNames?: string[]
19
- bundles?: string[]
20
11
  }
21
12
 
22
13
  export const CONFIG_MANAGER_API = {
@@ -30,7 +30,6 @@ import { zhUiT, type UiT } from '../../ui/i18n.ts';
30
30
 
31
31
  /** 同步端点常量(与 Host 半 src/index.ts API 常量保持一致) */
32
32
  export const SYNC_API = {
33
- base: '/api/dsh-config-manager/sync',
34
33
  status: '/api/dsh-config-manager/sync/status',
35
34
  push: '/api/dsh-config-manager/sync/push',
36
35
  pull: '/api/dsh-config-manager/sync/pull',
@@ -9,6 +9,7 @@ import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
10
  import crypto from 'node:crypto';
11
11
  import { parseJsonSafe } from '../utils/json.ts';
12
+ import { parsePatchLayerKey } from './patch-layers.ts';
12
13
  import { sha256Hex } from '../utils/hashing.ts';
13
14
  import { normalizePath } from '../utils/paths.ts';
14
15
  import { atomicWriteFile } from '../utils/atomic-write.ts';
@@ -206,10 +207,11 @@ async function engineSnapshotEntry(ctx: HostContext, target: SnapshotTarget): Pr
206
207
  copiedTo: `blobs/${crypto.randomUUID()}`,
207
208
  };
208
209
  }
209
- // patchLine:从组合 patch 文件读取原行(file 为必填的 file 字段约定为 'cordis.patch.yml')
210
- const file = 'cordis.patch.yml';
210
+ // patchLine:ref 是层限定复合键(<file>#<lineId>),据此到**对应层**读原行。
211
+ // 旧快照的裸 lineId 由 parsePatchLayerKey 兼容为 home 层。
212
+ const { file, lineId } = parsePatchLayerKey(target.ref);
211
213
  const lines = await ctx.patchFile.readPatchLines(file);
212
- const line = lines.find((l) => l.lineId === target.ref);
214
+ const line = lines.find((l) => l.lineId === lineId);
213
215
  return { kind: 'patchLine', adapter: target.adapter, ref: target.ref, before: line?.raw ?? null, existed: line !== undefined };
214
216
  }
215
217
  case 'skills':
@@ -474,7 +474,10 @@ test('自动快照端到端:真实变更 → 防抖到期 → 落盘一份 aut
474
474
  const metas = await h.lifecycle.list();
475
475
  assert.equal(metas[0]!.kind, 'auto');
476
476
  assert.equal(metas[0]!.trigger, 'watcher');
477
- assert.equal(h.autoMetas.length, 1, '宿主回调必须被触发');
477
+ // 回调在 saveSnapshot 落盘**之后**才触发,所以「等文件出现」不等于「回调已跑」:
478
+ // 直接断言会在负载高时偶发失败。等回调本身,而不是等它的副作用。
479
+ const fired = await waitFor(async () => h.autoMetas.length === 1);
480
+ assert.equal(fired, true, '宿主回调必须被触发');
478
481
  });
479
482
 
480
483
  test('自动快照端到端:恢复自写文件是回声 → 不产生快照(否则会挡住重做)', async (t) => {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * cordis patch 层的寻址契约(唯一真源)。
3
+ *
4
+ * 宿主组装 patch 栈的顺序是 `bundle → profile → home → --patch`(后者覆盖前者),两层分工不同:
5
+ * - home 层 `$DSH_HOME/cordis.patch.yml`:全机偏好,对每个 profile 生效;
6
+ * - profile 层 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`:该 profile 专属(端口、trustedHosts、
7
+ * 挂载行…),换机恢复时必须跟着走。
8
+ *
9
+ * 两层的**持久化标识**是这里的两个逻辑 token,而不是相对路径:同步快照会跨机器、
10
+ * 跨 profile 导入,相对路径会把源机的 profile 名带过去。home 层 token 与历史快照里的
11
+ * `PatchLine.file` 取值逐字相同(存量快照零改动即可解析回 home 层)。
12
+ *
13
+ * 层限定复合键 `<file>#<lineId>`:两层可以存在同名 `lineId`,而计划项 id / `target.ref` /
14
+ * 快照条目必须唯一指向「哪一层的哪一行」,否则应用与回滚会写错层。两个 token 与 lineId 都不含
15
+ * `#`(后者由 `readPatchLines` 保证非空字符串,此处不额外校验),分隔符无歧义。
16
+ */
17
+
18
+ /** home 层 token(`$DSH_HOME/cordis.patch.yml`);取值与历史快照的 file 字段一致。 */
19
+ export const HOME_PATCH_FILE = 'cordis.patch.yml';
20
+
21
+ /** profile 层 token(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`)。 */
22
+ export const PROFILE_PATCH_FILE = 'profile:cordis.patch.yml';
23
+
24
+ /** 层限定复合键的分隔符(两个层 token 与 lineId 都不含它)。 */
25
+ const PATCH_LAYER_SEPARATOR = '#';
26
+
27
+ /** 层限定复合键:`<file>#<lineId>`。 */
28
+ export function patchLayerKey(file: string, lineId: string): string {
29
+ return `${file}${PATCH_LAYER_SEPARATOR}${lineId}`;
30
+ }
31
+
32
+ /**
33
+ * 解析层限定复合键。
34
+ *
35
+ * 兼容分支:旧快照的 `target.ref` / `SnapshotEntry.ref` 是裸 `lineId`(当时只有 home 层),
36
+ * 不含 `#` 即视作 home 层。lineId 本身不含 `#`,因此该分支无歧义。
37
+ */
38
+ export function parsePatchLayerKey(ref: string): { file: string; lineId: string } {
39
+ const at = ref.indexOf(PATCH_LAYER_SEPARATOR);
40
+ if (at < 0) return { file: HOME_PATCH_FILE, lineId: ref };
41
+ return { file: ref.slice(0, at), lineId: ref.slice(at + 1) };
42
+ }
@@ -8,6 +8,7 @@
8
8
  * - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
9
9
  */
10
10
  import { resolveFileTarget } from './backup.ts';
11
+ import { parsePatchLayerKey } from './patch-layers.ts';
11
12
  import { msgOf } from './messages.ts';
12
13
  import type {
13
14
  ConfigAdapter, HostContext, RollbackReport, Snapshot, SnapshotEntry, SnapshotStore,
@@ -71,10 +72,9 @@ async function compensateOne(
71
72
  }
72
73
  case 'patchLine': {
73
74
  try {
74
- // 引擎只管理 profile 的 cordis.patch.yml(backup.ts 的 patchLine 快照只记 lineId 作 ref,
75
- // 不含文件编码)——回滚固定写回该文件;切勿把 lineId 当文件名(否则 patchPath 抛「仅支持管理」)。
76
- const file = 'cordis.patch.yml';
77
- const lineId = entry.ref;
75
+ // 快照条目的 ref 是层限定复合键(<file>#<lineId>)——必须写回**同一层**,
76
+ // 否则 profile 层的原值会被写进 home 层。旧快照的裸 lineId 兼容为 home 层。
77
+ const { file, lineId } = parsePatchLayerKey(entry.ref);
78
78
  await ctx.patchFile.applyPatchChanges(file, [
79
79
  { lineId, raw: entry.before, action: entry.before === null ? 'remove' : 'update' },
80
80
  ]);
@@ -10,11 +10,11 @@
10
10
  */
11
11
  import test from 'node:test';
12
12
  import assert from 'node:assert/strict';
13
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
 
17
- import { DshPluginsFacade, ensureActivationRow } from './index.ts';
17
+ import { DshPatchFileFacade, DshPluginsFacade, ensureActivationRow } from './index.ts';
18
18
  import { resolveProfileDir } from './core/plugin-cli.ts';
19
19
  import type { DshPluginResult } from './core/plugin-cli.ts';
20
20
  import type { PatchChange, PatchFileFacade } from './core/types.ts';
@@ -250,6 +250,29 @@ test('ensureActivationRow: bundle 包跳过(reconcile 已维护 bundles)', a
250
250
  }
251
251
  });
252
252
 
253
+ test('ensureActivationRow: 非 bundle 插件补行落在 profile 层(home 层文件不受影响)', async () => {
254
+ const { homeDir, profileDir, cleanup } = makeTempProfile();
255
+ try {
256
+ writeInstalledPkg(profileDir, 'pkg-a', '1.0.0');
257
+ const patchFile = new DshPatchFileFacade(homeDir, 'web');
258
+ const homePatch = join(homeDir, 'cordis.patch.yml');
259
+ const profilePatch = join(profileDir, 'cordis.patch.yml');
260
+ writeFileSync(homePatch, '- id: home-line\n', 'utf8');
261
+ const homeBefore = readFileSync(homePatch, 'utf8');
262
+
263
+ await ensureActivationRow(patchFile, join(profileDir, 'node_modules', 'pkg-a'), 'pkg-a');
264
+
265
+ assert.match(
266
+ readFileSync(profilePatch, 'utf8'),
267
+ /id: pm-pkg-a/,
268
+ '激活行必须落在 profile 层(home 层对每个 profile 生效,会让只在 web 装过的插件污染其他 profile)',
269
+ );
270
+ assert.equal(readFileSync(homePatch, 'utf8'), homeBefore, 'home 层文件必须原样不变');
271
+ } finally {
272
+ cleanup();
273
+ }
274
+ });
275
+
253
276
  test('ensureActivationRow: scope 包名 slug 形态正确(@scope/name → pm-scope-name)', async () => {
254
277
  const { profileDir, cleanup } = makeTempProfile();
255
278
  try {
package/src/index.ts CHANGED
@@ -84,7 +84,7 @@ import { makeMsg, msgOf, zhMsg } from './core/messages.ts'
84
84
  import type { MsgFunc } from './core/messages.ts'
85
85
  import {
86
86
  cleanupAbortedInstall, hasDshBundlePatch, installErrorFor, installSpecFor, listInstalledPlugins,
87
- resolveProfileDir, resolveProfileNameFromArgv, readProfileManifest, runDshPlugin, validateProfileName,
87
+ resolveProfileDir, resolveProfileNameFromArgv, runDshPlugin, validateProfileName,
88
88
  } from './core/plugin-cli.ts'
89
89
  import type {
90
90
  ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
@@ -92,7 +92,8 @@ import type {
92
92
  SettingsFacade, Snapshot, WorkspaceFacade,
93
93
  } from './core/types.ts'
94
94
  import { ImportNotConfirmedError, ImportUserSkippedError } from './core/types.ts'
95
- import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
95
+ import { createAdapters } from './adapters/index.ts'
96
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE } from './core/patch-layers.ts'
96
97
  import { createLocalPluginPackHook } from './core/local-plugin-host.ts'
97
98
  import { createEncryptionProvider, decryptCredentials, decryptArchive, SecurityError, encryptArchive, isArchiveBlob, verifyEncryptedBlob } from './security/index.ts'
98
99
  import { createHardenedZipParser } from './security/zip-security.ts'
@@ -156,7 +157,7 @@ export const name = 'config-manager'
156
157
  export const inject = ['settings', 'credentials']
157
158
 
158
159
  /** Plugin version, kept in sync with package.json ("version"). */
159
- const PLUGIN_VERSION = '0.1.60'
160
+ const PLUGIN_VERSION = '0.1.61'
160
161
 
161
162
  /** Plugin own package name — excluded from its own exported plugins list. */
162
163
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -220,6 +221,8 @@ export interface Config {
220
221
 
221
222
  /** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
222
223
  const API = {
224
+ // 设置页页脚版本行(pluginVersion / dshVersion)。只读,loopback fence。
225
+ status: '/api/dsh-config-manager/status',
223
226
  // P2-⑫:导出前只读预览(不落盘 ZIP;返回各分区 counts + 估算大小)
224
227
  // P1-⑧:快照管理(手动删除 + 置顶豁免自动清理)
225
228
  // m-backup-schedule:定时全量备份(读/存 backup-schedule.json + 立即执行一次)
@@ -644,12 +647,12 @@ class DshWorkspaceFacade implements WorkspaceFacade {
644
647
  }
645
648
  }
646
649
 
647
- /** Profile 目录内的 patch 文件(非 bundle 插件激活行写入处,marketplace 同款路径)。 */
648
- const PROFILE_PATCH_FILE = 'cordis.patch.yml'
649
-
650
650
  /** Patch-file facade:用户 patch 层($DSH_HOME/cordis.patch.yml)+ profile patch 层
651
- * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。 */
652
- class DshPatchFileFacade implements PatchFileFacade {
651
+ * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。
652
+ *
653
+ * 导出仅为让测试能用**真实门面**(而非 mock)钉住层寻址——mock 会让两个层 token 的取值
654
+ * 撞车无处暴露(DshPluginsFacade 同款做法)。 */
655
+ export class DshPatchFileFacade implements PatchFileFacade {
653
656
  private readonly homeDir: string
654
657
  private readonly profile: string
655
658
  private readonly msg: MsgFunc
@@ -661,9 +664,9 @@ class DshPatchFileFacade implements PatchFileFacade {
661
664
  }
662
665
 
663
666
  private patchPath(file: string): string {
664
- if (file === USER_PATCH_FILE) return join(this.homeDir, USER_PATCH_FILE)
665
- if (file === PROFILE_PATCH_FILE) return join(this.homeDir, 'profiles', this.profile, PROFILE_PATCH_FILE)
666
- throw new Error(this.msg('host.patchUnsupported', { user: USER_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }))
667
+ if (file === HOME_PATCH_FILE) return join(this.homeDir, HOME_PATCH_FILE)
668
+ if (file === PROFILE_PATCH_FILE) return join(this.homeDir, 'profiles', this.profile, HOME_PATCH_FILE)
669
+ throw new Error(this.msg('host.patchUnsupported', { user: HOME_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }))
667
670
  }
668
671
 
669
672
  async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
@@ -1423,44 +1426,6 @@ export function isGitHubAuthMissing(error: unknown): boolean {
1423
1426
  return error instanceof GitHubAuthError && (error.code === 'unauthorized' || error.code === 'no_token')
1424
1427
  }
1425
1428
 
1426
- /** /status 的插件诊断位(issue #28)。仅回非敏感元信息:目录、profile 名、计数。 */
1427
- export interface PluginDiagnostics {
1428
- homeDir: string
1429
- profile: string
1430
- /** profile 目录的 package.json 是否可读(不可读 → 清单必然为空) */
1431
- profileManifestReadable: boolean
1432
- /** 插件清单来源 = package.json 的 dependencies 里非 in-box 的包 */
1433
- installedPluginCount: number
1434
- installedPluginNames: string[]
1435
- /** dsh.profile.bundles 声明(非空即「替换默认插件栈」) */
1436
- bundles: string[]
1437
- }
1438
-
1439
- /**
1440
- * 读取插件诊断信息(issue #28):把「插件到底读了哪个目录 / 哪个 profile / 看到什么」变成
1441
- * 用户可自查的数据——此前只存在于宿主内部,导致「装了插件却识别不到」无从定位。
1442
- * best-effort:失败不抛出(诊断位缺失不应拖垮 /status)。
1443
- */
1444
- async function readPluginDiagnostics(host: HostContext): Promise<Partial<PluginDiagnostics>> {
1445
- try {
1446
- const profileDir = resolveProfileDir(host.homeDir, host.profile ?? 'web')
1447
- const manifest = readProfileManifest(profileDir)
1448
- const installed = await host.plugins.listInstalled()
1449
- const bundles = manifest?.dsh?.profile?.bundles
1450
- return {
1451
- homeDir: host.homeDir,
1452
- profile: host.profile ?? 'web',
1453
- profileManifestReadable: manifest !== null,
1454
- installedPluginCount: installed.length,
1455
- installedPluginNames: installed.map((p) => p.name),
1456
- bundles: Array.isArray(bundles) ? bundles : [],
1457
- }
1458
- } catch (err) {
1459
- host.log.warn(`plugin diagnostics unavailable: ${err instanceof Error ? err.message : String(err)}`)
1460
- return {}
1461
- }
1462
- }
1463
-
1464
1429
  /** Build the /api/dsh-config-manager route family. */
1465
1430
  function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSyncScheduler; makeSyncEngine: (cfg: SyncConfig) => SyncEngine; lifecycle: ConfigLifecycle } {
1466
1431
  const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, dataDir, credentials, githubClientId, githubClientSecret, history } = deps
@@ -1856,6 +1821,18 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
1856
1821
 
1857
1822
  const routesList: WebRoute[] = [
1858
1823
  // ------------------------------------------------------------- status
1824
+ // 设置页页脚版本行:插件版本 + DSH 版本。只读、无 secret,loopback fence。
1825
+ {
1826
+ kind: 'exact',
1827
+ path: API.status,
1828
+ handler: async (req, res) => {
1829
+ if (!guard(req, res, 'GET')) return
1830
+ writeJson(res, 200, {
1831
+ pluginVersion: PLUGIN_VERSION,
1832
+ dshVersion: host.dshVersion,
1833
+ })
1834
+ },
1835
+ },
1859
1836
  // ------------------------------------------------------------- export
1860
1837
  // ---------------------------------------------------- export-preview
1861
1838
  // P2-⑫:导出前只读预览(不落盘 ZIP):对选中分区逐个 adapter.export 收集 counts
@@ -22,6 +22,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
  import {
23
23
  EnvironmentLockManager,
24
24
  EnvironmentLockIOError,
25
+ parseLinuxProcStartTime,
25
26
  EnvironmentLockOwnedByAnotherError,
26
27
  EnvironmentLockUnavailableError,
27
28
  runWithMutationLock,
@@ -281,6 +282,34 @@ process.exit(1); // 期望被拒:非 0 指示「未获得锁」;父进程根
281
282
  await parent.release(pres.token!);
282
283
  });
283
284
 
285
+ test('parseLinuxProcStartTime:取字段 22(starttime),不得取字段 24(rss)', () => {
286
+ // 真实 /proc/<pid>/stat 形状;comm 含空格与括号(按最后一个 ')' 切片才正确)。
287
+ // 字段:pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt
288
+ // cmajflt utime stime cutime cstime priority nice num_threads itrealvalue
289
+ // starttime vsize rss ...
290
+ const line =
291
+ '12345 (node (worker)) S 1 12345 12345 0 -1 4194560 1000 0 0 0 5 3 0 0 20 0 11 0 999999 123456789 456 ' +
292
+ '18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0';
293
+ assert.equal(
294
+ parseLinuxProcStartTime(line),
295
+ '999999',
296
+ 'starttime 是字段 22;切片后 index 0 = 字段 3,故 index = 22-3 = 19。' +
297
+ '取 index 21 会得到字段 24(rss)—— 该值随进程内存占用变化,' +
298
+ '会让同一进程的两次读取得到不同身份,被误判为 PID 复用(STALE_LOCK_DETECTED)。',
299
+ );
300
+ });
301
+
302
+ test('parseLinuxProcStartTime:comm 含 ")" 时仍取最后一个 ")" 之后的字段', () => {
303
+ const line = '7 (a)b)c) R 1 7 7 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 424242 100 200';
304
+ assert.equal(parseLinuxProcStartTime(line), '424242');
305
+ });
306
+
307
+ test('parseLinuxProcStartTime:畸形输入返回 null(不得抛出,也不得编造身份)', () => {
308
+ assert.equal(parseLinuxProcStartTime(''), null);
309
+ assert.equal(parseLinuxProcStartTime('no-parens-here'), null);
310
+ assert.equal(parseLinuxProcStartTime('1 (x) S'), null, '字段不足 → null');
311
+ });
312
+
284
313
  test('§11.1-c4 release:close→unlink;unlink 失败抛 EnvironmentLockIOError 且保留 activeToken(可重试)', async (t) => {
285
314
  const locksDir = tmp(t);
286
315
  const ctl = makeIo();