@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.
@@ -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.js';
12
+ import { parsePatchLayerKey } from './patch-layers.js';
12
13
  import { sha256Hex } from '../utils/hashing.js';
13
14
  import { normalizePath } from '../utils/paths.js';
14
15
  import { atomicWriteFile } from '../utils/atomic-write.js';
@@ -180,10 +181,11 @@ async function engineSnapshotEntry(ctx, target) {
180
181
  copiedTo: `blobs/${crypto.randomUUID()}`,
181
182
  };
182
183
  }
183
- // patchLine:从组合 patch 文件读取原行(file 为必填的 file 字段约定为 'cordis.patch.yml')
184
- const file = 'cordis.patch.yml';
184
+ // patchLine:ref 是层限定复合键(<file>#<lineId>),据此到**对应层**读原行。
185
+ // 旧快照的裸 lineId 由 parsePatchLayerKey 兼容为 home 层。
186
+ const { file, lineId } = parsePatchLayerKey(target.ref);
185
187
  const lines = await ctx.patchFile.readPatchLines(file);
186
- const line = lines.find((l) => l.lineId === target.ref);
188
+ const line = lines.find((l) => l.lineId === lineId);
187
189
  return { kind: 'patchLine', adapter: target.adapter, ref: target.ref, before: line?.raw ?? null, existed: line !== undefined };
188
190
  }
189
191
  case 'skills':
@@ -0,0 +1,32 @@
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
+ /** home 层 token(`$DSH_HOME/cordis.patch.yml`);取值与历史快照的 file 字段一致。 */
18
+ export declare const HOME_PATCH_FILE = "cordis.patch.yml";
19
+ /** profile 层 token(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`)。 */
20
+ export declare const PROFILE_PATCH_FILE = "profile:cordis.patch.yml";
21
+ /** 层限定复合键:`<file>#<lineId>`。 */
22
+ export declare function patchLayerKey(file: string, lineId: string): string;
23
+ /**
24
+ * 解析层限定复合键。
25
+ *
26
+ * 兼容分支:旧快照的 `target.ref` / `SnapshotEntry.ref` 是裸 `lineId`(当时只有 home 层),
27
+ * 不含 `#` 即视作 home 层。lineId 本身不含 `#`,因此该分支无歧义。
28
+ */
29
+ export declare function parsePatchLayerKey(ref: string): {
30
+ file: string;
31
+ lineId: string;
32
+ };
@@ -0,0 +1,39 @@
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
+ /** home 层 token(`$DSH_HOME/cordis.patch.yml`);取值与历史快照的 file 字段一致。 */
18
+ export const HOME_PATCH_FILE = 'cordis.patch.yml';
19
+ /** profile 层 token(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`)。 */
20
+ export const PROFILE_PATCH_FILE = 'profile:cordis.patch.yml';
21
+ /** 层限定复合键的分隔符(两个层 token 与 lineId 都不含它)。 */
22
+ const PATCH_LAYER_SEPARATOR = '#';
23
+ /** 层限定复合键:`<file>#<lineId>`。 */
24
+ export function patchLayerKey(file, lineId) {
25
+ return `${file}${PATCH_LAYER_SEPARATOR}${lineId}`;
26
+ }
27
+ /**
28
+ * 解析层限定复合键。
29
+ *
30
+ * 兼容分支:旧快照的 `target.ref` / `SnapshotEntry.ref` 是裸 `lineId`(当时只有 home 层),
31
+ * 不含 `#` 即视作 home 层。lineId 本身不含 `#`,因此该分支无歧义。
32
+ */
33
+ export function parsePatchLayerKey(ref) {
34
+ const at = ref.indexOf(PATCH_LAYER_SEPARATOR);
35
+ if (at < 0)
36
+ return { file: HOME_PATCH_FILE, lineId: ref };
37
+ return { file: ref.slice(0, at), lineId: ref.slice(at + 1) };
38
+ }
39
+ //# sourceMappingURL=patch-layers.js.map
@@ -8,6 +8,7 @@
8
8
  * - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
9
9
  */
10
10
  import { resolveFileTarget } from './backup.js';
11
+ import { parsePatchLayerKey } from './patch-layers.js';
11
12
  import { msgOf } from './messages.js';
12
13
  /** 逆序补偿单条快照条目;返回 null=成功,否则为失败原因 */
13
14
  async function compensateOne(entry, ctx, store) {
@@ -57,10 +58,9 @@ async function compensateOne(entry, ctx, store) {
57
58
  }
58
59
  case 'patchLine': {
59
60
  try {
60
- // 引擎只管理 profile 的 cordis.patch.yml(backup.ts 的 patchLine 快照只记 lineId 作 ref,
61
- // 不含文件编码)——回滚固定写回该文件;切勿把 lineId 当文件名(否则 patchPath 抛「仅支持管理」)。
62
- const file = 'cordis.patch.yml';
63
- const lineId = entry.ref;
61
+ // 快照条目的 ref 是层限定复合键(<file>#<lineId>)——必须写回**同一层**,
62
+ // 否则 profile 层的原值会被写进 home 层。旧快照的裸 lineId 兼容为 home 层。
63
+ const { file, lineId } = parsePatchLayerKey(entry.ref);
64
64
  await ctx.patchFile.applyPatchChanges(file, [
65
65
  { lineId, raw: entry.before, action: entry.before === null ? 'remove' : 'update' },
66
66
  ]);
package/lib/index.d.ts CHANGED
@@ -132,6 +132,27 @@ export declare class DshPluginsFacade implements PluginsFacade {
132
132
  needsRestart: boolean;
133
133
  }>;
134
134
  }
135
+ /** Patch-file facade:用户 patch 层($DSH_HOME/cordis.patch.yml)+ profile patch 层
136
+ * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。
137
+ *
138
+ * 导出仅为让测试能用**真实门面**(而非 mock)钉住层寻址——mock 会让两个层 token 的取值
139
+ * 撞车无处暴露(DshPluginsFacade 同款做法)。 */
140
+ export declare class DshPatchFileFacade implements PatchFileFacade {
141
+ private readonly homeDir;
142
+ private readonly profile;
143
+ private readonly msg;
144
+ constructor(homeDir: string, profile: string, msg?: MsgFunc);
145
+ private patchPath;
146
+ readPatchLines(file: string): Promise<{
147
+ lineId: string;
148
+ raw: unknown;
149
+ }[]>;
150
+ applyPatchChanges(file: string, changes: {
151
+ lineId: string;
152
+ raw: unknown;
153
+ action: 'insert' | 'update' | 'remove';
154
+ }[]): Promise<void>;
155
+ }
135
156
  /** 同步路由可预期的请求级错误(status 缺省 400;引擎/传输失败走 500) */
136
157
  export declare class SyncRouteError extends Error {
137
158
  readonly status: number;
@@ -217,18 +238,6 @@ export declare function executeRestorePlan(plan: RestorePlan, exec: RestoreExecu
217
238
  * 是真实故障,仍按 500 暴露,绝不伪装成「未登录」。
218
239
  */
219
240
  export declare function isGitHubAuthMissing(error: unknown): boolean;
220
- /** /status 的插件诊断位(issue #28)。仅回非敏感元信息:目录、profile 名、计数。 */
221
- export interface PluginDiagnostics {
222
- homeDir: string;
223
- profile: string;
224
- /** profile 目录的 package.json 是否可读(不可读 → 清单必然为空) */
225
- profileManifestReadable: boolean;
226
- /** 插件清单来源 = package.json 的 dependencies 里非 in-box 的包 */
227
- installedPluginCount: number;
228
- installedPluginNames: string[];
229
- /** dsh.profile.bundles 声明(非空即「替换默认插件栈」) */
230
- bundles: string[];
231
- }
232
241
  /**
233
242
  * Mount the config-manager engine: host context, adapters, and the
234
243
  * /api/dsh-config-manager routes (when a webServer is present).
package/lib/index.js CHANGED
@@ -65,9 +65,10 @@ import { registerModelTools } from './core/model-tools.js';
65
65
  import { computeConsultReport } from './core/migration-consult.js';
66
66
  import { readExportZipSource, buildLocalSnapshotSource, buildProfileSource } from './core/consult-source.js';
67
67
  import { makeMsg, msgOf, zhMsg } from './core/messages.js';
68
- import { cleanupAbortedInstall, hasDshBundlePatch, installErrorFor, installSpecFor, listInstalledPlugins, resolveProfileDir, resolveProfileNameFromArgv, readProfileManifest, runDshPlugin, validateProfileName, } from './core/plugin-cli.js';
68
+ import { cleanupAbortedInstall, hasDshBundlePatch, installErrorFor, installSpecFor, listInstalledPlugins, resolveProfileDir, resolveProfileNameFromArgv, runDshPlugin, validateProfileName, } from './core/plugin-cli.js';
69
69
  import { ImportNotConfirmedError, ImportUserSkippedError } from './core/types.js';
70
- import { createAdapters, USER_PATCH_FILE } from './adapters/index.js';
70
+ import { createAdapters } from './adapters/index.js';
71
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE } from './core/patch-layers.js';
71
72
  import { createLocalPluginPackHook } from './core/local-plugin-host.js';
72
73
  import { createEncryptionProvider, decryptCredentials, decryptArchive, SecurityError, encryptArchive, isArchiveBlob, verifyEncryptedBlob } from './security/index.js';
73
74
  import { createHardenedZipParser } from './security/zip-security.js';
@@ -105,7 +106,7 @@ export const name = 'config-manager';
105
106
  /** Services required before the engine can mount (present in every profile). */
106
107
  export const inject = ['settings', 'credentials'];
107
108
  /** Plugin version, kept in sync with package.json ("version"). */
108
- const PLUGIN_VERSION = '0.1.60';
109
+ const PLUGIN_VERSION = '0.1.61';
109
110
  /** Plugin own package name — excluded from its own exported plugins list. */
110
111
  const PLUGIN_NAME = 'dsh-config-manager';
111
112
  /**
@@ -126,6 +127,8 @@ export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liq4i7n8UsylGRfb';
126
127
  /* ---------------------------------------------------------------- constants */
127
128
  /** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
128
129
  const API = {
130
+ // 设置页页脚版本行(pluginVersion / dshVersion)。只读,loopback fence。
131
+ status: '/api/dsh-config-manager/status',
129
132
  // P2-⑫:导出前只读预览(不落盘 ZIP;返回各分区 counts + 估算大小)
130
133
  // P1-⑧:快照管理(手动删除 + 置顶豁免自动清理)
131
134
  // m-backup-schedule:定时全量备份(读/存 backup-schedule.json + 立即执行一次)
@@ -520,11 +523,12 @@ class DshWorkspaceFacade {
520
523
  await registry.delete(id);
521
524
  }
522
525
  }
523
- /** Profile 目录内的 patch 文件(非 bundle 插件激活行写入处,marketplace 同款路径)。 */
524
- const PROFILE_PATCH_FILE = 'cordis.patch.yml';
525
526
  /** Patch-file facade:用户 patch 层($DSH_HOME/cordis.patch.yml)+ profile patch 层
526
- * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。 */
527
- class DshPatchFileFacade {
527
+ * ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。
528
+ *
529
+ * 导出仅为让测试能用**真实门面**(而非 mock)钉住层寻址——mock 会让两个层 token 的取值
530
+ * 撞车无处暴露(DshPluginsFacade 同款做法)。 */
531
+ export class DshPatchFileFacade {
528
532
  homeDir;
529
533
  profile;
530
534
  msg;
@@ -534,11 +538,11 @@ class DshPatchFileFacade {
534
538
  this.msg = msg;
535
539
  }
536
540
  patchPath(file) {
537
- if (file === USER_PATCH_FILE)
538
- return join(this.homeDir, USER_PATCH_FILE);
541
+ if (file === HOME_PATCH_FILE)
542
+ return join(this.homeDir, HOME_PATCH_FILE);
539
543
  if (file === PROFILE_PATCH_FILE)
540
- return join(this.homeDir, 'profiles', this.profile, PROFILE_PATCH_FILE);
541
- throw new Error(this.msg('host.patchUnsupported', { user: USER_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }));
544
+ return join(this.homeDir, 'profiles', this.profile, HOME_PATCH_FILE);
545
+ throw new Error(this.msg('host.patchUnsupported', { user: HOME_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }));
542
546
  }
543
547
  async readPatchLines(file) {
544
548
  const p = this.patchPath(file);
@@ -1199,31 +1203,6 @@ function parseMeForm(raw) {
1199
1203
  export function isGitHubAuthMissing(error) {
1200
1204
  return error instanceof GitHubAuthError && (error.code === 'unauthorized' || error.code === 'no_token');
1201
1205
  }
1202
- /**
1203
- * 读取插件诊断信息(issue #28):把「插件到底读了哪个目录 / 哪个 profile / 看到什么」变成
1204
- * 用户可自查的数据——此前只存在于宿主内部,导致「装了插件却识别不到」无从定位。
1205
- * best-effort:失败不抛出(诊断位缺失不应拖垮 /status)。
1206
- */
1207
- async function readPluginDiagnostics(host) {
1208
- try {
1209
- const profileDir = resolveProfileDir(host.homeDir, host.profile ?? 'web');
1210
- const manifest = readProfileManifest(profileDir);
1211
- const installed = await host.plugins.listInstalled();
1212
- const bundles = manifest?.dsh?.profile?.bundles;
1213
- return {
1214
- homeDir: host.homeDir,
1215
- profile: host.profile ?? 'web',
1216
- profileManifestReadable: manifest !== null,
1217
- installedPluginCount: installed.length,
1218
- installedPluginNames: installed.map((p) => p.name),
1219
- bundles: Array.isArray(bundles) ? bundles : [],
1220
- };
1221
- }
1222
- catch (err) {
1223
- host.log.warn(`plugin diagnostics unavailable: ${err instanceof Error ? err.message : String(err)}`);
1224
- return {};
1225
- }
1226
- }
1227
1206
  /** Build the /api/dsh-config-manager route family. */
1228
1207
  function makeRoutes(deps) {
1229
1208
  const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, dataDir, credentials, githubClientId, githubClientSecret, history } = deps;
@@ -1605,6 +1584,19 @@ function makeRoutes(deps) {
1605
1584
  });
1606
1585
  const routesList = [
1607
1586
  // ------------------------------------------------------------- status
1587
+ // 设置页页脚版本行:插件版本 + DSH 版本。只读、无 secret,loopback fence。
1588
+ {
1589
+ kind: 'exact',
1590
+ path: API.status,
1591
+ handler: async (req, res) => {
1592
+ if (!guard(req, res, 'GET'))
1593
+ return;
1594
+ writeJson(res, 200, {
1595
+ pluginVersion: PLUGIN_VERSION,
1596
+ dshVersion: host.dshVersion,
1597
+ });
1598
+ },
1599
+ },
1608
1600
  // ------------------------------------------------------------- export
1609
1601
  // ---------------------------------------------------- export-preview
1610
1602
  // P2-⑫:导出前只读预览(不落盘 ZIP):对选中分区逐个 adapter.export 收集 counts
@@ -222,6 +222,22 @@ export interface EnvLockManagerOptions {
222
222
  /** heartbeat 续期写失败回调(留痕;不中断 mutation) */
223
223
  onHeartbeatWriteFailure?: (err: unknown) => void;
224
224
  }
225
+ /**
226
+ * 从 /proc/<pid>/stat 文本提取进程创建身份(starttime,字段 22)。
227
+ *
228
+ * 字段布局(man 5 proc_pid_stat):1 pid, 2 comm, 3 state, …, 22 starttime, 23 vsize, 24 rss。
229
+ * comm 可能含空格与括号,故必须按**最后一个** ')' 切片;切片后 index 0 对应字段 3,
230
+ * 于是字段 N 的索引是 N-3 —— **starttime 的索引是 19**。
231
+ *
232
+ * 历史缺陷:此处曾取 index 21(= 字段 24 rss)。rss 随进程内存占用变化,
233
+ * 使同一进程的两次读取得到不同身份,被误判为「PID 复用」→ STALE_LOCK_DETECTED,
234
+ * 进而可能让 recoverStaleLock() 捕获一个**仍存活**的持有者的锁。
235
+ * 该路径仅在 Linux 生效(macOS/Windows 无 /proc),故只在 Linux CI 上暴露。
236
+ *
237
+ * @param statText /proc/<pid>/stat 全文
238
+ * @returns starttime 字符串;畸形/字段不足 → null(不抛错,也不编造身份)
239
+ */
240
+ export declare function parseLinuxProcStartTime(statText: string): string | null;
225
241
  /**
226
242
  * EnvironmentLockManager:跨进程环境锁管理器。
227
243
  *
@@ -153,16 +153,37 @@ function defaultIo() {
153
153
  } },
154
154
  };
155
155
  }
156
+ /**
157
+ * 从 /proc/<pid>/stat 文本提取进程创建身份(starttime,字段 22)。
158
+ *
159
+ * 字段布局(man 5 proc_pid_stat):1 pid, 2 comm, 3 state, …, 22 starttime, 23 vsize, 24 rss。
160
+ * comm 可能含空格与括号,故必须按**最后一个** ')' 切片;切片后 index 0 对应字段 3,
161
+ * 于是字段 N 的索引是 N-3 —— **starttime 的索引是 19**。
162
+ *
163
+ * 历史缺陷:此处曾取 index 21(= 字段 24 rss)。rss 随进程内存占用变化,
164
+ * 使同一进程的两次读取得到不同身份,被误判为「PID 复用」→ STALE_LOCK_DETECTED,
165
+ * 进而可能让 recoverStaleLock() 捕获一个**仍存活**的持有者的锁。
166
+ * 该路径仅在 Linux 生效(macOS/Windows 无 /proc),故只在 Linux CI 上暴露。
167
+ *
168
+ * @param statText /proc/<pid>/stat 全文
169
+ * @returns starttime 字符串;畸形/字段不足 → null(不抛错,也不编造身份)
170
+ */
171
+ export function parseLinuxProcStartTime(statText) {
172
+ const close = statText.lastIndexOf(')');
173
+ if (close < 0)
174
+ return null;
175
+ const fields = statText.slice(close + 1).trim().split(/\s+/);
176
+ const starttime = fields[19];
177
+ return typeof starttime === 'string' && starttime !== '' ? starttime : null;
178
+ }
156
179
  /** 默认进程探测(跨平台 best-effort;OS identity 能力由平台决定) */
157
180
  function defaultProbe() {
158
181
  const selfOsIdentity = (() => {
159
182
  try {
160
183
  if (process.platform === 'linux') {
161
- // /proc/<pid>/stat 第 22 字段 = starttime(tick 数)
162
184
  const l = fssync.readFileSync(`/proc/${process.pid}/stat`, 'utf8').toString();
163
- const afterComm = l.slice(l.lastIndexOf(')') + 1).trim().split(/\s+/);
164
- // 格式: state ppid ... starttime:comm 后第一字段是 state,starttime 是第 22 个(index 21 起)
165
- return `linux:${afterComm[21] ?? 'unknown'}`;
185
+ const starttime = parseLinuxProcStartTime(l);
186
+ return starttime === null ? null : `linux:${starttime}`;
166
187
  }
167
188
  if (process.platform === 'darwin')
168
189
  return `darwin:${process.pid}:${Date.now()}`; // 不可靠 → 保守返回占位
@@ -213,8 +234,8 @@ function defaultProbe() {
213
234
  if (process.platform === 'linux') {
214
235
  try {
215
236
  const l = fssync.readFileSync(`/proc/${pid}/stat`, 'utf8').toString();
216
- const afterComm = l.slice(l.lastIndexOf(')') + 1).trim().split(/\s+/);
217
- osIdentity = `linux:${afterComm[21] ?? 'unknown'}`;
237
+ const starttime = parseLinuxProcStartTime(l);
238
+ osIdentity = starttime === null ? null : `linux:${starttime}`;
218
239
  }
219
240
  catch {
220
241
  osIdentity = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logictan/dsh-config-manager",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
4
4
  "license": "MIT",
5
5
  "description": "Sync your DeepSeek Harness (DSH) configuration between machines over a private git or WebDAV channel: settings, plugins, MCP servers, skills, agent presets, workspaces and provider credentials. DSH 配置远程同步插件(私有通道,明文自用)。",
6
6
  "keywords": [
@@ -160,7 +160,7 @@
160
160
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
161
161
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
162
162
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
163
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
163
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.6-alpha.2",
164
164
  "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
165
165
  "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
166
166
  "@deepseek-ai/dsh-host-plugin-inventory": "^0.1.0-rc.6",
@@ -79,7 +79,7 @@ export type { CredentialRefsProvider } from './credentials.ts';
79
79
  export { SettingsAdapter } from './settings.ts';
80
80
  export { UiAdapter, isUiNamespace, KNOWN_UI_NAMESPACE_PREFIXES, UI_MIGRATION_NOTES } from './ui.ts';
81
81
  export { ProvidersAdapter, DEFAULT_PROVIDER_NAMESPACES, type ProviderExportEntry, type ProviderExportSection } from './providers.ts';
82
- export { PluginsAdapter, USER_PATCH_FILE } from './plugins.ts';
82
+ export { PluginsAdapter } from './plugins.ts';
83
83
  export type { LocalPluginPackHook } from './plugins.ts';
84
84
  export { McpAdapter, extractMcpServers, buildMcpPatchLine, type McpExportEntry, type McpExportSection } from './mcp.ts';
85
85
  export { PromptsAdapter, extractPrompts, mergePromptIntoLine, buildPromptLine, type PromptExportEntry, type PromptsExportSection } from './prompts.ts';
@@ -14,7 +14,7 @@ import type {
14
14
  ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
15
15
  ImportContext, PlanItem, ValidationResult,
16
16
  } from '../core/types.ts';
17
- import { USER_PATCH_FILE } from './plugins.ts';
17
+ import { HOME_PATCH_FILE } from '../core/patch-layers.ts';
18
18
 
19
19
  /** 导出记录:McpServerEntry 之外附加来源 patch 行 id(导入写回定位用) */
20
20
  export interface McpExportEntry extends McpServerEntry {
@@ -95,7 +95,7 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
95
95
  const warnings: string[] = [];
96
96
  let lines: { lineId: string; raw: unknown }[] = [];
97
97
  try {
98
- lines = await ctx.patchFile.readPatchLines(USER_PATCH_FILE);
98
+ lines = await ctx.patchFile.readPatchLines(HOME_PATCH_FILE);
99
99
  } catch (err) {
100
100
  warnings.push(msgOf(ctx)('adapter.patchReadFailedMCP', { reason: err instanceof Error ? err.message : String(err) }));
101
101
  }
@@ -111,7 +111,7 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
111
111
  async analyzeImport(data: McpExportSection, ctx: ImportContext): Promise<PlanItem[]> {
112
112
  const msg = ctx.msg;
113
113
  const items: PlanItem[] = [];
114
- const targetLines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
114
+ const targetLines = await ctx.target.patchFile.readPatchLines(HOME_PATCH_FILE);
115
115
  const targetServers = extractMcpServers(targetLines);
116
116
  for (const server of data.servers) {
117
117
  const id = `mcp:${server.serverName}`;
@@ -157,7 +157,7 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
157
157
  const ref = item.target?.ref;
158
158
  if (!ref) return { ok: false, message: msg('adapter.missingTargetRef') };
159
159
  const raw = buildMcpPatchLine(ref, server);
160
- await ctx.target.patchFile.applyPatchChanges(USER_PATCH_FILE, [
160
+ await ctx.target.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
161
161
  { lineId: ref, raw, action: item.kind === 'Create' ? 'insert' : 'update' },
162
162
  ]);
163
163
  return { ok: true, needsRestart: true, message: msg('adapter.mcpWritten', { serverName }) };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * cordis patch 的**层寻址**契约测试(S1–S5):
3
+ * - 导出同时覆盖 home 层与 profile 层,`file` 字段如实标注来源层;
4
+ * - diff 阶段按行自带的层读目标端(profile 层已有同值 → Skip,不是 Create);
5
+ * - 计划项 id / target.ref 是层限定复合键,跨层同名 lineId 各自落各自层;
6
+ * - 快照与回滚按层落位(回滚 profile 层的行不得污染 home 层)。
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+
11
+ import { PluginsAdapter } from './plugins.ts';
12
+ import { makeContext, makeImportContext, MemSnapshotStore } from './test-helpers.ts';
13
+ import { createSnapshot } from '../core/backup.ts';
14
+ import { rollback } from '../core/rollback.ts';
15
+ import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey } from '../core/patch-layers.ts';
16
+ import { SECTION_IDS } from '../schema/config.ts';
17
+ import type { ImportPlan, PlanItem } from '../core/types.ts';
18
+
19
+ /** 只含一项的导入计划(estimatedActions 需覆盖全部分区,故按 SECTION_IDS 补零)。 */
20
+ function importPlan(item: PlanItem): ImportPlan {
21
+ return {
22
+ items: [item],
23
+ globalStrategy: 'merge',
24
+ pathMappings: [],
25
+ missingSecrets: [],
26
+ needsRestart: false,
27
+ estimatedActions: Object.fromEntries(SECTION_IDS.map((id) => [id, 0])) as ImportPlan['estimatedActions'],
28
+ };
29
+ }
30
+
31
+ test('plugins.export: 两层 patch 行都进 section,file 字段区分来源层', async () => {
32
+ const ctx = makeContext('linux', '/home/alice', 'web');
33
+ const layered = ctx.useLayeredPatch();
34
+ layered.set(HOME_PATCH_FILE, 'home-a', { id: 'home-a', disabled: true });
35
+ layered.set(HOME_PATCH_FILE, 'home-b', { id: 'home-b', disabled: true });
36
+ layered.set(PROFILE_PATCH_FILE, 'profile-a', { id: 'profile-a', name: 'pkg-a' });
37
+ layered.set(PROFILE_PATCH_FILE, 'profile-b', { id: 'profile-b', name: 'pkg-b' });
38
+
39
+ const out = await new PluginsAdapter().export(ctx, { includeSecrets: false });
40
+ assert.equal(out.data.patch.length, 4, `两层并集必须齐全: ${JSON.stringify(out.data.patch)}`);
41
+ assert.deepEqual(
42
+ [...new Set(out.data.patch.map((p) => p.file))].sort(),
43
+ [HOME_PATCH_FILE, PROFILE_PATCH_FILE].sort(),
44
+ 'file 必须区分来源层',
45
+ );
46
+ assert.deepEqual(
47
+ out.data.patch.filter((p) => p.file === PROFILE_PATCH_FILE).map((p) => p.lineId),
48
+ ['profile-a', 'profile-b'],
49
+ );
50
+ });
51
+
52
+ test('plugins.export: profile 层文件缺失时降级为仅 home 层(不产生 error 级告警)', async () => {
53
+ const ctx = makeContext('linux', '/home/alice', 'web');
54
+ const layered = ctx.useLayeredPatch();
55
+ layered.set(HOME_PATCH_FILE, 'home-a', { id: 'home-a', disabled: true });
56
+
57
+ const out = await new PluginsAdapter().export(ctx, { includeSecrets: false });
58
+ assert.equal(out.data.patch.length, 1);
59
+ assert.deepEqual([...new Set(out.data.patch.map((p) => p.file))], [HOME_PATCH_FILE]);
60
+ assert.equal(out.warnings.length, 0, `缺 profile 层不是故障: ${out.warnings.join(' | ')}`);
61
+ });
62
+
63
+ test('plugins.analyzeImport: 目标端 profile 层已有同行 → Skip(不是 Create)', async () => {
64
+ const src = makeContext('linux', '/home/alice', 'web');
65
+ const srcLayered = src.useLayeredPatch();
66
+ const raw = { id: 'webserver', config: { port: 3080 } };
67
+ srcLayered.set(PROFILE_PATCH_FILE, 'webserver', raw);
68
+
69
+ const adapter = new PluginsAdapter();
70
+ const exported = await adapter.export(src, { includeSecrets: false });
71
+ const sections = new Map([['plugins', exported.data]]);
72
+
73
+ // 目标机 profile 层已有同值行 → Skip(旧实现只读 home 层,会误判成 Create)
74
+ const dst = makeContext('linux', '/home/bob', 'web');
75
+ dst.useLayeredPatch().set(PROFILE_PATCH_FILE, 'webserver', raw);
76
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
77
+ const item = items.find((i) => i.id === `patch:${patchLayerKey(PROFILE_PATCH_FILE, 'webserver')}`);
78
+ assert.equal(item?.kind, 'Skip', JSON.stringify(items));
79
+
80
+ // 目标机 profile 层同 id 但不同值 → Conflict(旧实现只读 home 层,会误判成 Create)
81
+ const dst2 = makeContext('linux', '/home/bob2', 'web');
82
+ dst2.useLayeredPatch().set(PROFILE_PATCH_FILE, 'webserver', { id: 'webserver', config: { port: 9999 } });
83
+ const items2 = await adapter.analyzeImport(exported.data, makeImportContext(dst2, sections));
84
+ const item2 = items2.find((i) => i.id === `patch:${patchLayerKey(PROFILE_PATCH_FILE, 'webserver')}`);
85
+ assert.equal(item2?.kind, 'Conflict', JSON.stringify(items2));
86
+ });
87
+
88
+ test('plugins: 跨层同名 lineId → 两个独立计划项,各自落各自层', async () => {
89
+ const src = makeContext('linux', '/home/alice', 'web');
90
+ const srcLayered = src.useLayeredPatch();
91
+ srcLayered.set(HOME_PATCH_FILE, 'dup', { id: 'dup', config: { from: 'home' } });
92
+ srcLayered.set(PROFILE_PATCH_FILE, 'dup', { id: 'dup', config: { from: 'profile' } });
93
+
94
+ const adapter = new PluginsAdapter();
95
+ const exported = await adapter.export(src, { includeSecrets: false });
96
+ const sections = new Map([['plugins', exported.data]]);
97
+ assert.equal(exported.data.patch.length, 2);
98
+
99
+ const dst = makeContext('linux', '/home/bob', 'web');
100
+ dst.useLayeredPatch();
101
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
102
+ const patchItems = items.filter((i) => i.id.startsWith('patch:'));
103
+ assert.equal(patchItems.length, 2, `同名 lineId 必须是两个独立项: ${JSON.stringify(items)}`);
104
+
105
+ for (const item of patchItems) {
106
+ const r = await adapter.applyItem(item, makeImportContext(dst, sections));
107
+ assert.equal(r.ok, true, JSON.stringify(r));
108
+ }
109
+ const layered = dst.patchFile as unknown as { has: (f: string, id: string) => boolean };
110
+ assert.equal(layered.has(HOME_PATCH_FILE, 'dup'), true, 'home 层的行必须落在 home 层');
111
+ assert.equal(layered.has(PROFILE_PATCH_FILE, 'dup'), true, 'profile 层的行必须落在 profile 层');
112
+ assert.deepEqual(
113
+ (dst.patchFile as unknown as { rawOf: (f: string, id: string) => unknown }).rawOf(HOME_PATCH_FILE, 'dup'),
114
+ { id: 'dup', config: { from: 'home' } },
115
+ );
116
+ assert.deepEqual(
117
+ (dst.patchFile as unknown as { rawOf: (f: string, id: string) => unknown }).rawOf(PROFILE_PATCH_FILE, 'dup'),
118
+ { id: 'dup', config: { from: 'profile' } },
119
+ );
120
+ });
121
+
122
+ test('rollback: profile 层 patchLine 回滚写回 profile 层(home 层文件不受影响)', async () => {
123
+ const ctx = makeContext('linux', '/home/bob', 'web');
124
+ const layered = ctx.useLayeredPatch();
125
+ const original = { id: 'webserver', config: { port: 3080 } };
126
+ layered.set(PROFILE_PATCH_FILE, 'webserver', original);
127
+ layered.set(HOME_PATCH_FILE, 'webserver', { id: 'webserver', config: { port: 1111 } });
128
+
129
+ const item: PlanItem = {
130
+ id: `patch:${patchLayerKey(PROFILE_PATCH_FILE, 'webserver')}`,
131
+ kind: 'Update',
132
+ adapter: 'plugins',
133
+ description: 'write profile patch row',
134
+ severity: 'info',
135
+ target: { adapter: 'plugins', ref: patchLayerKey(PROFILE_PATCH_FILE, 'webserver') },
136
+ };
137
+ const plan = importPlan(item);
138
+ const store = new MemSnapshotStore();
139
+ const snapshot = await createSnapshot({ ctx, plan, sourceZip: '/tmp/x.zip', store, adapters: [] });
140
+ const entry = snapshot.entries.find((e) => e.kind === 'patchLine');
141
+ assert.ok(entry !== undefined, `快照必须登记 patchLine 条目: ${JSON.stringify(snapshot.entries)}`);
142
+ assert.deepEqual(entry.before, original, '快照必须记 profile 层原值(旧实现固定读 home 层,会记成 home 层的值)');
143
+
144
+ // 模拟导入改写 profile 层
145
+ await ctx.patchFile.applyPatchChanges(PROFILE_PATCH_FILE, [
146
+ { lineId: 'webserver', raw: { id: 'webserver', config: { port: 9999 } }, action: 'update' },
147
+ ]);
148
+ const report = await rollback({ ctx, snapshot, store, adapters: [] });
149
+ assert.equal(report.full, true, JSON.stringify(report));
150
+ assert.deepEqual(layered.rawOf(PROFILE_PATCH_FILE, 'webserver'), original, '回滚必须写回 profile 层原值');
151
+ assert.deepEqual(
152
+ layered.rawOf(HOME_PATCH_FILE, 'webserver'),
153
+ { id: 'webserver', config: { port: 1111 } },
154
+ 'home 层的行不得被 profile 层回滚污染',
155
+ );
156
+ });
157
+
158
+ test('rollback: 旧快照的裸 lineId ref 兼容为 home 层', async () => {
159
+ const ctx = makeContext('linux', '/home/bob', 'web');
160
+ const layered = ctx.useLayeredPatch();
161
+ layered.set(HOME_PATCH_FILE, 'legacy', { id: 'legacy', disabled: false });
162
+
163
+ const item: PlanItem = {
164
+ id: 'patch:legacy',
165
+ kind: 'Update',
166
+ adapter: 'plugins',
167
+ description: 'legacy ref',
168
+ severity: 'info',
169
+ target: { adapter: 'plugins', ref: 'legacy' },
170
+ };
171
+ const plan = importPlan(item);
172
+ const store = new MemSnapshotStore();
173
+ const snapshot = await createSnapshot({ ctx, plan, sourceZip: '/tmp/x.zip', store, adapters: [] });
174
+ assert.deepEqual(snapshot.entries.find((e) => e.kind === 'patchLine')?.before, { id: 'legacy', disabled: false });
175
+
176
+ await ctx.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
177
+ { lineId: 'legacy', raw: { id: 'legacy', disabled: true }, action: 'update' },
178
+ ]);
179
+ const report = await rollback({ ctx, snapshot, store, adapters: [] });
180
+ assert.equal(report.full, true, JSON.stringify(report));
181
+ assert.deepEqual(layered.rawOf(HOME_PATCH_FILE, 'legacy'), { id: 'legacy', disabled: false });
182
+ });
183
+
184
+ test('plugins.export: 存量快照的 file=home token 仍解析到 home 层', async () => {
185
+ const src = makeContext('linux', '/home/alice', 'web');
186
+ const layered = src.useLayeredPatch();
187
+ layered.set(HOME_PATCH_FILE, 'legacy-home', { id: 'legacy-home', disabled: true });
188
+
189
+ const adapter = new PluginsAdapter();
190
+ const exported = await adapter.export(src, { includeSecrets: false });
191
+ assert.equal(exported.data.patch[0]?.file, HOME_PATCH_FILE, 'home 层 token 与历史快照取值一致');
192
+
193
+ const sections = new Map([['plugins', exported.data]]);
194
+ const dst = makeContext('linux', '/home/bob', 'web');
195
+ dst.useLayeredPatch();
196
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
197
+ const item = items.find((i) => i.id.startsWith('patch:'));
198
+ assert.equal(item?.target?.ref, patchLayerKey(HOME_PATCH_FILE, 'legacy-home'));
199
+ await adapter.applyItem(item!, makeImportContext(dst, sections));
200
+ assert.equal((dst.patchFile as unknown as { has: (f: string, id: string) => boolean }).has(HOME_PATCH_FILE, 'legacy-home'), true);
201
+ });