dsh-syn-config 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,8 @@
18
18
  > 点它打开面板:顶部是体检结论(阻塞/警告数),下面四个动作——**体检 / 拉取计划 / 推送计划 / 密钥扫描**——都是对只读路由的 `GET`,面板只能看、不能写;每个结果旁边有「复制命令」,真正的写操作仍要 `/syn-dsh … now`。
19
19
  > `syn-dsh` 技能由插件自带(`assets/syn-dsh.md`,rank 600),不再放在配置仓库里。
20
20
  >
21
+ > **升级插件后要 push 清单。** 配置仓库 `profiles/<profile>/package.json` 是 `apply.sh` 的来源;只改本机不改仓库,下次 `apply.sh --yes` 会把本机清单覆盖回仓库版本(曾因此把 `dsh-syn-config` 回滚成旧的 `dsh-config-sync`,状态灯随之变红)。`verify.mjs` 现在会比对这两份清单,不一致直接失败并给出修复路径。想彻底不漂移:`/syn-dsh push now`(或 `apply.sh` 之后手工提交该文件)。
22
+ >
21
23
  > **密钥只由人注入。** 工具与斜杠命令都拒绝写 `inject`/`refresh`:`/syn-dsh secrets` 只放 `scan`/`fingerprint`,`dsh_sync_secrets` 在 `dry_run:false` 时直接拒绝,并提示自己执行 `bash ~/dsh-config/scripts/apply.sh --yes`。确需让工具代写,本机在插件行加 `allowSecretInject: true`(默认关闭)。
22
24
  >
23
25
  > 设计理由与后续阶段见主仓库的调研文档:`../research/syn-dsh-plugin/PLAN.md`。
package/assets/syn-dsh.md CHANGED
@@ -231,3 +231,10 @@ bash ~/dsh-config/scripts/apply.sh --yes # 从 ~/.dsh/secrets.env 注入
231
231
  ```
232
232
 
233
233
  要让模型工具代写,需在本机插件行显式加 `allowSecretInject: true`(默认关闭)。
234
+
235
+ ## 升级插件后必须同步清单
236
+
237
+ 配置仓库里的 `profiles/<profile>/package.json` 与 `cordis.patch.yml` 是 `apply.sh` 的**来源**。只在本机改插件、不推仓库,下一次 `apply.sh --yes` 会把本机清单覆盖回仓库版本(真发生过:`dsh-syn-config` 被回滚成旧的 `dsh-config-sync`,状态灯变红)。
238
+
239
+ - 升级后:`/syn-dsh push now`(或手动提交该文件)
240
+ - 自检:`node scripts/verify.mjs` 会比对「本机清单」与「仓库清单」,不一致直接失败
@@ -41,6 +41,59 @@ function section(id, title, items, extra = {}) {
41
41
  return { id, title, status, items, ...extra }
42
42
  }
43
43
 
44
+ /**
45
+ * Semantic fingerprint of a plugin manifest.
46
+ *
47
+ * `link:` specs point at the same checkout from different bases on different
48
+ * machines (`/Users/me/x` vs `../../../x`), so only the final path segment is
49
+ * meaningful. Everything else compares by value.
50
+ *
51
+ * @param {string} text
52
+ * @returns {{ deps: Record<string, string>, bundles: string[] }}
53
+ */
54
+ function manifestFingerprint(text) {
55
+ const parsed = JSON.parse(text)
56
+ const normalized = {}
57
+ for (const [name, spec] of Object.entries(parsed.dependencies ?? {})) {
58
+ const value = typeof spec === 'string' && spec.startsWith('link:')
59
+ ? `link:${spec.split(/[\\/]/u).filter((segment) => segment !== '').pop() ?? ''}`
60
+ : String(spec)
61
+ normalized[name] = value
62
+ }
63
+ const bundles = [...(parsed.dsh?.profile?.bundles ?? [])].map(String).sort()
64
+ return { deps: normalized, bundles }
65
+ }
66
+
67
+ /**
68
+ * Names of manifest entries that really differ between live and repository.
69
+ *
70
+ * @param {string} liveText
71
+ * @param {string} repoText
72
+ * @returns {string[]} human-readable differences, empty when equivalent
73
+ */
74
+ export function manifestDrift(liveText, repoText) {
75
+ let live
76
+ let repo
77
+ try {
78
+ live = manifestFingerprint(liveText)
79
+ repo = manifestFingerprint(repoText)
80
+ } catch {
81
+ return ['清单无法解析']
82
+ }
83
+ const drift = []
84
+ for (const name of new Set([...Object.keys(live.deps), ...Object.keys(repo.deps)])) {
85
+ if (live.deps[name] !== repo.deps[name]) {
86
+ drift.push(`${name} ${live.deps[name] ?? '(缺)'} ↔ ${repo.deps[name] ?? '(缺)'}`)
87
+ }
88
+ }
89
+ if (live.bundles.join(',') !== repo.bundles.join(',')) {
90
+ const onlyLive = live.bundles.filter((x) => !repo.bundles.includes(x))
91
+ const onlyRepo = repo.bundles.filter((x) => !live.bundles.includes(x))
92
+ drift.push(`bundle ${[...onlyLive.map((x) => `+${x}`), ...onlyRepo.map((x) => `-${x}`)].join(' ') || '顺序不同'}`)
93
+ }
94
+ return drift
95
+ }
96
+
44
97
  const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
45
98
 
46
99
  /**
@@ -325,10 +378,10 @@ export async function runDoctor(options) {
325
378
  const liveManifestText = readTextFile(liveManifest)
326
379
  const repoManifestText = readTextFile(repoManifest)
327
380
  if (liveManifestText !== null && repoManifestText !== null) {
328
- const same = liveManifestText.replace(/\s+$/u, '') === repoManifestText.replace(/\s+$/u, '')
329
- items.push(same
330
- ? item('ok', 'package.json(插件清单)与仓库一致')
331
- : item('warn', 'package.json(插件清单)与仓库不同(装/卸了插件:sync 推送 / update 拉取)'))
381
+ const drift = manifestDrift(liveManifestText, repoManifestText)
382
+ items.push(drift.length === 0
383
+ ? item('ok', 'package.json(插件清单)与仓库一致(link: 的绝对/相对写法不参与比较)')
384
+ : item('warn', `package.json(插件清单)与仓库不同:${drift.slice(0, 5).join(',')}(装/卸了插件:sync 推送 / update 拉取)`))
332
385
  try {
333
386
  const parsed = JSON.parse(liveManifestText)
334
387
  const dependencies = Object.keys(parsed.dependencies ?? {})
package/lib/core/pack.js CHANGED
@@ -30,6 +30,7 @@ import { resolveAgentsHome, resolveDshHome } from './env.js'
30
30
  import { isFile, readTextFile, writeFileAtomic } from './files.js'
31
31
  import { mirrorDir } from './mirror.js'
32
32
  import { adoptReasoningEffort } from './patch.js'
33
+ import { loadSecrets, redact } from './secrets.js'
33
34
 
34
35
  /** Files mirrored from the profile directory; everything else stays local. */
35
36
  const PROFILE_FILES = ['package.json', 'cordis.patch.yml', 'cordis.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']
@@ -154,6 +155,12 @@ export function snapshot(options) {
154
155
  }
155
156
 
156
157
  // --- profile --------------------------------------------------------------
158
+ // A real value that the repository already stores as a placeholder is not a
159
+ // change to publish. Projecting redaction *before* the comparison keeps the
160
+ // plan honest — and means the plaintext never reaches the repository tree.
161
+ const secretEntries = loadSecrets(join(dshHome, 'secrets.env'))
162
+ /** Repository-form text: real secret values replaced by their placeholders. */
163
+ const forRepo = (text) => redact(text, secretEntries).text
157
164
  const profileWrites = []
158
165
  if (includeProfile && existsSync(profileDir)) {
159
166
  const previousPatch = readTextFile(join(repoProfile, 'cordis.patch.yml'))
@@ -175,9 +182,10 @@ export function snapshot(options) {
175
182
  // local machine state and must never travel.
176
183
  next = adoptReasoningEffort(next, previousPatch)
177
184
  }
185
+ const projected = forRepo(next)
178
186
  const current = readTextFile(join(repoProfile, name))
179
- if (current !== next) {
180
- profileWrites.push({ name, text: next })
187
+ if (current !== projected) {
188
+ profileWrites.push({ name, text: projected })
181
189
  changes.updated.push(`profiles/${profile}/${name}`)
182
190
  changes.files.push(`profiles/${profile}/${name}`)
183
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-syn-config",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": false,
5
5
  "description": "Cross-platform (macOS + Windows) DeepSeek Harness plugin that syncs DSH config — skills, profile settings, AGENTS.md — with a private dsh-config repository.",
6
6
  "type": "module",
@@ -136,6 +136,22 @@ async function main() {
136
136
  for (const note of status.notes) console.log(` note: ${note}`)
137
137
  }
138
138
 
139
+ // The repo copy of the profile manifest is what `apply.sh` restores from.
140
+ // If it still names another package, the next apply rolls this machine back
141
+ // to it — which is exactly how a working install "became unavailable" once.
142
+ const repoManifestPath = join(repoPath, 'profiles', profileName, 'package.json')
143
+ if (existsSync(repoManifestPath)) {
144
+ const repoManifest = JSON.parse(readFileSync(repoManifestPath, 'utf8'))
145
+ const liveSync = Object.keys(manifest.dependencies ?? {}).filter((dep) => dep.includes('syn-config') || dep.includes('config-sync'))
146
+ const repoSync = Object.keys(repoManifest.dependencies ?? {}).filter((dep) => dep.includes('syn-config') || dep.includes('config-sync'))
147
+ const agree = liveSync.length === 1 && repoSync.length === 1 && liveSync[0] === repoSync[0]
148
+ record(step(agree, 'repo copy agrees with this install', agree ? liveSync[0] : `live=${liveSync.join(',') || 'none'} repo=${repoSync.join(',') || 'none'}`))
149
+ if (!agree) {
150
+ console.log(' the next `apply.sh --yes` would overwrite this profile from the repo:')
151
+ console.log(` fix: update ${repoManifestPath} (dependencies + dsh.profile.bundles), then push`)
152
+ }
153
+ }
154
+
139
155
  console.log('')
140
156
  if (failures.length > 0) {
141
157
  console.error(`[verify] FAILED — ${failures.length} check(s) did not pass.`)