dsh-config-manager 0.1.78 → 0.1.80

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.
Files changed (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -130,6 +130,10 @@ test('P0-A:runExternalIntent 记录 external step + COMMITTED', async (t) => {
130
130
  assert.ok(j);
131
131
  assert.equal(j!.steps['ext']?.external, true);
132
132
  assert.equal(j!.state, 'COMMITTED');
133
+ // 成功路径必须清算 external step:否则 journal 会以「COMMITTED + step 永远 planned」落盘,
134
+ // 事后读台账的人无法区分「已成功执行」与「根本没跑」(本机 0.1.78 事故现场正是这种形态)。
135
+ assert.equal(j!.steps['ext']?.status, 'done', '外部步骤成功后必须标 done');
136
+ assert.equal(typeof j!.steps['ext']?.appliedAt, 'string', '外部步骤成功后必须有 appliedAt');
133
137
  assert.deepEqual(await recovery.store.scanActive(), []);
134
138
  });
135
139
 
@@ -6,6 +6,7 @@ import test from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
7
  import {
8
8
  classifyDshPluginFailure, dshArgv, installAnchorFromProfileContext, installErrorFor, isTransientDshPluginFailure,
9
+ pluginInstallTarget,
9
10
  profileNameFromArgv, profileNameFromDir, profileNameFromProfileContext,
10
11
  resolveProcessProfileName, resolveProfileNameFromEnv,
11
12
  type DshPluginResult,
@@ -199,6 +200,31 @@ const CARRIER: DesktopCarrier = {
199
200
  env: { ELECTRON_RUN_AS_NODE: '1' },
200
201
  };
201
202
 
203
+ test('pluginInstallTarget:安装目标决策表(issue #101 空转修复)', () => {
204
+ const cases: { name: string; pkg: string; input: { version?: string | undefined; spec?: string | undefined; tarballPath?: string | undefined }; expected: string }[] = [
205
+ { name: '备份内 tarball 优先于版本与 spec', pkg: 'p', input: { version: '1.0.0', spec: '^2.0.0', tarballPath: '/h/plugins/p.tgz' }, expected: 'file:/h/plugins/p.tgz' },
206
+ { name: '空 tarball 视为没有(回落 spec)', pkg: 'p', input: { tarballPath: '', spec: 'github:u/r' }, expected: 'github:u/r' },
207
+ { name: '非 registry 来源(github:)原样,手里的 version 不再拼', pkg: 'p', input: { version: '1.0.0', spec: 'github:u/r' }, expected: 'github:u/r' },
208
+ { name: '非 registry 来源(file:)原样', pkg: 'p', input: { spec: 'file:../local-p' }, expected: 'file:../local-p' },
209
+ {
210
+ name: '具体 semver → 精确版本(修复升级空转)',
211
+ pkg: 'dsh-free-search',
212
+ input: { version: '0.9.5', spec: '0.9.3' },
213
+ expected: 'dsh-free-search@0.9.5',
214
+ },
215
+ { name: '预发布版本同样精确', pkg: 'p', input: { version: '1.0.0-rc.2' }, expected: 'p@1.0.0-rc.2' },
216
+ { name: '带 build 元数据同样精确', pkg: 'p', input: { version: '1.0.0+build.7' }, expected: 'p@1.0.0+build.7' },
217
+ { name: 'version 不是具体版本 → 落到 spec', pkg: 'p', input: { version: 'latest', spec: '^1.2.3' }, expected: 'p@^1.2.3' },
218
+ { name: 'registry 区间 spec 带包名(手交给 pnpm 解析)', pkg: 'dshmarket', input: { spec: '^1.66.38' }, expected: 'dshmarket@^1.66.38' },
219
+ { name: 'dist-tag spec 带包名', pkg: 'p', input: { spec: 'next' }, expected: 'p@next' },
220
+ { name: '旧备份:version / spec 全缺 → 裸包名(改造前行为)', pkg: 'p', input: {}, expected: 'p' },
221
+ { name: '空字符串按缺省处理', pkg: 'p', input: { version: '', spec: '' }, expected: 'p' },
222
+ ];
223
+ for (const c of cases) {
224
+ assert.equal(pluginInstallTarget(c.pkg, c.input), c.expected, c.name);
225
+ }
226
+ });
227
+
202
228
  test('dshArgv:desktop 档案走桌面端 CLI 载体(普通 dsh CLI 对它硬拒绝)', () => {
203
229
  const argv = dshArgv('desktop', CARRIER);
204
230
  assert.equal(argv.file, CARRIER.execPath, '必须用 Electron 主程序(Node 模式)启动载体');
@@ -15,7 +15,8 @@
15
15
  * 只依赖 node 内置模块,零 DSH 运行时依赖(M5 的 TDD 可直接单测本模块)。
16
16
  */
17
17
 
18
- import { engineMsg } from './messages-core.ts';
18
+ import { engineMsg } from './messages-core.ts'
19
+ import { isConcreteVersion } from './validator.ts';
19
20
  import { spawn } from 'node:child_process'
20
21
  import type { ChildProcess, SpawnOptions } from 'node:child_process'
21
22
  import { readFileSync, rmSync } from 'node:fs'
@@ -414,13 +415,38 @@ const INSTALL_TIMEOUT_MS = Number(process.env.DSH_CONFIG_MANAGER_INSTALL_TIMEOUT
414
415
  /**
415
416
  * 非 registry 来源的依赖 spec 前缀:这些来源必须把 spec 原样交给 pnpm add,
416
417
  * 裸包名在 registry 上查不到(fetch-404 → 幽灵依赖拖垮整个 profile 的安装)。
417
- * 版本区间(^x / x.y.z)与裸包名走默认语义:npm 最新版(官方机制,见 §34.17 设计决策)。
418
+ * registry 包由 `pluginInstallTarget()` 拼精确版本或「包名@spec」(issue #101)。
418
419
  */
419
420
  const NON_REGISTRY_SPEC = /^(github:|gitlab:|bitbucket:|git\+|file:|link:|workspace:|https?:)/i
420
421
 
421
- /** 安装目标:非 registry spec 按 spec 安装;其余按裸包名(npm 最新版)。 */
422
- export function installSpecFor(pkg: string, spec?: string): string {
422
+ /** `pluginInstallTarget()` 的输入:全部来自备份里的 `PluginEntry`(都可缺省 = 旧备份)。 */
423
+ export interface PluginInstallTargetInput {
424
+ /** 备份记录的实际安装版本(`PluginEntry.version`):具体 semver 时精确安装到它(issue #101)。 */
425
+ version?: string | undefined
426
+ /** 备份记录的声明依赖 spec(`PluginEntry.spec`):非 registry 来源原样,registry 区间带包名。 */
427
+ spec?: string | undefined
428
+ /** 备份内自带 tarball 解包后的绝对路径:优先于 spec(T1 本地源插件迁移)。 */
429
+ tarballPath?: string | undefined
430
+ }
431
+
432
+ /**
433
+ * 安装目标:可直接交给 `dsh plugin add` 的**完整参数**(issue #101)。
434
+ *
435
+ * 为什么不能只传裸包名:pnpm add 会尊重目标机 `package.json` 里既有的 spec —— 固定版不动、
436
+ * 已经满足的区间也不动,于是「备份里记着 0.9.5、目标机装着 0.9.3」的升级会**空转却报成功**。
437
+ * 规则按优先级排列(`src/core/plugin-cli.test.ts` 表驱动钉住):
438
+ * ① 备份内 tarball → `file:<abs>`(换机后原绝对路径必然失效);
439
+ * ② 非 registry 来源(github:/git+/file:/link:)→ spec 原样(裸包名在 registry 上查不到);
440
+ * ③ `version` 是具体 semver → `<pkg>@<version>`(精确版本,pnpm 无法被既有 spec 挡住);
441
+ * ④ 其余 spec(`^1.2.3` / dist-tag)→ `<pkg>@<spec>`(交给 pnpm 解析,至少不会退回裸包名);
442
+ * ⑤ 什么都没有(旧备份 / 无版本记录)→ 裸包名(npm 最新版,改造前行为)。
443
+ */
444
+ export function pluginInstallTarget(pkg: string, input: PluginInstallTargetInput = {}): string {
445
+ const { version, spec, tarballPath } = input
446
+ if (tarballPath !== undefined && tarballPath !== '') return `file:${tarballPath}`
423
447
  if (spec !== undefined && spec !== '' && NON_REGISTRY_SPEC.test(spec)) return spec
448
+ if (version !== undefined && isConcreteVersion(version)) return `${pkg}@${version}`
449
+ if (spec !== undefined && spec !== '') return `${pkg}@${spec}`
424
450
  return pkg
425
451
  }
426
452
 
@@ -16,7 +16,9 @@ import path from 'node:path';
16
16
 
17
17
  import { createRecoveryOrchestrator } from './recovery-orchestrator.ts';
18
18
  import type { RecoveryOrchestratorDeps } from './recovery-orchestrator.ts';
19
- import { JournalStore } from './journal.ts';
19
+ import {
20
+ JournalStore, createJournalEntry, generateOperationId, transitionJournalState,
21
+ } from './journal.ts';
20
22
  import { RunRegistry } from './run-registry.ts';
21
23
  import { nullLogger } from '../utils/logger.ts';
22
24
  import { zhMsg } from './messages.ts';
@@ -32,15 +34,22 @@ async function makeOrchestrator(opts: {
32
34
  lockState?: string;
33
35
  lockDetail?: string;
34
36
  lockThrows?: boolean;
37
+ lockOwner?: string;
35
38
  recoverResult?: { ok: boolean; removed: boolean; state: string; detail?: string };
36
- } = {}): Promise<{ orchestrator: ReturnType<typeof createRecoveryOrchestrator>; spy: RecoverSpy }> {
39
+ } = {}): Promise<{
40
+ orchestrator: ReturnType<typeof createRecoveryOrchestrator>;
41
+ spy: RecoverSpy;
42
+ /** 供用例预置 active journal(模拟「正在执行中的操作」)。 */
43
+ store: JournalStore;
44
+ }> {
37
45
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-recovery-orch-'));
38
46
  const spy: RecoverSpy = {
39
47
  calls: 0,
40
48
  result: opts.recoverResult ?? { ok: true, removed: true, state: 'STALE_LOCK_DETECTED', detail: '已移除 stale ownership (op=autosync, pid=24140)' },
41
49
  };
50
+ const store = new JournalStore({ transactionsDir: path.join(dir, 'transactions') });
42
51
  const deps: RecoveryOrchestratorDeps = {
43
- store: new JournalStore({ transactionsDir: path.join(dir, 'transactions') }),
52
+ store,
44
53
  runs: new RunRegistry(),
45
54
  snapshotsDir: path.join(dir, 'snapshots'),
46
55
  host: { log: nullLogger() } as unknown as HostContext,
@@ -50,14 +59,18 @@ async function makeOrchestrator(opts: {
50
59
  clearSafeMode: async () => undefined,
51
60
  inspectLockState: async () => {
52
61
  if (opts.lockThrows === true) throw new Error('probe failed');
53
- return { state: opts.lockState ?? 'FREE', ...(opts.lockDetail !== undefined ? { detail: opts.lockDetail } : {}) };
62
+ return {
63
+ state: opts.lockState ?? 'FREE',
64
+ ...(opts.lockDetail !== undefined ? { detail: opts.lockDetail } : {}),
65
+ ...(opts.lockOwner !== undefined ? { ownerInstanceId: opts.lockOwner } : {}),
66
+ };
54
67
  },
55
68
  recoverStaleLock: async () => {
56
69
  spy.calls += 1;
57
70
  return spy.result;
58
71
  },
59
72
  };
60
- return { orchestrator: createRecoveryOrchestrator(deps), spy };
73
+ return { orchestrator: createRecoveryOrchestrator(deps), spy, store };
61
74
  }
62
75
 
63
76
  // ---------- ① 纯锁残留场景:incidents 为空,但锁事项必须可见 ----------
@@ -190,3 +203,89 @@ test('源码守卫:/recovery/lock/recover 分支先于 operationId 解析,
190
203
  assert.ok(ctorBody.includes('inspectLockState:'), 'createRecoveryOrchestrator 必须注入 inspectLockState');
191
204
  assert.ok(ctorBody.includes('recoverStaleLock:'), 'createRecoveryOrchestrator 必须注入 recoverStaleLock');
192
205
  });
206
+
207
+
208
+ // ---------- ④ 正在正常执行的操作 vs 未完成的操作(本轮修复的核心口径) ----------
209
+
210
+ /**
211
+ * 事故现场(2026-10-21,本机 0.1.78):autosync push 的 journal 在执行期间存在于
212
+ * `transactions/active/`(CREATED→APPLYING),status() 把它当 incident 上报 ⇒
213
+ * 面板显示「需要人工处理 + 无可信快照」,顶部冒出「已进入安全模式」,「执行恢复」永久灰;
214
+ * 唯一出口 dismiss 又要 GLOBAL 锁,而锁正被同一个操作持有 ⇒ 点一次 423 一次。
215
+ * 下面四条把「什么算正在跑」钉死。
216
+ */
217
+
218
+ const LIVE_OWNER = '44c2454fe7c1865e8771e294a5955e20';
219
+
220
+ /** 预置一条 active journal(默认 CREATED,模拟刚开始的外部操作)。 */
221
+ async function seedActiveJournal(
222
+ store: JournalStore,
223
+ ownerInstanceId: string,
224
+ opts: { operationType?: string; needsAttention?: boolean } = {},
225
+ ): Promise<string> {
226
+ await store.ensureDirs();
227
+ const operationId = generateOperationId();
228
+ const now = new Date().toISOString();
229
+ await store.create(createJournalEntry(opts.operationType ?? 'autosync-push', {
230
+ operationId, ownerInstanceId, lockId: ownerInstanceId,
231
+ packageVersion: '0.1.78', environmentFingerprint: 'fp-test',
232
+ }, now));
233
+ if (opts.needsAttention === true) {
234
+ await store.update(operationId, (j) => transitionJournalState(j, 'NEEDS_ATTENTION'));
235
+ }
236
+ return operationId;
237
+ }
238
+
239
+ test('status:锁持有者 == journal.ownerInstanceId(正在跑)→ 不进 incidents,单列进 live', async () => {
240
+ const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: LIVE_OWNER });
241
+ const opId = await seedActiveJournal(store, LIVE_OWNER);
242
+ const r = await orchestrator.status();
243
+ assert.equal(r.status, 200);
244
+ assert.deepEqual(r.body['incidents'], [], '正在正常执行的操作不是「未完成的操作」——进 incidents 就会伪造假事故与假 SAFE MODE 横幅');
245
+ const live = r.body['live'] as Array<Record<string, unknown>>;
246
+ assert.equal(live.length, 1);
247
+ assert.equal(live[0]!['operationId'], opId);
248
+ assert.equal(live[0]!['operationType'], 'autosync-push');
249
+ assert.equal(live[0]!['state'], 'CREATED');
250
+ assert.ok(typeof live[0]!['startedAt'] === 'string', 'live 必须带开始时间(面板要显示它跑了多久)');
251
+ // 锁仍是 LOCKED(活跃持有)→ attention=false,不催用户去「回收」一把活锁
252
+ assert.deepEqual(r.body['lock'], { state: 'LOCKED', attention: false });
253
+ });
254
+
255
+ test('status:journal.ownerInstanceId 与锁持有者不一致 → 仍按 incident 上报(保守)', async () => {
256
+ const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: 'someone-else' });
257
+ await seedActiveJournal(store, LIVE_OWNER);
258
+ const r = await orchestrator.status();
259
+ const incidents = r.body['incidents'] as Array<Record<string, unknown>>;
260
+ assert.equal(incidents.length, 1, '归属不一致就不能假定它正在跑(可能是崩溃残骸)');
261
+ assert.equal(incidents[0]!['operationType'], 'autosync-push');
262
+ assert.deepEqual(r.body['live'], []);
263
+ });
264
+
265
+ test('status:锁未持有(FREE)→ 同一 journal 仍按 incident 上报(无人能证明它还在跑)', async () => {
266
+ const { orchestrator, store } = await makeOrchestrator({ lockState: 'FREE' });
267
+ await seedActiveJournal(store, LIVE_OWNER);
268
+ const r = await orchestrator.status();
269
+ assert.equal((r.body['incidents'] as unknown[]).length, 1);
270
+ assert.deepEqual(r.body['live'], []);
271
+ });
272
+
273
+ test('status:NEEDS_ATTENTION 即使归属匹配也不得被吞(它是明确的「需人工处理」)', async () => {
274
+ const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: LIVE_OWNER });
275
+ await seedActiveJournal(store, LIVE_OWNER, { needsAttention: true });
276
+ const r = await orchestrator.status();
277
+ const incidents = r.body['incidents'] as Array<Record<string, unknown>>;
278
+ assert.equal(incidents.length, 1);
279
+ assert.equal(incidents[0]!['state'], 'NEEDS_ATTENTION');
280
+ assert.deepEqual(r.body['live'], [], '需人工处理的事项绝不能伪装成「正在跑」');
281
+ });
282
+
283
+ test('dismiss:正在跑的操作 → 409 operation-in-flight(不 quarantine 在跑的 journal)', async () => {
284
+ const { orchestrator, store } = await makeOrchestrator({ lockState: 'LOCKED', lockOwner: LIVE_OWNER });
285
+ const opId = await seedActiveJournal(store, LIVE_OWNER);
286
+ const r = await orchestrator.dismiss(opId, true);
287
+ assert.equal(r.status, 409);
288
+ assert.equal(r.body['code'], 'operation-in-flight');
289
+ const active = await store.scanActive();
290
+ assert.deepEqual(active, [opId], '拒绝时必须原地不动:把在跑的 journal 挪走会让执行方后续 transition 失败');
291
+ });
@@ -76,9 +76,10 @@ export interface RecoveryOrchestratorDeps {
76
76
  /**
77
77
  * 只读环境锁状态(issue #31)。**由宿主注入而非本模块 import**:本模块不依赖 env-lock,
78
78
  * 无锁环境(测试 mock 端口)可返回保守的 UNKNOWN_STATE。
79
- * detail 含 owner pid/op 等内部诊断 → 只用于日志,绝不进响应体。
79
+ * detail / ownerInstanceId 含 owner pid/op/实例标识等内部诊断 → 只用于日志与
80
+ * **本模块内部**的 journal↔锁归属匹配,绝不进响应体。
80
81
  */
81
- inspectLockState: () => Promise<{ state: string; detail?: string }>;
82
+ inspectLockState: () => Promise<{ state: string; detail?: string; ownerInstanceId?: string }>;
82
83
  /**
83
84
  * 显式回收 stale 残留锁(EnvironmentLockManager.recoverStaleLock)。
84
85
  * **调用方不得先 acquire 锁**:要回收的正是挡住 acquire 的那把锁,先取锁必然失败。
@@ -118,6 +119,35 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
118
119
  const { store, runs, snapshotsDir, host, snapshotExists, getEnvironmentFingerprint, clearSafeMode, inspectLockState, recoverStaleLock } = deps;
119
120
  const safeModeBlocked = deps.safeModeBlocked ?? ((): boolean => false);
120
121
 
122
+ /**
123
+ * 该 active journal 是否属于「正在正常执行中的外部操作」(in-flight),而不是
124
+ * 「一次未正常完成的操作」(incident)。
125
+ *
126
+ * 为什么必须区分:autosync push 的 journal 在整个执行期间存在于 `transactions/active/`
127
+ * (state=CREATED→APPLYING,成功后直接 moveToCompleted)。旧 status() 把所有非 terminal
128
+ * 的 active journal 一律当 incident 上报 ⇒ 正在跑的 push 被显示成「需要人工处理」的事故,
129
+ * 顶部冒出「配置修改已被保护」+「已进入安全模式」,而这类 journal 没有 snapshotId、
130
+ * decision 恒为 needs-attention ⇒ 「执行恢复」永久灰;唯一出口 dismiss 又要 GLOBAL 锁,
131
+ * 而锁正被同一个操作持着 ⇒ 点一次 423 一次。用户界面上是个纯粹的死胡同。
132
+ *
133
+ * 判据三合一,任一不成立就保守地仍按 incident 上报:
134
+ * 1. state 非 terminal 且非 NEEDS_ATTENTION(后者是明确的「需人工处理」);
135
+ * 2. 环境锁 state === 'LOCKED'(另一任务活跃持有;STALE/UNKNOWN 都不可当作「有人在跑」);
136
+ * 3. journal.ownerInstanceId === 锁持有者的 owner.instanceId —— 证明持锁的实例**就是**
137
+ * 写这条 journal 的实例,即它正在跑,而非上次崩溃的残骸。
138
+ *
139
+ * 不用时间窗启发式:op 执行期间 journal 本来就可能长时间不写(外部步骤无中途落盘),
140
+ * 任何「多久没更新算卡住」的阈值都会误伤慢速 push。
141
+ */
142
+ const isLiveInFlight = (j: OperationJournal, lock: { state: string; ownerInstanceId?: string } | undefined): boolean => {
143
+ if (lock === undefined || lock.state !== 'LOCKED') return false;
144
+ if (isTerminalState(j.state) || j.state === 'NEEDS_ATTENTION') return false;
145
+ const holder = lock.ownerInstanceId;
146
+ if (holder === undefined || holder === '') return false;
147
+ const owner = j.ownerInstanceId;
148
+ return owner !== null && owner !== '' && owner === holder;
149
+ };
150
+
121
151
  /**
122
152
  * 只读 recovery decision(不修改 journal)。**不用 reconcileActive**:其 §6.5 硬门控会把
123
153
  * RECOVERING journal 迁移到 NEEDS_ATTENTION,若 status 轮询时触发会破坏 execute→verify 流程。
@@ -214,15 +244,44 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
214
244
  // 「解除安全模式」入口,而不是显示「暂无需要处理的恢复事项」然后继续 423。
215
245
  const blocked = safeModeBlocked();
216
246
  const clearable = blocked ? await resolveSafeMode(true) : false;
247
+ // issue #31:附带环境锁分类。**只暴露分类,不暴露 owner pid/op/hostname**(内部诊断)。
248
+ // attention 只对「需用户显式处理」的状态为 true:STALE_LOCK_DETECTED(残留锁,重试不会自愈)
249
+ // 与 UNKNOWN_STATE(无法判定)→ 引导用户回收;LOCKED(另一任务活跃持有)会自行释放,
250
+ // 不该催用户去「回收」,否则又是一个误导。探测失败 → 保守 UNKNOWN_STATE + attention。
251
+ //
252
+ // 探测必须**先于** incident 分类:LOCKED 且持有者 == journal.ownerInstanceId 时该 journal
253
+ // 是「正在跑」,不该进 incidents(解释见 isLiveInFlight)。
254
+ let lock: { state: string; attention: boolean } | undefined;
255
+ let lockInsp: { state: string; ownerInstanceId?: string } | undefined;
256
+ try {
257
+ const insp = await inspectLockState();
258
+ lockInsp = insp.ownerInstanceId !== undefined
259
+ ? { state: insp.state, ownerInstanceId: insp.ownerInstanceId }
260
+ : { state: insp.state };
261
+ lock = {
262
+ state: insp.state,
263
+ attention: insp.state === 'STALE_LOCK_DETECTED' || insp.state === 'UNKNOWN_STATE',
264
+ };
265
+ } catch {
266
+ lock = { state: 'UNKNOWN_STATE', attention: true };
267
+ }
217
268
  const activeIds = await store.scanActive();
218
269
  const incidents: Array<{
219
270
  operationId: string; operationType: string; state: string; decision: string;
220
271
  snapshotId: string | null; reason: string; createdAt: string;
221
272
  }> = [];
273
+ /** 正在正常执行的操作(不是 incident):面板据此解释「为什么现在什么都点不动」。 */
274
+ const live: Array<{ operationId: string; operationType: string; state: string; startedAt: string }> = [];
222
275
  for (const opId of activeIds) {
223
276
  const j = await store.loadActive(opId);
224
277
  if (j === null) continue;
225
278
  if (isTerminalState(j.state) && j.state !== 'NEEDS_ATTENTION') continue; // COMMITTED/ROLLED_BACK/RECOVERED → 非 incident
279
+ // 正在跑的操作:既不进 incidents(否则伪装成事故 + 假 SAFE MODE 横幅),
280
+ // 也不进 running(那是 recovery run),单列进 live 供界面如实说明。
281
+ if (isLiveInFlight(j, lockInsp)) {
282
+ live.push({ operationId: opId, operationType: j.operationType, state: j.state, startedAt: j.createdAt });
283
+ continue;
284
+ }
226
285
  const d = await decision(j);
227
286
  incidents.push({
228
287
  operationId: opId,
@@ -235,23 +294,10 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
235
294
  });
236
295
  }
237
296
  const running = runs.listActive().filter((r) => r.kind === 'recovery').map((r) => ({ runId: r.runId, status: r.status }));
238
- // issue #31:附带环境锁分类。**只暴露分类,不暴露 owner pid/op/hostname**(内部诊断)。
239
- // attention 只对「需用户显式处理」的状态为 true:STALE_LOCK_DETECTED(残留锁,重试不会自愈)
240
- // 与 UNKNOWN_STATE(无法判定)→ 引导用户回收;LOCKED(另一任务活跃持有)会自行释放,
241
- // 不该催用户去「回收」,否则又是一个误导。探测失败 → 保守 UNKNOWN_STATE + attention。
242
- let lock: { state: string; attention: boolean } | undefined;
243
- try {
244
- const insp = await inspectLockState();
245
- lock = {
246
- state: insp.state,
247
- attention: insp.state === 'STALE_LOCK_DETECTED' || insp.state === 'UNKNOWN_STATE',
248
- };
249
- } catch {
250
- lock = { state: 'UNKNOWN_STATE', attention: true };
251
- }
252
297
  // issue #56:SAFE MODE 阻断态如实回传。`clearable` = 「结案但保护仍开着」
253
298
  // (durable 标记在、没有任何未解决 incident)—— 界面据此渲染显式解除入口。
254
- return { status: 200, body: { incidents, running, lock, safeMode: { blocked, clearable } } };
299
+ // live 是**加法字段**:旧客户端读不到就按「无进行中操作」处理,不影响既有渲染。
300
+ return { status: 200, body: { incidents, running, live, lock, safeMode: { blocked, clearable } } };
255
301
  },
256
302
 
257
303
  async preview(operationId) {
@@ -366,6 +412,17 @@ export function createRecoveryOrchestrator(deps: RecoveryOrchestratorDeps): Reco
366
412
  if (userConfirmed !== true) return { status: 400, body: { error: 'userConfirmed required' } };
367
413
  const j = await store.loadActive(operationId);
368
414
  if (j === null) return { status: 404, body: { error: 'operation not found' } };
415
+ // 正在执行的操作不能「放弃」:它不是事故,quarantine 会把在跑的那条 journal 从 active/
416
+ // 抽走,让执行方后续 transition 失败。正常情况下这条路径走不到 —— 路由的 GLOBAL 锁
417
+ // 会先以 423 挡下;这里是防御性判据(锁端口未接线/跨进程判定差异时仍能给出准确原因,
418
+ // 而不是笼统的「另一个任务正在运行」)。
419
+ let lockForDismiss: { state: string; ownerInstanceId?: string } | undefined;
420
+ try {
421
+ lockForDismiss = await inspectLockState();
422
+ } catch { /* 探测失败 → 不拦(保持旧行为) */ }
423
+ if (isLiveInFlight(j, lockForDismiss)) {
424
+ return { status: 409, body: { error: engineMsg('core.recovery.opInFlight', { state: j.state }), code: 'operation-in-flight' } };
425
+ }
369
426
  // dismiss → quarantine(用户放弃;不删除 snapshot/journal evidence,不强行 RECOVERED)
370
427
  const result = await executeRecovery(store, { operationId, action: 'dismiss', snapshotId: j.snapshotId }, true);
371
428
  if (result === 'failed') return { status: 400, body: { error: engineMsg('core.recovery.dismissFailed') } };