@cleocode/core 2026.10.1 → 2026.10.2

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 (385) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/cloud/journal.d.ts +9 -5
  3. package/dist/cloud/journal.d.ts.map +1 -1
  4. package/dist/cloud/journal.js +29 -8
  5. package/dist/cloud/journal.js.map +1 -1
  6. package/dist/cloud/manifest-check.d.ts +140 -4
  7. package/dist/cloud/manifest-check.d.ts.map +1 -1
  8. package/dist/cloud/manifest-check.js +309 -10
  9. package/dist/cloud/manifest-check.js.map +1 -1
  10. package/dist/cloud/nexus-attach.d.ts +25 -0
  11. package/dist/cloud/nexus-attach.d.ts.map +1 -1
  12. package/dist/cloud/nexus-attach.js +81 -6
  13. package/dist/cloud/nexus-attach.js.map +1 -1
  14. package/dist/cloud/nexus-cloud-activity.d.ts +37 -0
  15. package/dist/cloud/nexus-cloud-activity.d.ts.map +1 -0
  16. package/dist/cloud/nexus-cloud-activity.js +79 -0
  17. package/dist/cloud/nexus-cloud-activity.js.map +1 -0
  18. package/dist/cloud/nexus-cloud-status.d.ts.map +1 -1
  19. package/dist/cloud/nexus-cloud-status.js +36 -0
  20. package/dist/cloud/nexus-cloud-status.js.map +1 -1
  21. package/dist/cloud/nexus-device.d.ts +1 -1
  22. package/dist/cloud/nexus-enrol.d.ts.map +1 -1
  23. package/dist/cloud/nexus-enrol.js +5 -0
  24. package/dist/cloud/nexus-enrol.js.map +1 -1
  25. package/dist/cloud/nexus-home.d.ts +74 -0
  26. package/dist/cloud/nexus-home.d.ts.map +1 -0
  27. package/dist/cloud/nexus-home.js +97 -0
  28. package/dist/cloud/nexus-home.js.map +1 -0
  29. package/dist/cloud/nexus-vault-keys.d.ts +129 -0
  30. package/dist/cloud/nexus-vault-keys.d.ts.map +1 -0
  31. package/dist/cloud/nexus-vault-keys.js +277 -0
  32. package/dist/cloud/nexus-vault-keys.js.map +1 -0
  33. package/dist/cloud/nexus-vault-state.d.ts +121 -0
  34. package/dist/cloud/nexus-vault-state.d.ts.map +1 -0
  35. package/dist/cloud/nexus-vault-state.js +274 -0
  36. package/dist/cloud/nexus-vault-state.js.map +1 -0
  37. package/dist/cloud/nexus-vault.d.ts +160 -0
  38. package/dist/cloud/nexus-vault.d.ts.map +1 -0
  39. package/dist/cloud/nexus-vault.js +1733 -0
  40. package/dist/cloud/nexus-vault.js.map +1 -0
  41. package/dist/cloud/signing.d.ts +24 -8
  42. package/dist/cloud/signing.d.ts.map +1 -1
  43. package/dist/cloud/signing.js +61 -15
  44. package/dist/cloud/signing.js.map +1 -1
  45. package/dist/docs/export-document.js +8137 -3318
  46. package/dist/docs/export-document.js.map +3 -3
  47. package/dist/doctor/migrations.d.ts +75 -0
  48. package/dist/doctor/migrations.d.ts.map +1 -0
  49. package/dist/doctor/migrations.js +136 -0
  50. package/dist/doctor/migrations.js.map +1 -0
  51. package/dist/doctor/sync-sealer.d.ts +48 -0
  52. package/dist/doctor/sync-sealer.d.ts.map +1 -0
  53. package/dist/doctor/sync-sealer.js +109 -0
  54. package/dist/doctor/sync-sealer.js.map +1 -0
  55. package/dist/doctor/sync-triggers.d.ts +53 -0
  56. package/dist/doctor/sync-triggers.d.ts.map +1 -0
  57. package/dist/doctor/sync-triggers.js +157 -0
  58. package/dist/doctor/sync-triggers.js.map +1 -0
  59. package/dist/init.d.ts.map +1 -1
  60. package/dist/init.js +5 -1
  61. package/dist/init.js.map +1 -1
  62. package/dist/internal.d.ts +1 -1
  63. package/dist/internal.d.ts.map +1 -1
  64. package/dist/internal.js +1 -1
  65. package/dist/internal.js.map +1 -1
  66. package/dist/llm/plugin-facade.js +8442 -3623
  67. package/dist/llm/plugin-facade.js.map +3 -3
  68. package/dist/nexus/projects-clean.d.ts +3 -0
  69. package/dist/nexus/projects-clean.d.ts.map +1 -1
  70. package/dist/nexus/projects-clean.js +9 -3
  71. package/dist/nexus/projects-clean.js.map +1 -1
  72. package/dist/nexus/registry-roots.d.ts +3 -1
  73. package/dist/nexus/registry-roots.d.ts.map +1 -1
  74. package/dist/nexus/registry-roots.js +5 -2
  75. package/dist/nexus/registry-roots.js.map +1 -1
  76. package/dist/nexus/registry.d.ts +8 -1
  77. package/dist/nexus/registry.d.ts.map +1 -1
  78. package/dist/nexus/registry.js +24 -10
  79. package/dist/nexus/registry.js.map +1 -1
  80. package/dist/nexus/workspace.d.ts +4 -1
  81. package/dist/nexus/workspace.d.ts.map +1 -1
  82. package/dist/nexus/workspace.js +10 -4
  83. package/dist/nexus/workspace.js.map +1 -1
  84. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  85. package/dist/orchestrate/spawn-ops.js +7 -2
  86. package/dist/orchestrate/spawn-ops.js.map +1 -1
  87. package/dist/orchestrate/worker-verify.d.ts +121 -12
  88. package/dist/orchestrate/worker-verify.d.ts.map +1 -1
  89. package/dist/orchestrate/worker-verify.js +172 -21
  90. package/dist/orchestrate/worker-verify.js.map +1 -1
  91. package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
  92. package/dist/orchestration/spawn-prompt.js +15 -9
  93. package/dist/orchestration/spawn-prompt.js.map +1 -1
  94. package/dist/release/ci-evidence.d.ts +9 -0
  95. package/dist/release/ci-evidence.d.ts.map +1 -1
  96. package/dist/release/ci-evidence.js +9 -2
  97. package/dist/release/ci-evidence.js.map +1 -1
  98. package/dist/resources/backend.d.ts +15 -3
  99. package/dist/resources/backend.d.ts.map +1 -1
  100. package/dist/resources/backend.js +7 -3
  101. package/dist/resources/backend.js.map +1 -1
  102. package/dist/resources/darwin-backend.d.ts +142 -0
  103. package/dist/resources/darwin-backend.d.ts.map +1 -0
  104. package/dist/resources/darwin-backend.js +288 -0
  105. package/dist/resources/darwin-backend.js.map +1 -0
  106. package/dist/resources/governor.d.ts +44 -1
  107. package/dist/resources/governor.d.ts.map +1 -1
  108. package/dist/resources/governor.js +100 -36
  109. package/dist/resources/governor.js.map +1 -1
  110. package/dist/resources/heavy-command.d.ts +132 -0
  111. package/dist/resources/heavy-command.d.ts.map +1 -0
  112. package/dist/resources/heavy-command.js +768 -0
  113. package/dist/resources/heavy-command.js.map +1 -0
  114. package/dist/resources/monitor.d.ts +50 -1
  115. package/dist/resources/monitor.d.ts.map +1 -1
  116. package/dist/resources/monitor.js +83 -1
  117. package/dist/resources/monitor.js.map +1 -1
  118. package/dist/resources/run-admission.d.ts +303 -0
  119. package/dist/resources/run-admission.d.ts.map +1 -0
  120. package/dist/resources/run-admission.js +648 -0
  121. package/dist/resources/run-admission.js.map +1 -0
  122. package/dist/resources/run-class.d.ts +111 -0
  123. package/dist/resources/run-class.d.ts.map +1 -0
  124. package/dist/resources/run-class.js +658 -0
  125. package/dist/resources/run-class.js.map +1 -0
  126. package/dist/resources/run-governed.d.ts +158 -0
  127. package/dist/resources/run-governed.d.ts.map +1 -0
  128. package/dist/resources/run-governed.js +406 -0
  129. package/dist/resources/run-governed.js.map +1 -0
  130. package/dist/resources/slot-holder.d.ts +267 -0
  131. package/dist/resources/slot-holder.d.ts.map +1 -0
  132. package/dist/resources/slot-holder.js +505 -0
  133. package/dist/resources/slot-holder.js.map +1 -0
  134. package/dist/resources/tool-groups.d.ts +74 -0
  135. package/dist/resources/tool-groups.d.ts.map +1 -0
  136. package/dist/resources/tool-groups.js +118 -0
  137. package/dist/resources/tool-groups.js.map +1 -0
  138. package/dist/routing/build-command-groups.d.ts.map +1 -1
  139. package/dist/routing/build-command-groups.js +1 -0
  140. package/dist/routing/build-command-groups.js.map +1 -1
  141. package/dist/selfimprove/fix-gen.js +8446 -3627
  142. package/dist/selfimprove/fix-gen.js.map +3 -3
  143. package/dist/sentient/cross-project-hygiene.d.ts +3 -2
  144. package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
  145. package/dist/sentient/cross-project-hygiene.js +5 -2
  146. package/dist/sentient/cross-project-hygiene.js.map +1 -1
  147. package/dist/sentient/state.d.ts +7 -0
  148. package/dist/sentient/state.d.ts.map +1 -1
  149. package/dist/sentient/state.js.map +1 -1
  150. package/dist/sentient/tick.d.ts +14 -2
  151. package/dist/sentient/tick.d.ts.map +1 -1
  152. package/dist/sentient/tick.js +63 -4
  153. package/dist/sentient/tick.js.map +1 -1
  154. package/dist/store/backup-recover.d.ts.map +1 -1
  155. package/dist/store/backup-recover.js +5 -0
  156. package/dist/store/backup-recover.js.map +1 -1
  157. package/dist/store/data-accessor.d.ts +1 -0
  158. package/dist/store/data-accessor.d.ts.map +1 -1
  159. package/dist/store/data-accessor.js +7 -0
  160. package/dist/store/data-accessor.js.map +1 -1
  161. package/dist/store/dual-scope-db.d.ts +23 -1
  162. package/dist/store/dual-scope-db.d.ts.map +1 -1
  163. package/dist/store/dual-scope-db.js +126 -36
  164. package/dist/store/dual-scope-db.js.map +1 -1
  165. package/dist/store/exodus/recovery.d.ts.map +1 -1
  166. package/dist/store/exodus/recovery.js +15 -0
  167. package/dist/store/exodus/recovery.js.map +1 -1
  168. package/dist/store/legacy-tasks-lineage.d.ts.map +1 -1
  169. package/dist/store/legacy-tasks-lineage.js +5 -0
  170. package/dist/store/legacy-tasks-lineage.js.map +1 -1
  171. package/dist/store/lock.d.ts +8 -1
  172. package/dist/store/lock.d.ts.map +1 -1
  173. package/dist/store/lock.js +10 -2
  174. package/dist/store/lock.js.map +1 -1
  175. package/dist/store/migration-manager.d.ts +5 -0
  176. package/dist/store/migration-manager.d.ts.map +1 -1
  177. package/dist/store/migration-manager.js +11 -0
  178. package/dist/store/migration-manager.js.map +1 -1
  179. package/dist/store/migration-runner.d.ts +158 -0
  180. package/dist/store/migration-runner.d.ts.map +1 -0
  181. package/dist/store/migration-runner.js +493 -0
  182. package/dist/store/migration-runner.js.map +1 -0
  183. package/dist/store/portable-bundle-import.d.ts +15 -1
  184. package/dist/store/portable-bundle-import.d.ts.map +1 -1
  185. package/dist/store/portable-bundle-import.js +24 -4
  186. package/dist/store/portable-bundle-import.js.map +1 -1
  187. package/dist/store/portable-bundle-relocate.d.ts +43 -0
  188. package/dist/store/portable-bundle-relocate.d.ts.map +1 -1
  189. package/dist/store/portable-bundle-relocate.js +79 -5
  190. package/dist/store/portable-bundle-relocate.js.map +1 -1
  191. package/dist/store/portable-bundle-scan.d.ts +20 -0
  192. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  193. package/dist/store/portable-bundle-scan.js +38 -3
  194. package/dist/store/portable-bundle-scan.js.map +1 -1
  195. package/dist/store/portable-bundle.d.ts +33 -0
  196. package/dist/store/portable-bundle.d.ts.map +1 -1
  197. package/dist/store/portable-bundle.js +34 -7
  198. package/dist/store/portable-bundle.js.map +1 -1
  199. package/dist/store/row-identity-registry.d.ts +137 -7
  200. package/dist/store/row-identity-registry.d.ts.map +1 -1
  201. package/dist/store/row-identity-registry.js +364 -9
  202. package/dist/store/row-identity-registry.js.map +1 -1
  203. package/dist/store/row-identity.d.ts.map +1 -1
  204. package/dist/store/row-identity.js +9 -10
  205. package/dist/store/row-identity.js.map +1 -1
  206. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  207. package/dist/store/sqlite-data-accessor.js +10 -0
  208. package/dist/store/sqlite-data-accessor.js.map +1 -1
  209. package/dist/store/sqlite.d.ts +31 -0
  210. package/dist/store/sqlite.d.ts.map +1 -1
  211. package/dist/store/sqlite.js +5 -1
  212. package/dist/store/sqlite.js.map +1 -1
  213. package/dist/store/sync/capture.d.ts +203 -0
  214. package/dist/store/sync/capture.d.ts.map +1 -0
  215. package/dist/store/sync/capture.js +562 -0
  216. package/dist/store/sync/capture.js.map +1 -0
  217. package/dist/store/sync/clock-store.d.ts.map +1 -1
  218. package/dist/store/sync/clock-store.js +2 -0
  219. package/dist/store/sync/clock-store.js.map +1 -1
  220. package/dist/store/sync/fk-orphans.d.ts +133 -0
  221. package/dist/store/sync/fk-orphans.d.ts.map +1 -0
  222. package/dist/store/sync/fk-orphans.js +265 -0
  223. package/dist/store/sync/fk-orphans.js.map +1 -0
  224. package/dist/store/sync/flags.d.ts +7 -0
  225. package/dist/store/sync/flags.d.ts.map +1 -1
  226. package/dist/store/sync/flags.js +16 -0
  227. package/dist/store/sync/flags.js.map +1 -1
  228. package/dist/store/sync/hlc.d.ts.map +1 -1
  229. package/dist/store/sync/hlc.js +4 -0
  230. package/dist/store/sync/hlc.js.map +1 -1
  231. package/dist/store/sync/identity-fill.d.ts +45 -0
  232. package/dist/store/sync/identity-fill.d.ts.map +1 -0
  233. package/dist/store/sync/identity-fill.js +85 -0
  234. package/dist/store/sync/identity-fill.js.map +1 -0
  235. package/dist/store/sync/machinery.d.ts +27 -0
  236. package/dist/store/sync/machinery.d.ts.map +1 -0
  237. package/dist/store/sync/machinery.js +44 -0
  238. package/dist/store/sync/machinery.js.map +1 -0
  239. package/dist/store/sync/netting.d.ts +120 -0
  240. package/dist/store/sync/netting.d.ts.map +1 -0
  241. package/dist/store/sync/netting.js +336 -0
  242. package/dist/store/sync/netting.js.map +1 -0
  243. package/dist/store/sync/replica.d.ts +27 -2
  244. package/dist/store/sync/replica.d.ts.map +1 -1
  245. package/dist/store/sync/replica.js +47 -3
  246. package/dist/store/sync/replica.js.map +1 -1
  247. package/dist/store/sync/schema.d.ts +12 -0
  248. package/dist/store/sync/schema.d.ts.map +1 -1
  249. package/dist/store/sync/schema.js +21 -0
  250. package/dist/store/sync/schema.js.map +1 -1
  251. package/dist/store/sync/seal-backlog.d.ts +21 -0
  252. package/dist/store/sync/seal-backlog.d.ts.map +1 -0
  253. package/dist/store/sync/seal-backlog.js +24 -0
  254. package/dist/store/sync/seal-backlog.js.map +1 -0
  255. package/dist/store/sync/sealer-values.d.ts +34 -0
  256. package/dist/store/sync/sealer-values.d.ts.map +1 -0
  257. package/dist/store/sync/sealer-values.js +53 -0
  258. package/dist/store/sync/sealer-values.js.map +1 -0
  259. package/dist/store/sync/sealer.d.ts +145 -0
  260. package/dist/store/sync/sealer.d.ts.map +1 -0
  261. package/dist/store/sync/sealer.js +906 -0
  262. package/dist/store/sync/sealer.js.map +1 -0
  263. package/dist/store/sync/structural.d.ts +63 -0
  264. package/dist/store/sync/structural.d.ts.map +1 -0
  265. package/dist/store/sync/structural.js +180 -0
  266. package/dist/store/sync/structural.js.map +1 -0
  267. package/dist/store/sync/timestamps.d.ts +92 -0
  268. package/dist/store/sync/timestamps.d.ts.map +1 -0
  269. package/dist/store/sync/timestamps.js +228 -0
  270. package/dist/store/sync/timestamps.js.map +1 -0
  271. package/dist/store/sync/tombstones.d.ts +75 -0
  272. package/dist/store/sync/tombstones.d.ts.map +1 -0
  273. package/dist/store/sync/tombstones.js +103 -0
  274. package/dist/store/sync/tombstones.js.map +1 -0
  275. package/dist/store/sync/trigger-classes.d.ts +221 -0
  276. package/dist/store/sync/trigger-classes.d.ts.map +1 -0
  277. package/dist/store/sync/trigger-classes.js +441 -0
  278. package/dist/store/sync/trigger-classes.js.map +1 -0
  279. package/dist/store/sync/trigger-suspend-at-path.d.ts +23 -0
  280. package/dist/store/sync/trigger-suspend-at-path.d.ts.map +1 -0
  281. package/dist/store/sync/trigger-suspend-at-path.js +44 -0
  282. package/dist/store/sync/trigger-suspend-at-path.js.map +1 -0
  283. package/dist/store/sync/writer-version.d.ts +43 -0
  284. package/dist/store/sync/writer-version.d.ts.map +1 -0
  285. package/dist/store/sync/writer-version.js +90 -0
  286. package/dist/store/sync/writer-version.js.map +1 -0
  287. package/dist/store/table-classification.d.ts.map +1 -1
  288. package/dist/store/table-classification.js +102 -0
  289. package/dist/store/table-classification.js.map +1 -1
  290. package/dist/store/trigger-suspend-schema.d.ts +44 -0
  291. package/dist/store/trigger-suspend-schema.d.ts.map +1 -0
  292. package/dist/store/trigger-suspend-schema.js +25 -0
  293. package/dist/store/trigger-suspend-schema.js.map +1 -0
  294. package/dist/store/vault-manifest.d.ts +365 -0
  295. package/dist/store/vault-manifest.d.ts.map +1 -0
  296. package/dist/store/vault-manifest.js +812 -0
  297. package/dist/store/vault-manifest.js.map +1 -0
  298. package/dist/store/writer-lease.d.ts +31 -0
  299. package/dist/store/writer-lease.d.ts.map +1 -1
  300. package/dist/store/writer-lease.js +65 -2
  301. package/dist/store/writer-lease.js.map +1 -1
  302. package/dist/system/health.d.ts.map +1 -1
  303. package/dist/system/health.js +21 -0
  304. package/dist/system/health.js.map +1 -1
  305. package/dist/system/project-health.d.ts.map +1 -1
  306. package/dist/system/project-health.js +3 -1
  307. package/dist/system/project-health.js.map +1 -1
  308. package/dist/tasks/affected-packages.d.ts +58 -0
  309. package/dist/tasks/affected-packages.d.ts.map +1 -1
  310. package/dist/tasks/affected-packages.js +75 -6
  311. package/dist/tasks/affected-packages.js.map +1 -1
  312. package/dist/tasks/affected-scope.d.ts +150 -23
  313. package/dist/tasks/affected-scope.d.ts.map +1 -1
  314. package/dist/tasks/affected-scope.js +320 -32
  315. package/dist/tasks/affected-scope.js.map +1 -1
  316. package/dist/tasks/change-set.d.ts +96 -0
  317. package/dist/tasks/change-set.d.ts.map +1 -1
  318. package/dist/tasks/change-set.js +169 -4
  319. package/dist/tasks/change-set.js.map +1 -1
  320. package/dist/tasks/complete-ci.d.ts +115 -0
  321. package/dist/tasks/complete-ci.d.ts.map +1 -0
  322. package/dist/tasks/complete-ci.js +171 -0
  323. package/dist/tasks/complete-ci.js.map +1 -0
  324. package/dist/tasks/complete.d.ts.map +1 -1
  325. package/dist/tasks/complete.js +50 -17
  326. package/dist/tasks/complete.js.map +1 -1
  327. package/dist/tasks/compute-task-view.d.ts.map +1 -1
  328. package/dist/tasks/compute-task-view.js +7 -0
  329. package/dist/tasks/compute-task-view.js.map +1 -1
  330. package/dist/tasks/done-plan.d.ts.map +1 -1
  331. package/dist/tasks/done-plan.js +51 -30
  332. package/dist/tasks/done-plan.js.map +1 -1
  333. package/dist/tasks/done-record.d.ts +6 -0
  334. package/dist/tasks/done-record.d.ts.map +1 -1
  335. package/dist/tasks/done-record.js +9 -3
  336. package/dist/tasks/done-record.js.map +1 -1
  337. package/dist/tasks/evidence.d.ts +3 -2
  338. package/dist/tasks/evidence.d.ts.map +1 -1
  339. package/dist/tasks/evidence.js +143 -12
  340. package/dist/tasks/evidence.js.map +1 -1
  341. package/dist/tasks/gate-notes.d.ts +22 -0
  342. package/dist/tasks/gate-notes.d.ts.map +1 -0
  343. package/dist/tasks/gate-notes.js +33 -0
  344. package/dist/tasks/gate-notes.js.map +1 -0
  345. package/dist/tasks/index.d.ts +2 -1
  346. package/dist/tasks/index.d.ts.map +1 -1
  347. package/dist/tasks/index.js +2 -1
  348. package/dist/tasks/index.js.map +1 -1
  349. package/dist/tasks/test-run-binding.d.ts +117 -0
  350. package/dist/tasks/test-run-binding.d.ts.map +1 -0
  351. package/dist/tasks/test-run-binding.js +365 -0
  352. package/dist/tasks/test-run-binding.js.map +1 -0
  353. package/dist/tasks/tool-cache-env.d.ts +123 -0
  354. package/dist/tasks/tool-cache-env.d.ts.map +1 -0
  355. package/dist/tasks/tool-cache-env.js +287 -0
  356. package/dist/tasks/tool-cache-env.js.map +1 -0
  357. package/dist/tasks/tool-cache-failed-first.d.ts +155 -0
  358. package/dist/tasks/tool-cache-failed-first.d.ts.map +1 -0
  359. package/dist/tasks/tool-cache-failed-first.js +285 -0
  360. package/dist/tasks/tool-cache-failed-first.js.map +1 -0
  361. package/dist/tasks/tool-cache.d.ts +375 -55
  362. package/dist/tasks/tool-cache.d.ts.map +1 -1
  363. package/dist/tasks/tool-cache.js +912 -303
  364. package/dist/tasks/tool-cache.js.map +1 -1
  365. package/dist/tasks/tool-resolver.d.ts +4 -3
  366. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  367. package/dist/tasks/tool-resolver.js +15 -4
  368. package/dist/tasks/tool-resolver.js.map +1 -1
  369. package/dist/tasks/tool-semaphore.d.ts +67 -7
  370. package/dist/tasks/tool-semaphore.d.ts.map +1 -1
  371. package/dist/tasks/tool-semaphore.js +134 -56
  372. package/dist/tasks/tool-semaphore.js.map +1 -1
  373. package/dist/upgrade.d.ts.map +1 -1
  374. package/dist/upgrade.js +4 -0
  375. package/dist/upgrade.js.map +1 -1
  376. package/dist/validation/engine-ops.d.ts +6 -0
  377. package/dist/validation/engine-ops.d.ts.map +1 -1
  378. package/dist/validation/engine-ops.js +8 -0
  379. package/dist/validation/engine-ops.js.map +1 -1
  380. package/migrations/drizzle-cleo-project/20260930170000_t12819-trigger-suspend-clause/migration.sql +367 -0
  381. package/migrations/sync-journal/20260930120000_t12343-capture/migration.sql +47 -0
  382. package/migrations/sync-journal/20261001170000_t12984-sealer/migration.sql +101 -0
  383. package/package.json +13 -13
  384. package/templates/CLEO-INJECTION.md +5 -5
  385. package/templates/CLEO-REFERENCE.md +8 -4
@@ -0,0 +1,1733 @@
1
+ /**
2
+ * The cloud vault: encrypted snapshots of a project's (or this account's
3
+ * global) CLEO store on Cleo Nexus, with lineage, a single-writer lease and
4
+ * verified restore (T12336 protocol, T12337 CLI, T12338 lease handoff).
5
+ *
6
+ * A snapshot is a checkpoint on the store's journal stream (`project:<id>`,
7
+ * or the account's `home:<userId>`):
8
+ *
9
+ * - **Bundle**: the store's portable bundle (`exportPortableBundle`, no
10
+ * secrets; for the global store without machine-local state or the config
11
+ * home), sealed with the stream data key and uploaded as a blob. The data
12
+ * key derives from the account master key, which is escrowed on Cleo Nexus
13
+ * (released only to your approved devices; see `nexus-vault-keys.ts` for the
14
+ * threat model), so this is encrypted at rest, not zero-knowledge.
15
+ * - **Manifest** (plaintext): per-table row counts and keyed hashes of the
16
+ * store's syncing tables (`buildVaultManifest`). The server checks counts
17
+ * reconcile with the journal; only key holders can check hashes.
18
+ * - **Lineage**: each snapshot names its parent; the server refuses one that
19
+ * does not descend from the stream head (E_LINEAGE). The client refuses
20
+ * earlier, with `E_NEXUS_VAULT_BEHIND`, when the head is not the snapshot
21
+ * this store last pushed or restored: another device wrote since.
22
+ * - **Counts**: a non-genesis snapshot's counts must equal the parent's plus
23
+ * the per-table deltas of the journal segments between them. A push whose
24
+ * counts changed appends one signed "vault delta" segment carrying exactly
25
+ * the difference (the journal sync of Stage C will carry real ops).
26
+ * - **Lease**: a push takes the stream's `writer` lease. Another device's
27
+ * live lease refuses the push (`E_NEXUS_VAULT_LEASE_HELD`); `--force`
28
+ * takes it, and the server labels the take as a fork from the holder.
29
+ * - **Fork label**: a `--force` push over a head this store had not synced,
30
+ * or over another device's lease, carries `zz_vault_fork` (0 rows) in its
31
+ * signed manifest, so every device's verify still sees the fork after the
32
+ * lease is released (T13007).
33
+ * - **Stream format** (T13034): the vault writes checkpoint/v2 and stamps
34
+ * `SYNC_SCHEMA_VERSION` (shared with the change journal) on its segments
35
+ * and manifests. It reads and verifies the segment/v3 and checkpoint/v3 the
36
+ * journal writes, and refuses a push onto a stream whose head snapshot is
37
+ * checkpoint/v3 (the server's `E_STREAM_VERSION` ratchet) or that holds a
38
+ * newer sync schema before it writes anything
39
+ * (`E_NEXUS_VAULT_STREAM_UPGRADED`).
40
+ *
41
+ * Restore downloads, verifies the author's signature, decrypts, and checks
42
+ * every table's count and hash against the manifest before anything is
43
+ * placed; this machine's `local-only` rows are carried into the snapshot so
44
+ * machine state (replica binding, registry paths) is never overwritten, and
45
+ * what the snapshot no longer lists is removed afterwards (T13004).
46
+ *
47
+ * Nothing here blocks normal commands: the vault runs only when asked.
48
+ *
49
+ * @task T12336
50
+ * @task T12337
51
+ * @task T12338
52
+ * @epic T12322
53
+ */
54
+ import { execFileSync } from 'node:child_process';
55
+ import fs from 'node:fs';
56
+ import os from 'node:os';
57
+ import path from 'node:path';
58
+ import { NEXUS_VAULT_LEASE_ROLE, nexusCloudDevicePageSchema, nexusLeaseAcquireSchema, nexusStreamHeadSchema, SYNC_SCHEMA_VERSION, } from '@cleocode/contracts';
59
+ import { ListCheckpointsResult, ListLeasesResult, } from '@cleocode/contracts/cloud';
60
+ import { readDeclaredProjectIdentity } from '@cleocode/paths';
61
+ import { extract as tarExtract } from 'tar';
62
+ import { z } from 'zod';
63
+ import { getCleoHome, resolveOrCwd } from '../paths.js';
64
+ import { withLock } from '../store/lock.js';
65
+ import { exportPortableBundle, globalHomeRules } from '../store/portable-bundle.js';
66
+ import { importPortableBundle } from '../store/portable-bundle-import.js';
67
+ import { integrityCheck, PROJECT_SECTION_RULES, scanSection, sha256File, } from '../store/portable-bundle-scan.js';
68
+ import { FIRST_OPEN_LOCK_SUFFIX } from '../store/sqlite.js';
69
+ import { readActiveReplicaId } from '../store/sync/replica.js';
70
+ import { buildVaultManifest, carryMachineState, compareVaultManifests, emptyVaultTableHash, isVaultAnnotationKey, isVaultForkManifest, sameVaultManifest, VAULT_FILES_KEY, VAULT_FORK_KEY, VAULT_FORMAT_KEY, VAULT_GLOBAL_CONFIG_LOCAL_KEYS, VAULT_MANIFEST_FORMAT_VERSION, vaultDatabaseEntry, vaultDatabaseKey, vaultFileDigest, vaultFilesEntry, vaultForkEntry, vaultFormatEntry, vaultManifestFormat, vaultStripColumns, } from '../store/vault-manifest.js';
71
+ import { foreignWriterLeases, storeOpenElsewhere } from '../store/writer-lease.js';
72
+ import { deriveKey } from './crypto.js';
73
+ import { NexusError } from './http.js';
74
+ import { cursorFromCheckpoint, initialPullCursor, Journal } from './journal.js';
75
+ import { manifestVersion } from './manifest-check.js';
76
+ import { canonicalGlobalReplicaBinder } from './nexus-attach.js';
77
+ import { NexusAccountError } from './nexus-auth.js';
78
+ import { nexusApiErrorToAccountError } from './nexus-enrol.js';
79
+ import { readNexusProjectLink } from './nexus-link.js';
80
+ import { connectNexusVault, nexusHomeDataKey, nexusProjectDataKey, unlockNexusAccountKey, } from './nexus-vault-keys.js';
81
+ import { homeStream, projectStream } from './streams.js';
82
+ /** Default lease length of a push: long enough for a large upload, short enough to hand off. */
83
+ export const NEXUS_VAULT_LEASE_TTL_SECONDS = 900;
84
+ /** Why CLEO-installed global content is never in a vault snapshot (T13022). */
85
+ const INSTALLED_BY_CLEO = "installed by this machine's CLEO version (devices on other versions would downgrade it); never in a vault snapshot";
86
+ /**
87
+ * Global-home paths a vault snapshot never carries (T12968): this machine's
88
+ * identity and runtime state, and the content CLEO's installer writes for its
89
+ * own version (T13022: templates, harness skills, hooks, extensions, the
90
+ * model catalog, migration markers, the CleoOS hub extensions and identity,
91
+ * and the CANT starter bundle the CleoOS postinstall rewrites, T13038).
92
+ * A global pull must leave them as they are, so the bundle does not hold them
93
+ * at all. The config home is left out too.
94
+ */
95
+ export const VAULT_GLOBAL_EXCLUSIONS = {
96
+ dirs: {
97
+ state: 'machine-local state (on macOS the replica registries live here); never in a vault snapshot',
98
+ keys: 'machine-local keys (evidence cache key); never in a vault snapshot',
99
+ 'rate-limit-state': 'machine-local rate-limit counters; never in a vault snapshot',
100
+ templates: INSTALLED_BY_CLEO,
101
+ skills: INSTALLED_BY_CLEO,
102
+ hooks: INSTALLED_BY_CLEO,
103
+ extensions: INSTALLED_BY_CLEO,
104
+ 'pi-extensions': INSTALLED_BY_CLEO,
105
+ 'llm-catalog': INSTALLED_BY_CLEO,
106
+ '.migrations': INSTALLED_BY_CLEO,
107
+ 'cant/starter': INSTALLED_BY_CLEO,
108
+ },
109
+ files: {
110
+ 'CLEOOS-IDENTITY.md': INSTALLED_BY_CLEO,
111
+ 'device-id': "this machine's stable device id; never in a vault snapshot",
112
+ 'web-server.json': 'machine-local web server runtime state; never in a vault snapshot',
113
+ 'sentient-state.json': 'machine-local daemon state; never in a vault snapshot',
114
+ 'device-heartbeat.stamp': 'machine-local heartbeat; never in a vault snapshot',
115
+ 'nexus.db': 'machine-local code index (rebuilt per machine); never in a vault snapshot',
116
+ 'exodus-complete': 'machine-local migration marker; never in a vault snapshot',
117
+ 'telemetry-config.json': "this install's telemetry opt-in and anonymous id; never in a vault snapshot",
118
+ 'decide/spend.json': "this machine's decision spend ledger (in-flight reservations); never in a vault snapshot",
119
+ 'decide/budget.json': "this machine's decision request token bucket; never in a vault snapshot",
120
+ },
121
+ };
122
+ /**
123
+ * Plain files of a snapshot that belong to the machine, not to the store
124
+ * (T13005), so they are outside its hashed file inventory and a restore never
125
+ * overwrites them:
126
+ *
127
+ * - `skip`: never placed from a snapshot (`worktrees.json` names this
128
+ * machine's worktrees).
129
+ * - `keep`: this machine's copy stays; the snapshot's is placed only where
130
+ * this machine has none: the replica link (`nexus-link.json`), the
131
+ * per-checkout identity cache (`project-info.json`, T13022), files CLEO
132
+ * regenerates on its own (`memory-bridge.md`, timestamped) and per-session
133
+ * scratch (top-level dotfiles, `tmp/`, `state/`), which would otherwise read
134
+ * as a change on every run.
135
+ *
136
+ * Everything else, `config.json` and `project-context.json` included, is in
137
+ * the inventory: hashed (relocation-aware, {@link vaultFileDigest}) and placed.
138
+ */
139
+ function machineLocalFile(relPath) {
140
+ if (relPath === 'worktrees.json')
141
+ return 'skip';
142
+ return relPath === 'nexus-link.json' ||
143
+ relPath === 'project-info.json' ||
144
+ relPath === 'memory-bridge.md' ||
145
+ (!relPath.includes('/') && relPath.startsWith('.')) ||
146
+ relPath.startsWith('tmp/') ||
147
+ relPath.startsWith('state/')
148
+ ? 'keep'
149
+ : null;
150
+ }
151
+ /** A plain file outside the vault's hashed inventory ({@link machineLocalFile}). */
152
+ const inventoryExcluded = (relPath) => machineLocalFile(relPath) !== null;
153
+ const tableScopeOf = (t) => (t.scope === 'global' ? 'global' : 'project');
154
+ const hashKeyOf = (dataKey) => deriveKey(dataKey, 'vault-manifest');
155
+ const streamPath = (streamId) => `/v1/streams/${encodeURIComponent(streamId)}`;
156
+ function vaultError(code, message, fix) {
157
+ return new NexusAccountError(code, message, fix);
158
+ }
159
+ /**
160
+ * The store a command acts on. `push` binds the global replica and may mint
161
+ * the project key; `read` only reads (a read-only replica lookup, no key
162
+ * minting), so status, verify, restore and lease release never write (T12974).
163
+ */
164
+ async function resolveTarget(conn, mk, opts, mode) {
165
+ if (opts.scope === 'global') {
166
+ const cleoHome = getCleoHome();
167
+ const dbPath = path.join(cleoHome, 'cleo.db');
168
+ let replicaId;
169
+ if (mode === 'push') {
170
+ replicaId = (await canonicalGlobalReplicaBinder().ensure()).replicaId;
171
+ // The binder's handle must not stay open past the bind (a later restore replaces the file).
172
+ const { _resetDualScopeDbCache } = await import('../store/dual-scope-db.js');
173
+ _resetDualScopeDbCache('global');
174
+ }
175
+ else {
176
+ replicaId = await readActiveReplicaId(dbPath, 'global');
177
+ }
178
+ return {
179
+ scope: 'global',
180
+ streamId: homeStream(conn.userId),
181
+ storeRoot: cleoHome,
182
+ dbPath,
183
+ projectId: null,
184
+ replicaId,
185
+ dataKey: nexusHomeDataKey(mk),
186
+ };
187
+ }
188
+ const root = path.resolve(resolveOrCwd(opts.projectRoot));
189
+ if (!readDeclaredProjectIdentity(root)) {
190
+ throw vaultError('E_NEXUS_NOT_A_PROJECT', `${root} is not a CLEO project`, 'run it inside a CLEO project, or pass --scope global');
191
+ }
192
+ const link = readNexusProjectLink(root, conn.apiUrl);
193
+ if (!link) {
194
+ throw vaultError('E_NEXUS_VAULT_NOT_LINKED', `this project is not linked to Cleo Nexus at ${conn.apiUrl}`, 'run `cleo project link`');
195
+ }
196
+ const dataKey = await nexusProjectDataKey(conn, mk, link.remoteProjectId, mode === 'push');
197
+ if (dataKey === null) {
198
+ throw vaultError('E_NEXUS_VAULT_EMPTY', 'the cloud holds no snapshot of this project yet', 'run `cleo cloud push` on a device that has it');
199
+ }
200
+ return {
201
+ scope: 'project',
202
+ streamId: link.streamId || projectStream(link.remoteProjectId),
203
+ storeRoot: root,
204
+ dbPath: path.join(root, '.cleo', 'cleo.db'),
205
+ projectId: link.remoteProjectId,
206
+ replicaId: link.replicaId && link.nexusDeviceId === conn.deviceId ? link.replicaId : null,
207
+ dataKey,
208
+ };
209
+ }
210
+ function journalFor(conn, t) {
211
+ return new Journal({
212
+ http: conn.http,
213
+ streamId: t.streamId,
214
+ replicaId: t.replicaId ?? '00000000-0000-7000-8000-000000000000',
215
+ deviceId: conn.deviceId,
216
+ signing: conn.keys.signing,
217
+ key: t.dataKey,
218
+ fetch: conn.blobFetch,
219
+ });
220
+ }
221
+ async function deviceNames(conn) {
222
+ try {
223
+ const page = await conn.call('GET', '/v1/devices?limit=100', nexusCloudDevicePageSchema);
224
+ return new Map(page.devices.map((d) => [d.deviceId, d.name]));
225
+ }
226
+ catch {
227
+ return new Map();
228
+ }
229
+ }
230
+ function snapshotOf(cp, names) {
231
+ return {
232
+ checkpointId: cp.checkpointId,
233
+ parentCheckpointId: cp.parentCheckpointId,
234
+ deviceId: cp.deviceId,
235
+ deviceName: names.get(cp.deviceId) ?? null,
236
+ replicaId: cp.replicaId,
237
+ coversSeq: cp.coversSeq,
238
+ createdAt: cp.createdAt ?? null,
239
+ sizeBytes: cp.sizeBytes,
240
+ rows: Object.values(cp.manifest.tables).reduce((n, t) => n + t.rows, 0),
241
+ endorsedBy: cp.endorsements.map((e) => e.deviceId),
242
+ };
243
+ }
244
+ async function listCheckpoints(conn, streamId) {
245
+ const res = await conn.find(`${streamPath(streamId)}/checkpoints`, ListCheckpointsResult);
246
+ return res?.checkpoints ?? [];
247
+ }
248
+ async function streamHead(conn, streamId) {
249
+ return ((await conn.find(streamPath(streamId), nexusStreamHeadSchema)) ?? {
250
+ streamId,
251
+ headSeq: 0,
252
+ headCheckpointId: null,
253
+ });
254
+ }
255
+ async function leasesOf(conn, t, names, warnings) {
256
+ try {
257
+ const res = await conn.raw('GET', `${streamPath(t.streamId)}/leases`, ListLeasesResult);
258
+ return res.leases.map((l) => ({
259
+ role: l.role,
260
+ replicaId: l.replicaId,
261
+ deviceId: l.deviceId ?? null,
262
+ deviceName: l.deviceId ? (names.get(l.deviceId) ?? null) : null,
263
+ expiresAt: l.expiresAt,
264
+ forkedFromReplicaId: l.forkedFromReplicaId ?? null,
265
+ acquiredAt: l.acquiredAt ?? null,
266
+ mine: l.replicaId === t.replicaId,
267
+ }));
268
+ }
269
+ catch (err) {
270
+ if (err instanceof NexusError && err.status === 404) {
271
+ warnings.push({
272
+ code: 'W_NEXUS_LEASES_UNAVAILABLE',
273
+ message: 'this Cleo Nexus server does not list leases yet',
274
+ });
275
+ return [];
276
+ }
277
+ throw err;
278
+ }
279
+ }
280
+ /** Take (or renew) the stream's writer lease; `force` takes it from a live holder (a labelled fork). */
281
+ async function acquireLease(conn, t, force, reason) {
282
+ if (!t.replicaId)
283
+ throw new Error('acquireLease: no replica');
284
+ try {
285
+ const res = await conn.raw('POST', `${streamPath(t.streamId)}/leases`, nexusLeaseAcquireSchema, {
286
+ role: NEXUS_VAULT_LEASE_ROLE,
287
+ replicaId: t.replicaId,
288
+ ttlSeconds: NEXUS_VAULT_LEASE_TTL_SECONDS,
289
+ force,
290
+ reason,
291
+ });
292
+ const l = res.lease;
293
+ return {
294
+ lease: {
295
+ role: l.role,
296
+ replicaId: l.replicaId,
297
+ deviceId: conn.deviceId,
298
+ deviceName: null,
299
+ expiresAt: l.expiresAt,
300
+ forkedFromReplicaId: l.forkedFromReplicaId ?? null,
301
+ mine: true,
302
+ },
303
+ forked: force && (l.forkedFromReplicaId ?? null) !== null,
304
+ };
305
+ }
306
+ catch (err) {
307
+ if (err instanceof NexusError && err.code === 'E_LEASE_HELD') {
308
+ const holder = String(err.details?.['replicaId'] ?? 'another replica');
309
+ const until = String(err.details?.['expiresAt'] ?? 'its expiry');
310
+ throw vaultError('E_NEXUS_VAULT_LEASE_HELD', `another device holds the write lease on ${t.streamId} (replica ${holder}, until ${until})`, 'wait for it to push and release (`cleo cloud lease release` on that machine), then `cleo cloud pull`; or `--force` to take it, which labels this snapshot as a fork');
311
+ }
312
+ throw err;
313
+ }
314
+ }
315
+ /** Hand the writer lease back, ignoring failures (a failed push must not keep it). */
316
+ async function releaseLeaseQuietly(conn, t) {
317
+ if (!t.replicaId)
318
+ return;
319
+ try {
320
+ await conn.raw('DELETE', `${streamPath(t.streamId)}/leases/${NEXUS_VAULT_LEASE_ROLE}?replicaId=${encodeURIComponent(t.replicaId)}`, z.looseObject({}));
321
+ }
322
+ catch {
323
+ // It expires on its own.
324
+ }
325
+ }
326
+ /**
327
+ * Every segment after `cursor`, replayed through the journal (signatures and
328
+ * gaps checked, segment/v2 and /v3 alike): the per-segment deltas, and the
329
+ * highest segment `schemaVersion` seen (0 when there is none).
330
+ */
331
+ async function replaySegments(journal, cursor, signers) {
332
+ const deltas = [];
333
+ let maxSchemaVersion = 0;
334
+ let c = cursor;
335
+ for (;;) {
336
+ const page = await journal.pull(c, signers, 200);
337
+ for (const s of page.segments) {
338
+ deltas.push(s.meta.deltas);
339
+ maxSchemaVersion = Math.max(maxSchemaVersion, s.meta.schemaVersion);
340
+ }
341
+ c = page.cursor;
342
+ if (page.segments.length === 0 || c.after >= page.head) {
343
+ return { cursor: c, deltas, maxSchemaVersion };
344
+ }
345
+ }
346
+ }
347
+ /**
348
+ * What works on a stream the change journal writes (its head snapshot is
349
+ * checkpoint/v3), for every message that would otherwise say "push" (T13034).
350
+ */
351
+ const JOURNAL_STREAM_REMEDY = 'on this stream local changes travel through the change journal (`sync.push`), not vault snapshots (vault snapshots of journal streams are tracked in T12999); `cleo cloud pull`, `cleo cloud restore` and `cleo cloud verify` still work';
352
+ /** Whether a snapshot is checkpoint/v3: the change journal writes its stream (T13034). */
353
+ function isJournalSnapshot(cp) {
354
+ return cp !== null && cp !== undefined && manifestVersion(cp.manifest) === 3;
355
+ }
356
+ /**
357
+ * Why this CLEO cannot push to the stream at all (T13034), or `null`: the
358
+ * stream holds data of a newer sync schema (a segment, or the head snapshot,
359
+ * above {@link SYNC_SCHEMA_VERSION}), so a snapshot stamped with the older
360
+ * schema would sit below what the stream already reached; or the head
361
+ * snapshot records a vault manifest format this CLEO does not recognise, so this store
362
+ * cannot even tell whether it changed. Checked on the stream head before the
363
+ * export and the lease (`maxSchemaVersion`, which the server raises on every
364
+ * append), and on the replayed window again before the delta segment, for a
365
+ * segment appended in between.
366
+ */
367
+ function aheadOfThisCleo(t, schemaVersion, parent) {
368
+ if (schemaVersion > SYNC_SCHEMA_VERSION) {
369
+ return streamUpgradedError(`${t.streamId} holds data of sync schema ${schemaVersion}, newer than the schema ${SYNC_SCHEMA_VERSION} this CLEO writes`, 'nothing was written; upgrade CLEO on this machine, then push again');
370
+ }
371
+ if (parent && vaultManifestFormat(parent.manifest, hashKeyOf(t.dataKey)) === 'unknown') {
372
+ return streamUpgradedError(`the head snapshot ${parent.checkpointId} records a vault manifest format this CLEO does not recognise (this CLEO hashes format ${VAULT_MANIFEST_FORMAT_VERSION}; a different vault format wrote it, most likely a newer CLEO)`, 'nothing was written; if a newer CLEO pushed it, upgrade CLEO on this machine, then push again (`cleo cloud vault` shows which device pushed it)');
373
+ }
374
+ return null;
375
+ }
376
+ /**
377
+ * The refusal of a push that would write over a checkpoint/v3 head (T13034).
378
+ * The server refuses a v2 checkpoint after a v3 one (`E_STREAM_VERSION`,
379
+ * reason `stream-v3`), and the vault writes v2 only: a v3 checkpoint carries
380
+ * the journal's replay pin, which endorsers recompute by replay, and its
381
+ * bundle carries the unresolved journal entries (journal spec §2.11 §6, §7).
382
+ * A store snapshot has neither, so the vault never claims them. Raised after
383
+ * the up-to-date check (an unchanged store reports up-to-date) and before the
384
+ * lease, upload or delta segment, so it never appends a segment it would
385
+ * orphan.
386
+ */
387
+ function journalStreamRefusal(t, parent) {
388
+ return streamUpgradedError(`${t.streamId} takes only checkpoint/v3 snapshots: its head snapshot ${parent.checkpointId} is one (the change journal writes this stream), and the server refuses the checkpoint/v2 snapshots this CLEO's vault writes after it`, `nothing was written; ${JOURNAL_STREAM_REMEDY}`);
389
+ }
390
+ function streamUpgradedError(message, fix) {
391
+ return vaultError('E_NEXUS_VAULT_STREAM_UPGRADED', message, fix);
392
+ }
393
+ /**
394
+ * A warning when a snapshot's hashes use another vault manifest format than
395
+ * this CLEO's (T13034): every table then compares as changed, so a verdict
396
+ * or a pending-change list against it says nothing. `null` when the formats
397
+ * match or there is no snapshot.
398
+ */
399
+ function formatWarning(t, cp) {
400
+ if (!cp)
401
+ return null;
402
+ const format = vaultManifestFormat(cp.manifest, hashKeyOf(t.dataKey));
403
+ if (format === VAULT_MANIFEST_FORMAT_VERSION)
404
+ return null;
405
+ return {
406
+ code: 'W_NEXUS_VAULT_FORMAT',
407
+ message: format === 'unknown'
408
+ ? `snapshot ${cp.checkpointId} records a vault manifest format this CLEO does not recognise (this CLEO hashes format ${VAULT_MANIFEST_FORMAT_VERSION}; a different vault format wrote it, most likely a newer CLEO), so tables compared with it read as changed; if a newer CLEO pushed it, upgrade CLEO on this machine`
409
+ : `snapshot ${cp.checkpointId} was hashed under vault manifest format ${format}, older than this CLEO's (${VAULT_MANIFEST_FORMAT_VERSION}), so tables compared with it read as changed; the next push records the current format`,
410
+ };
411
+ }
412
+ /**
413
+ * The checkpoints whose author signature (or a live device's endorsement)
414
+ * verifies against the trusted signers. Each one that does not is reported
415
+ * when `warnings` is given and left out (T13007), so no verdict, comparison
416
+ * or refusal is decided from a manifest the server could have forged.
417
+ */
418
+ function trustedCheckpoints(journal, cps, signers, warnings) {
419
+ return cps.filter((cp) => {
420
+ try {
421
+ journal.verifyCheckpoint(cp, signers);
422
+ return true;
423
+ }
424
+ catch (err) {
425
+ warnings?.push({
426
+ code: 'W_NEXUS_VAULT_UNTRUSTED_SNAPSHOT',
427
+ message: `snapshot ${cp.checkpointId}: ${err instanceof Error ? err.message : String(err)}`,
428
+ });
429
+ return false;
430
+ }
431
+ });
432
+ }
433
+ /**
434
+ * What this store last synced with on the stream. A push whose state write
435
+ * was lost (a crash between the checkpoint and the state file, T13007) left
436
+ * its in-flight mark and the head as this device and replica's own snapshot
437
+ * over the marked parent: that head is what this store holds, so it counts
438
+ * as synced (`adopted`; push and pull record it). Without the mark (a store
439
+ * restored to an older snapshot of its own) nothing is adopted.
440
+ */
441
+ function syncedState(conn, t, trusted, headCheckpointId) {
442
+ const recorded = conn.state.stream(conn.apiUrl, conn.userId, t.streamId, t.storeRoot);
443
+ const head = trusted.find((c) => c.checkpointId === headCheckpointId);
444
+ if (head !== undefined &&
445
+ t.replicaId !== null &&
446
+ head.deviceId === conn.deviceId &&
447
+ head.replicaId === t.replicaId &&
448
+ recorded?.pushInFlight !== undefined &&
449
+ head.checkpointId !== recorded.lastCheckpointId &&
450
+ head.parentCheckpointId === recorded.pushInFlight.parentCheckpointId) {
451
+ // The mark carries the pushed snapshot's marks and digests (#1773 R6).
452
+ const pushed = recorded.pushInFlight;
453
+ const gitTracked = pushed.gitTracked ?? recorded.gitTracked;
454
+ return {
455
+ state: {
456
+ lastCheckpointId: head.checkpointId,
457
+ lastCoversSeq: head.coversSeq,
458
+ updatedAt: head.createdAt,
459
+ ...(gitTracked !== undefined ? { gitTracked } : {}),
460
+ ...(pushed.files !== undefined ? { files: pushed.files } : {}),
461
+ },
462
+ adopted: head,
463
+ };
464
+ }
465
+ return { state: recorded, adopted: null };
466
+ }
467
+ /** Length of the digest prefix a sync records per file: 128 bits tell edits apart. */
468
+ const SYNC_FILE_DIGEST_LENGTH = 32;
469
+ /** Per-path digests as a sync records them (the digest prefix). */
470
+ function syncFiles(files) {
471
+ return Object.fromEntries([...files].map(([p, d]) => [p, d.slice(0, SYNC_FILE_DIGEST_LENGTH)]));
472
+ }
473
+ /** Record that this store holds `cp` on the stream, with its marks and per-path digests. */
474
+ function saveSynced(conn, t, cp, record) {
475
+ conn.state.saveStream(conn.apiUrl, conn.userId, record.streamId ?? t.streamId, t.storeRoot, {
476
+ lastCheckpointId: cp.checkpointId,
477
+ lastCoversSeq: cp.coversSeq,
478
+ gitTracked: t.scope === 'project' ? record.gitTracked : [],
479
+ ...(record.files !== undefined ? { files: record.files } : {}),
480
+ });
481
+ }
482
+ /** The marks of the snapshot this store last synced; a global store has none (T13038). */
483
+ function syncedMarks(t, synced) {
484
+ return t.scope === 'project' ? (synced?.gitTracked ?? []) : [];
485
+ }
486
+ /**
487
+ * The paths a push marks as git-tracked (T13019, T13038): the marks of the
488
+ * snapshot this store last synced, so tracked-ness travels with the data and
489
+ * converges across checkouts, plus what this checkout's git tracks. A global
490
+ * store marks nothing.
491
+ */
492
+ function trackedForPush(t, git, synced) {
493
+ if (t.scope !== 'project')
494
+ return new Set();
495
+ return new Set([...git.tracked, ...syncedMarks(t, synced)]);
496
+ }
497
+ /**
498
+ * The paths left out of the file-inventory hash this store compares when it
499
+ * has no per-path digests of its last sync (T13019): in a checkout, what its
500
+ * git tracks and what that snapshot marks. A machine without git leaves
501
+ * nothing out: it holds the snapshot's copies of marked files, so without
502
+ * digests to judge them by they count as changed, never skipped (#1773 R6).
503
+ */
504
+ function untrackedComparison(t, git, synced) {
505
+ if (git.mode !== 'known')
506
+ return new Set();
507
+ return new Set([...git.tracked, ...syncedMarks(t, synced)]);
508
+ }
509
+ /**
510
+ * The plain files this store changed since the snapshot it last synced,
511
+ * judged path by path against the digests that sync recorded (T13038):
512
+ * added, removed or edited. In a checkout, paths git owns are skipped (tracked
513
+ * here, or marked by that snapshot). A machine without git compares every
514
+ * path, marked ones included, since it holds the snapshot's copies: an edit
515
+ * there is a local change. `null` when the sync recorded no digests (an older
516
+ * state, a recovered push) or git cannot say: the inventory hash decides.
517
+ *
518
+ * @returns Changed paths, sorted, or `null`.
519
+ */
520
+ function fileChangesSinceSync(local, t, git, synced) {
521
+ const recorded = synced?.files;
522
+ if (git.mode === 'unknown' || recorded === undefined)
523
+ return null;
524
+ const marked = new Set(syncedMarks(t, synced));
525
+ const skip = (p) => git.mode === 'known' && (git.tracked.has(p) || marked.has(p));
526
+ const out = new Set();
527
+ for (const [p, digest] of local) {
528
+ if (!skip(p) && recorded[p] !== digest.slice(0, SYNC_FILE_DIGEST_LENGTH))
529
+ out.add(p);
530
+ }
531
+ for (const p of Object.keys(recorded))
532
+ if (!skip(p) && !local.has(p))
533
+ out.add(p);
534
+ return [...out].sort();
535
+ }
536
+ /**
537
+ * Compare this store with a snapshot it synced, table by table (T13038): the
538
+ * file inventory is judged path by path when `changes` is known, else by its
539
+ * hash (without it when git cannot say, {@link comparable}).
540
+ */
541
+ function compareWithSynced(local, snapshot, git, changes) {
542
+ const rows = compareVaultManifests(local, comparable(snapshot, git));
543
+ if (changes === null)
544
+ return rows;
545
+ return rows.map((r) => (r.table === VAULT_FILES_KEY ? { ...r, match: changes.length === 0 } : r));
546
+ }
547
+ /**
548
+ * Export the store the way the vault snapshots it: no secrets, no `strip`
549
+ * columns (T13007), and for the global store without machine-local state or
550
+ * the config home (T12968).
551
+ */
552
+ async function exportVaultBundle(t, outputPath, label, tracked) {
553
+ await exportPortableBundle({
554
+ scope: t.scope === 'global' ? 'global' : 'project',
555
+ ...(t.scope === 'project' ? { projectRoot: t.storeRoot } : {}),
556
+ ...(t.scope === 'global'
557
+ ? { globalHomeExclusions: VAULT_GLOBAL_EXCLUSIONS, includeConfigHome: false }
558
+ : {}),
559
+ ...(t.scope === 'project' && tracked.size > 0 ? { gitTrackedFiles: tracked } : {}),
560
+ stripColumns: vaultStripColumns(tableScopeOf(t)),
561
+ outputPath,
562
+ label,
563
+ });
564
+ }
565
+ /** How long `git ls-files` may run before the vault treats tracking as unknown. */
566
+ const GIT_LS_FILES_TIMEOUT_MS = 15_000;
567
+ /** Largest `git ls-files` output read (the `.cleo/` pathspec of a large repository). */
568
+ const GIT_LS_FILES_MAX_BYTES = 64 * 1024 * 1024;
569
+ /** Environment variables that would point git at another repository than the store's. */
570
+ const GIT_LOCATION_ENV = [
571
+ 'GIT_DIR',
572
+ 'GIT_WORK_TREE',
573
+ 'GIT_INDEX_FILE',
574
+ 'GIT_COMMON_DIR',
575
+ 'GIT_OBJECT_DIRECTORY',
576
+ ];
577
+ /** The store's git tracking, each side from its own checkout (T13019). */
578
+ function gitTracking(t) {
579
+ const none = { mode: 'none', tracked: new Set() };
580
+ if (t.scope === 'global')
581
+ return none;
582
+ let dir = path.resolve(t.storeRoot);
583
+ for (;;) {
584
+ if (fs.existsSync(path.join(dir, '.git')))
585
+ break;
586
+ const up = path.dirname(dir);
587
+ if (up === dir)
588
+ return none;
589
+ dir = up;
590
+ }
591
+ try {
592
+ const out = execFileSync('git', ['ls-files', '-z', '--', '.cleo'], {
593
+ cwd: t.storeRoot,
594
+ encoding: 'utf8',
595
+ timeout: GIT_LS_FILES_TIMEOUT_MS,
596
+ maxBuffer: GIT_LS_FILES_MAX_BYTES,
597
+ stdio: ['ignore', 'pipe', 'ignore'],
598
+ env: Object.fromEntries(Object.entries(process.env).filter(([k]) => !GIT_LOCATION_ENV.includes(k))),
599
+ });
600
+ const tracked = new Set();
601
+ for (const p of out.split('\0'))
602
+ if (p.startsWith('.cleo/'))
603
+ tracked.add(p.slice('.cleo/'.length));
604
+ return { mode: 'known', tracked };
605
+ }
606
+ catch {
607
+ return { mode: 'unknown', tracked: new Set() };
608
+ }
609
+ }
610
+ /** The warning for a checkout git could not list (T13019). */
611
+ function gitUnknownWarning(t) {
612
+ return {
613
+ code: 'W_NEXUS_VAULT_GIT_UNKNOWN',
614
+ message: `git could not list the tracked files of ${t.storeRoot}; the vault leaves every plain file under .cleo/ alone (not compared, overwritten or removed) until it can`,
615
+ };
616
+ }
617
+ /**
618
+ * A manifest as this store can compare it: without the file inventory when
619
+ * git tracking is unknown, since local files are then left alone (T13019).
620
+ */
621
+ function comparable(m, git) {
622
+ if (git.mode !== 'unknown' || !Object.hasOwn(m.tables, VAULT_FILES_KEY))
623
+ return m;
624
+ const { [VAULT_FILES_KEY]: _files, ...tables } = m.tables;
625
+ return { ...m, tables };
626
+ }
627
+ /**
628
+ * The vault manifest of an extracted bundle: the syncing tables of its
629
+ * `cleo.db`, one entry per other database of the section, and one entry for
630
+ * its plain-file inventory (T12969), so a change anywhere in the bundle is seen.
631
+ */
632
+ async function snapshotManifest(extractDir, manifest, t) {
633
+ const section = t.scope === 'global' ? manifest.global?.home : manifest.projects[0];
634
+ const entry = section?.databases.find((d) => d.role === 'primary' && d.relPath === 'cleo.db');
635
+ if (!section || !entry) {
636
+ throw vaultError('E_NEXUS_VAULT_VERIFY_FAILED', 'the snapshot holds no primary cleo.db');
637
+ }
638
+ // A restore relocates a project's primary store from its original path; a
639
+ // global store, and every other database, is placed as is.
640
+ const root = t.scope === 'global' ? null : (manifest.projects[0]?.originalPath ?? section.originalRoot);
641
+ const hashKey = hashKeyOf(t.dataKey);
642
+ const dbPath = path.join(extractDir, entry.bundlePath);
643
+ const vault = buildVaultManifest(dbPath, { scope: tableScopeOf(t), hashKey, root }).manifest;
644
+ for (const d of section.databases) {
645
+ if (d === entry)
646
+ continue;
647
+ vault.tables[vaultDatabaseKey(d.relPath)] = vaultDatabaseEntry(path.join(extractDir, d.bundlePath), { hashKey, root: null });
648
+ }
649
+ const files = [];
650
+ const digests = new Map();
651
+ for (const f of section.files) {
652
+ if (f.secret || inventoryExcluded(f.relPath))
653
+ continue;
654
+ const digest = await vaultFileDigest(path.join(extractDir, f.bundlePath), f.relPath, {
655
+ scope: tableScopeOf(t),
656
+ root,
657
+ sha256: f.sha256,
658
+ });
659
+ digests.set(f.relPath, digest);
660
+ // Tracked by the pusher's git: git's job, out of the hash (T13019). A
661
+ // global snapshot never carries marks, so a flag there is ignored (T13038).
662
+ if (t.scope === 'project' && f.gitTracked === true)
663
+ continue;
664
+ files.push({ relPath: f.relPath, sha256: digest });
665
+ }
666
+ vault.tables[VAULT_FILES_KEY] = vaultFilesEntry(files, hashKey);
667
+ return { vault, dbPath, files: digests };
668
+ }
669
+ /** Export and extract the store into `work`, returning its bundle and vault manifest. */
670
+ async function exportAndRead(t, work, label, tracked) {
671
+ const bundlePath = path.join(work, 'snapshot.cleobundle.tar.gz');
672
+ await exportVaultBundle(t, bundlePath, label, tracked);
673
+ const extractDir = path.join(work, 'x');
674
+ fs.mkdirSync(extractDir);
675
+ await tarExtract({ file: bundlePath, cwd: extractDir });
676
+ const bundleManifest = JSON.parse(fs.readFileSync(path.join(extractDir, 'manifest.json'), 'utf8'));
677
+ const { vault, files } = await snapshotManifest(extractDir, bundleManifest, t);
678
+ return { bundlePath, vault, gitTracked: snapshotGitTracked(bundleManifest, t), files };
679
+ }
680
+ /** The paths a project snapshot's bundle marks as git-tracked; a global one has none (T13019, T13038). */
681
+ function snapshotGitTracked(manifest, t) {
682
+ if (t.scope !== 'project')
683
+ return [];
684
+ return (manifest.projects[0]?.files ?? [])
685
+ .filter((f) => f.gitTracked === true)
686
+ .map((f) => f.relPath);
687
+ }
688
+ /**
689
+ * The vault manifest of this machine's store as a push would snapshot it, or
690
+ * `null` when it has none, computed from the live databases (one read
691
+ * transaction each) and a walk with the export's own rules: no export, no
692
+ * VACUUM, so `cloud vault`, `verify` and the pull checks stay cheap (#1773 P0).
693
+ * The values match {@link snapshotManifest} of a fresh export: credential
694
+ * columns hash as NULL in every database, as the export clears them. Paths
695
+ * in `untracked` (git-tracked here, or marked by the snapshot it is compared
696
+ * with) are left out of the inventory hash, and every inventory file's digest
697
+ * is returned for a path-by-path comparison (T13038); with tracking unknown
698
+ * the file inventory is left out entirely (T13019).
699
+ */
700
+ async function localManifest(t, git, untracked) {
701
+ if (!fs.existsSync(t.dbPath))
702
+ return null;
703
+ const { sectionRoot, scan, primaryRel } = sectionScan(t);
704
+ const hashKey = hashKeyOf(t.dataKey);
705
+ const root = t.scope === 'global' ? null : t.storeRoot;
706
+ const vault = buildVaultManifest(t.dbPath, { scope: tableScopeOf(t), hashKey, root }).manifest;
707
+ for (const rel of scan.sqlite) {
708
+ if (rel === primaryRel)
709
+ continue;
710
+ try {
711
+ vault.tables[vaultDatabaseKey(rel)] = vaultDatabaseEntry(path.join(sectionRoot, rel), {
712
+ hashKey,
713
+ root: null,
714
+ });
715
+ }
716
+ catch {
717
+ // Unreadable: the export leaves it out of an unencrypted bundle too.
718
+ }
719
+ }
720
+ const digests = new Map();
721
+ if (git.mode === 'unknown')
722
+ return { manifest: vault, files: digests };
723
+ const files = [];
724
+ for (const rel of scan.files) {
725
+ if (inventoryExcluded(rel))
726
+ continue;
727
+ const digest = await vaultFileDigest(path.join(sectionRoot, rel), rel, {
728
+ scope: tableScopeOf(t),
729
+ root,
730
+ });
731
+ digests.set(rel, digest);
732
+ if (!untracked.has(rel))
733
+ files.push({ relPath: rel, sha256: digest });
734
+ }
735
+ vault.tables[VAULT_FILES_KEY] = vaultFilesEntry(files, hashKey);
736
+ return { manifest: vault, files: digests };
737
+ }
738
+ /**
739
+ * This store's section as a vault snapshot walks it: the section root, the
740
+ * export's walk rules (so secrets, excluded directories and machine-local
741
+ * global state are never in it) and the primary store's path in it.
742
+ */
743
+ function sectionScan(t) {
744
+ const sectionRoot = t.scope === 'global' ? t.storeRoot : path.join(t.storeRoot, '.cleo');
745
+ const rules = t.scope === 'global' ? globalHomeRules(VAULT_GLOBAL_EXCLUSIONS) : PROJECT_SECTION_RULES;
746
+ return {
747
+ sectionRoot,
748
+ scan: scanSection(sectionRoot, rules),
749
+ primaryRel: path.relative(sectionRoot, t.dbPath).split(path.sep).join('/'),
750
+ };
751
+ }
752
+ function tempDir(prefix) {
753
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
754
+ }
755
+ /** `cleo cloud push`. */
756
+ async function pushNexusVaultImpl(opts = {}) {
757
+ const conn = await connectNexusVault(opts);
758
+ const key = await unlockNexusAccountKey(conn);
759
+ const t = await resolveTarget(conn, key.masterKey, opts, 'push');
760
+ const scope = t.scope;
761
+ const warnings = [...conn.warnings];
762
+ const replicaId = t.replicaId;
763
+ if (!replicaId) {
764
+ throw vaultError('E_NEXUS_VAULT_NOT_LINKED', 'this copy of the project is not attached from this device', 'run `cleo project link`');
765
+ }
766
+ const force = opts.force === true;
767
+ const journal = journalFor(conn, t);
768
+ const head = await streamHead(conn, t.streamId);
769
+ const checkpoints = head.headCheckpointId ? await listCheckpoints(conn, t.streamId) : [];
770
+ const parent = checkpoints.find((c) => c.checkpointId === head.headCheckpointId) ?? null;
771
+ if (head.headCheckpointId && !parent) {
772
+ throw vaultError('E_NEXUS_VAULT_REFUSED', `the head snapshot ${head.headCheckpointId} is not in the stream's list`);
773
+ }
774
+ if (parent)
775
+ journal.verifyCheckpoint(parent, key.signers);
776
+ // A stream past this CLEO refuses before the export and the lease (T13034).
777
+ const ahead = aheadOfThisCleo(t, Math.max(head.maxSchemaVersion ?? 0, parent?.manifest.schemaVersion ?? 0), parent);
778
+ if (ahead)
779
+ throw ahead;
780
+ const { state: synced, adopted } = syncedState(conn, t, parent ? [parent] : [], head.headCheckpointId);
781
+ if (adopted) {
782
+ saveSynced(conn, t, adopted, {
783
+ gitTracked: syncedMarks(t, synced),
784
+ ...(synced?.files !== undefined ? { files: synced.files } : {}),
785
+ });
786
+ }
787
+ // Another device pushed since this store last synced: only `--force` pushes
788
+ // over it, and that snapshot is labelled a fork on the checkpoint (T13007).
789
+ const overUnsynced = head.headCheckpointId !== null && head.headCheckpointId !== synced?.lastCheckpointId;
790
+ if (overUnsynced && !force) {
791
+ throw vaultError('E_NEXUS_VAULT_BEHIND', `another device pushed snapshot ${head.headCheckpointId} after this machine's last sync`, isJournalSnapshot(parent)
792
+ ? `run \`cleo cloud pull\` first (it refuses to overwrite local changes); ${JOURNAL_STREAM_REMEDY}`
793
+ : 'run `cleo cloud pull` first (it refuses to overwrite local changes), or `--force` to push this store as a labelled fork');
794
+ }
795
+ const git = gitTracking(t);
796
+ if (git.mode === 'unknown')
797
+ warnings.push(gitUnknownWarning(t));
798
+ const work = tempDir('cleo-vault-push-');
799
+ try {
800
+ const { bundlePath, vault, gitTracked, files } = await exportAndRead(t, work, `cloud-vault-${scope}`, trackedForPush(t, git, synced));
801
+ const names = await deviceNames(conn);
802
+ // Nothing changed since the head: no lease, no upload (T12971). Against the
803
+ // snapshot this store synced, files are judged path by path (T13038).
804
+ const changes = parent !== null && parent.checkpointId === synced?.lastCheckpointId
805
+ ? fileChangesSinceSync(files, t, git, synced)
806
+ : null;
807
+ // Without digests, a machine without git cannot judge the marked files it
808
+ // holds (their edits are out of the hash): they count as changed (#1773 R6).
809
+ const marksUnjudged = changes === null && git.mode === 'none' && syncedMarks(t, synced).length > 0;
810
+ const unchanged = parent !== null &&
811
+ !marksUnjudged &&
812
+ compareVaultManifests(vault, parent.manifest).every((r) => r.table === VAULT_FILES_KEY && changes !== null ? changes.length === 0 : r.match);
813
+ if (parent && unchanged) {
814
+ saveSynced(conn, t, parent, { gitTracked, files: syncFiles(files) });
815
+ warnings.push(...conn.state.drainWarnings());
816
+ return {
817
+ apiUrl: conn.apiUrl,
818
+ scope,
819
+ streamId: t.streamId,
820
+ status: 'up-to-date',
821
+ snapshot: snapshotOf(parent, names),
822
+ parentCheckpointId: parent.parentCheckpointId,
823
+ deltaSegmentSeq: null,
824
+ lease: null,
825
+ forked: false,
826
+ warnings,
827
+ };
828
+ }
829
+ // A change onto a checkpoint/v3 head: refused before the lease (T13034).
830
+ if (parent && isJournalSnapshot(parent))
831
+ throw journalStreamRefusal(t, parent);
832
+ // A table the parent lists must stay listed (0 rows when it is gone); the
833
+ // parent's annotations (fork label, format record) are not tables and are
834
+ // not carried. The snapshot records the format its hashes use (T13034).
835
+ const manifest = {
836
+ schemaVersion: SYNC_SCHEMA_VERSION,
837
+ tables: { ...vault.tables, [VAULT_FORMAT_KEY]: vaultFormatEntry(hashKeyOf(t.dataKey)) },
838
+ };
839
+ if (parent) {
840
+ for (const table of Object.keys(parent.manifest.tables)) {
841
+ if (isVaultAnnotationKey(table))
842
+ continue;
843
+ manifest.tables[table] ??= { rows: 0, hash: buildEmptyHash(t, table) };
844
+ }
845
+ }
846
+ // The lease is taken only after the cheap refusals and the up-to-date
847
+ // check, and handed back when the push ends (kept only with `hold`).
848
+ const acquired = await acquireLease(conn, t, force, force ? 'cleo cloud push --force' : 'cleo cloud push');
849
+ const { lease } = acquired;
850
+ // A fork: over a head this store had not synced, or over another device's
851
+ // live lease. Labelled on the signed checkpoint, so every device's verify
852
+ // still sees it after the lease is handed back (T13007).
853
+ const forked = overUnsynced || acquired.forked;
854
+ if (forked && parent) {
855
+ manifest.tables[VAULT_FORK_KEY] = vaultForkEntry(hashKeyOf(t.dataKey), parent.checkpointId);
856
+ }
857
+ try {
858
+ // A crash before the snapshot is recorded must not leave this store behind its own push.
859
+ conn.state.markPushInFlight(conn.apiUrl, conn.userId, t.streamId, t.storeRoot, parent?.checkpointId ?? null, { gitTracked: t.scope === 'project' ? gitTracked : [], files: syncFiles(files) });
860
+ let deltaSegmentSeq = null;
861
+ let cp = null;
862
+ // A segment another device appends between our replay and our checkpoint
863
+ // makes the server's count or replica-map check fail: replay once more
864
+ // (our own delta is then part of the history) and retry (T12975). The
865
+ // retry seals the bundle under a new checkpoint id (the AAD covers it),
866
+ // so the first attempt's uploaded blob is left unreferenced: one orphan
867
+ // per retry, for the server's blob garbage collection.
868
+ for (let attempt = 0; cp === null; attempt++) {
869
+ const replay = await replaySegments(journal, parent ? cursorFromCheckpoint(parent) : initialPullCursor(), key.signers);
870
+ let cursor = replay.cursor;
871
+ // A segment of a newer sync schema appended since the head was read: refused
872
+ // before our delta segment (the push hands the lease back).
873
+ const windowAhead = aheadOfThisCleo(t, replay.maxSchemaVersion, null);
874
+ if (windowAhead)
875
+ throw windowAhead;
876
+ if (parent) {
877
+ const between = {};
878
+ for (const d of replay.deltas) {
879
+ for (const [table, v] of Object.entries(d)) {
880
+ const p = between[table] ?? { created: 0, deleted: 0 };
881
+ between[table] = { created: p.created + v.created, deleted: p.deleted + v.deleted };
882
+ }
883
+ }
884
+ const need = {};
885
+ const deltas = {};
886
+ for (const table of new Set([
887
+ ...Object.keys(manifest.tables),
888
+ ...Object.keys(parent.manifest.tables),
889
+ ...Object.keys(between),
890
+ ])) {
891
+ const expected = (parent.manifest.tables[table]?.rows ?? 0) +
892
+ (between[table]?.created ?? 0) -
893
+ (between[table]?.deleted ?? 0);
894
+ const diff = (manifest.tables[table]?.rows ?? 0) - expected;
895
+ if (diff !== 0) {
896
+ need[table] = diff;
897
+ deltas[table] = { created: Math.max(0, diff), deleted: Math.max(0, -diff) };
898
+ }
899
+ }
900
+ if (Object.keys(deltas).length > 0) {
901
+ // A replica's first segment is replicaSeq 0 (the server and every puller enforce it).
902
+ const mine = cursor.replicas[replicaId];
903
+ const replicaSeq = mine ? mine.replicaSeq + 1 : 0;
904
+ const hlc = `${String(Date.now()).padStart(13, '0')}-000000-${replicaId}`;
905
+ const appended = await journal.push(replicaSeq, Buffer.from(JSON.stringify({
906
+ kind: 'cleo-vault-delta/v1',
907
+ base: parent.checkpointId,
908
+ tables: need,
909
+ }), 'utf8'), {
910
+ opCount: 1,
911
+ hlcMin: hlc,
912
+ hlcMax: hlc,
913
+ deltas,
914
+ schemaVersion: SYNC_SCHEMA_VERSION,
915
+ });
916
+ deltaSegmentSeq = appended.seq;
917
+ cursor = {
918
+ after: appended.seq,
919
+ knowsAllReplicas: cursor.knowsAllReplicas,
920
+ replicas: {
921
+ ...cursor.replicas,
922
+ [replicaId]: { deviceId: conn.deviceId, replicaSeq },
923
+ },
924
+ };
925
+ }
926
+ }
927
+ try {
928
+ cp = await journal.pushCheckpoint({
929
+ bundle: fs.readFileSync(bundlePath),
930
+ manifest,
931
+ cursor,
932
+ parentCheckpointId: parent?.checkpointId ?? null,
933
+ });
934
+ }
935
+ catch (err) {
936
+ if (err instanceof NexusError && err.code === 'E_LINEAGE') {
937
+ throw vaultError('E_NEXUS_VAULT_BEHIND', 'another device pushed a snapshot while this one was uploading', 'run `cleo cloud pull`, then push again');
938
+ }
939
+ // The server's v3 ratchet (reason `stream-v3`). The check above refuses a v3 head before
940
+ // anything is written; this maps a server whose head this client did not see as v3.
941
+ if (err instanceof NexusError && err.code === 'E_STREAM_VERSION') {
942
+ throw streamUpgradedError(`the server refused the snapshot: ${t.streamId} takes only checkpoint/v3 snapshots (${err.message})`, JOURNAL_STREAM_REMEDY);
943
+ }
944
+ const countRefusal = err instanceof NexusError &&
945
+ (err.code === 'E_REGRESSION' || err.code === 'E_VALIDATION');
946
+ if (countRefusal && attempt === 0)
947
+ continue;
948
+ if (countRefusal) {
949
+ throw vaultError('E_NEXUS_VAULT_REFUSED', `the server refused the snapshot: ${err.message}`);
950
+ }
951
+ throw err;
952
+ }
953
+ }
954
+ saveSynced(conn, t, cp, { gitTracked, files: syncFiles(files) });
955
+ if (opts.hold !== true)
956
+ await releaseLeaseQuietly(conn, t);
957
+ warnings.push(...conn.state.drainWarnings());
958
+ return {
959
+ apiUrl: conn.apiUrl,
960
+ scope,
961
+ streamId: t.streamId,
962
+ status: 'pushed',
963
+ snapshot: snapshotOf(cp, names),
964
+ parentCheckpointId: cp.parentCheckpointId,
965
+ deltaSegmentSeq,
966
+ lease: opts.hold === true ? lease : null,
967
+ forked,
968
+ warnings,
969
+ };
970
+ }
971
+ catch (err) {
972
+ await releaseLeaseQuietly(conn, t);
973
+ throw err;
974
+ }
975
+ }
976
+ finally {
977
+ fs.rmSync(work, { recursive: true, force: true });
978
+ }
979
+ }
980
+ /** The keyed hash of an empty table (a table the parent lists that this store no longer has). */
981
+ function buildEmptyHash(t, table) {
982
+ return emptyVaultTableHash(hashKeyOf(t.dataKey), table);
983
+ }
984
+ /** Warnings for what {@link carryMachineState} could not keep. */
985
+ function carryWarnings(kept) {
986
+ const out = [];
987
+ if (kept.skipped.length > 0) {
988
+ out.push({
989
+ code: 'W_NEXUS_VAULT_LOCAL_TABLES',
990
+ message: `machine-local values taken from the snapshot (the table is shaped differently here or has no primary key): ${kept.skipped.join(', ')}`,
991
+ });
992
+ }
993
+ for (const l of kept.lost) {
994
+ out.push({
995
+ code: 'W_NEXUS_VAULT_CREDENTIALS_LOST',
996
+ message: `${l.rows} row(s) of ${l.table} held credentials on this machine that the snapshot no longer has a place for: ${l.remedy}`,
997
+ });
998
+ }
999
+ return out;
1000
+ }
1001
+ /**
1002
+ * Refuse to replace a store another process has open (T12973): a live writer
1003
+ * lease means a write is in flight, and any other open connection (an idle
1004
+ * session, the sentient daemon) would keep writing to the replaced file
1005
+ * afterwards. Every database the restore replaces is checked: the primary
1006
+ * store and the section's others (blobs manifest, attachments index, a legacy
1007
+ * `brain.db`, T13007). This process's own handles are closed first. Residual:
1008
+ * a process that opens a store between this check and the placement (a few
1009
+ * milliseconds, under the first-open lock that a store's first open also takes).
1010
+ */
1011
+ async function assertStoreQuiescent(t) {
1012
+ const { closeAllDatabases } = await import('../store/sqlite.js');
1013
+ await closeAllDatabases();
1014
+ const { _resetDualScopeDbCache } = await import('../store/dual-scope-db.js');
1015
+ _resetDualScopeDbCache();
1016
+ const held = foreignWriterLeases(t.dbPath);
1017
+ if (held.length > 0) {
1018
+ throw vaultError('E_NEXUS_VAULT_STORE_BUSY', `another CLEO process is writing to ${t.dbPath} (${held.map((h) => `${h.lane} lane, pid ${h.holderPid}`).join('; ')}); restoring now would lose its writes`, 'wait for it to finish (or stop it), then run the command again');
1019
+ }
1020
+ const { sectionRoot, scan, primaryRel } = sectionScan(t);
1021
+ const open = [
1022
+ t.dbPath,
1023
+ ...scan.sqlite.filter((rel) => rel !== primaryRel).map((rel) => path.join(sectionRoot, rel)),
1024
+ ].filter((db) => storeOpenElsewhere(db));
1025
+ if (open.length > 0) {
1026
+ throw vaultError('E_NEXUS_VAULT_STORE_BUSY', `another process has ${open.join(', ')} open (a CLEO session, daemon or tool); it would keep writing to the replaced store`, 'close it (end the session, stop the daemon with `cleo daemon stop`), then run the command again');
1027
+ }
1028
+ }
1029
+ /** Safety bundles kept per store under `backups/vault`; older ones are removed (T13007). */
1030
+ export const NEXUS_VAULT_SAFETY_BUNDLES_KEPT = 10;
1031
+ /** Keep the newest {@link NEXUS_VAULT_SAFETY_BUNDLES_KEPT} safety bundles in `dir`. */
1032
+ function rotateSafetyBundles(dir) {
1033
+ const bundles = fs
1034
+ .readdirSync(dir)
1035
+ .filter((name) => /^pre-restore-.+\.cleobundle\.tar\.gz$/.test(name))
1036
+ .sort();
1037
+ for (const name of bundles.slice(0, Math.max(0, bundles.length - NEXUS_VAULT_SAFETY_BUNDLES_KEPT))) {
1038
+ fs.rmSync(path.join(dir, name), { force: true });
1039
+ }
1040
+ }
1041
+ /**
1042
+ * What a safety bundle holds of the store's section: each plain file's
1043
+ * SHA-256, and each database (`null`; a VACUUM copy never hashes like the
1044
+ * live file). Read from the bundle's own manifest.
1045
+ */
1046
+ async function safetyInventory(bundlePath, t) {
1047
+ const dir = tempDir('cleo-vault-safety-');
1048
+ try {
1049
+ await tarExtract({ file: bundlePath, cwd: dir, filter: (p) => p === 'manifest.json' });
1050
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
1051
+ const section = t.scope === 'global' ? manifest.global?.home : manifest.projects[0];
1052
+ const out = new Map();
1053
+ for (const f of section?.files ?? [])
1054
+ out.set(f.relPath, f.sha256);
1055
+ for (const d of section?.databases ?? [])
1056
+ out.set(d.relPath, null);
1057
+ return out;
1058
+ }
1059
+ finally {
1060
+ fs.rmSync(dir, { recursive: true, force: true });
1061
+ }
1062
+ }
1063
+ /**
1064
+ * After a verified snapshot is placed, remove what this store holds inside
1065
+ * the vault's inventory scope that the snapshot does not list (T13004), so a
1066
+ * file or database another device deleted is deleted here too: inventory
1067
+ * files, and databases other than the primary store (with their sidecars).
1068
+ * Never removed (T13019, T13020): machine-local files
1069
+ * ({@link machineLocalFile}), secrets and excluded directories (outside the
1070
+ * scope); a path this checkout's git tracks (or any file, when git cannot
1071
+ * say); and anything the safety bundle does not hold as it is now (a file
1072
+ * created or edited after the safety export). Runs only after the safety
1073
+ * backup, under the store's first-open lock.
1074
+ *
1075
+ * @returns The removed paths, relative to the section root.
1076
+ */
1077
+ async function pruneUnlisted(t, listed, git, safety) {
1078
+ const { sectionRoot, scan, primaryRel } = sectionScan(t);
1079
+ const removed = [];
1080
+ const remove = (rel, sidecars) => {
1081
+ const abs = path.join(sectionRoot, rel);
1082
+ fs.rmSync(abs, { force: true });
1083
+ if (sidecars) {
1084
+ for (const suffix of ['-wal', '-shm', '-journal'])
1085
+ fs.rmSync(`${abs}${suffix}`, { force: true });
1086
+ }
1087
+ // Leave no emptied directory behind (never the section root itself).
1088
+ for (let dir = path.dirname(abs); dir.startsWith(`${sectionRoot}${path.sep}`); dir = path.dirname(dir)) {
1089
+ try {
1090
+ fs.rmdirSync(dir);
1091
+ }
1092
+ catch {
1093
+ break;
1094
+ }
1095
+ }
1096
+ removed.push(rel);
1097
+ };
1098
+ if (git.mode !== 'unknown') {
1099
+ for (const rel of scan.files) {
1100
+ if (inventoryExcluded(rel) || listed.has(rel) || git.tracked.has(rel))
1101
+ continue;
1102
+ const held = safety.get(rel);
1103
+ if (held === undefined || held === null)
1104
+ continue;
1105
+ if ((await sha256File(path.join(sectionRoot, rel))) === held)
1106
+ remove(rel, false);
1107
+ }
1108
+ }
1109
+ for (const rel of scan.sqlite) {
1110
+ if (rel !== primaryRel && !listed.has(rel) && safety.has(rel))
1111
+ remove(rel, true);
1112
+ }
1113
+ return removed;
1114
+ }
1115
+ /** The global `config.json` install keys of a file, to keep them across a restore (T13022). */
1116
+ function readGlobalConfigLocalKeys(configPath) {
1117
+ const out = new Map();
1118
+ let doc = null;
1119
+ try {
1120
+ doc = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1121
+ }
1122
+ catch {
1123
+ doc = null;
1124
+ }
1125
+ for (const [section, key] of VAULT_GLOBAL_CONFIG_LOCAL_KEYS) {
1126
+ const value = doc?.[section]?.[key];
1127
+ out.set(`${section}.${key}`, typeof value === 'string' ? value : null);
1128
+ }
1129
+ return out;
1130
+ }
1131
+ /**
1132
+ * Put this machine's install keys back into the placed global `config.json`
1133
+ * (absent here: removed from the placed copy), so the snapshot's install id
1134
+ * never becomes this machine's (T13022). A placed file that is not JSON is
1135
+ * left as it is.
1136
+ */
1137
+ function keepGlobalConfigLocalKeys(configPath, local) {
1138
+ let doc;
1139
+ try {
1140
+ doc = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1141
+ }
1142
+ catch {
1143
+ return;
1144
+ }
1145
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc))
1146
+ return;
1147
+ let changed = false;
1148
+ for (const [section, key] of VAULT_GLOBAL_CONFIG_LOCAL_KEYS) {
1149
+ const want = local.get(`${section}.${key}`) ?? null;
1150
+ const inner = doc[section];
1151
+ const block = inner !== null && typeof inner === 'object' && !Array.isArray(inner) ? inner : null;
1152
+ const have = block?.[key];
1153
+ if (want === null) {
1154
+ if (block !== null && have !== undefined) {
1155
+ delete block[key];
1156
+ changed = true;
1157
+ }
1158
+ }
1159
+ else if (have !== want) {
1160
+ doc[section] = { ...(block ?? {}), [key]: want };
1161
+ changed = true;
1162
+ }
1163
+ }
1164
+ if (!changed)
1165
+ return;
1166
+ const tmp = `${configPath}.${process.pid}.tmp`;
1167
+ fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`);
1168
+ fs.renameSync(tmp, configPath);
1169
+ }
1170
+ /** `cleo cloud pull` and `cleo cloud restore`. */
1171
+ async function restoreNexusVaultImpl(opts) {
1172
+ const conn = await connectNexusVault(opts);
1173
+ // Read-only: a restore never mints, escrows or certifies (T12974).
1174
+ const key = await unlockNexusAccountKey(conn, { readOnly: true });
1175
+ const warnings = [...conn.warnings];
1176
+ let t;
1177
+ if (opts.scope !== 'global' && opts.projectId) {
1178
+ // The target of a new-machine restore is where the user stands (it is not
1179
+ // a project yet), never an enclosing project root.
1180
+ const into = path.resolve(opts.into ?? opts.projectRoot ?? process.cwd()); // CWD-OK: restore target directory, not a project root lookup
1181
+ const dataKey = await nexusProjectDataKey(conn, key.masterKey, opts.projectId, false);
1182
+ if (dataKey === null) {
1183
+ throw vaultError('E_NEXUS_VAULT_EMPTY', `the cloud holds no snapshot of project ${opts.projectId}`);
1184
+ }
1185
+ t = {
1186
+ scope: 'project',
1187
+ streamId: projectStream(opts.projectId),
1188
+ storeRoot: into,
1189
+ dbPath: path.join(into, '.cleo', 'cleo.db'),
1190
+ projectId: opts.projectId,
1191
+ replicaId: null,
1192
+ dataKey,
1193
+ };
1194
+ }
1195
+ else {
1196
+ t = await resolveTarget(conn, key.masterKey, opts, 'read');
1197
+ }
1198
+ const journal = journalFor(conn, t);
1199
+ const head = await streamHead(conn, t.streamId);
1200
+ const target = opts.checkpointId ?? head.headCheckpointId;
1201
+ if (!target) {
1202
+ throw vaultError('E_NEXUS_VAULT_EMPTY', `the cloud holds no snapshot on ${t.streamId}`, 'run `cleo cloud push` on a device that has the data');
1203
+ }
1204
+ const cps = await listCheckpoints(conn, t.streamId);
1205
+ // Only a snapshot whose signature verifies says what this store holds (T13007).
1206
+ const trusted = trustedCheckpoints(journal, cps, key.signers, null);
1207
+ const { state: synced, adopted } = syncedState(conn, t, trusted, head.headCheckpointId);
1208
+ if (adopted) {
1209
+ saveSynced(conn, t, adopted, {
1210
+ gitTracked: syncedMarks(t, synced),
1211
+ ...(synced?.files !== undefined ? { files: synced.files } : {}),
1212
+ });
1213
+ }
1214
+ const names = await deviceNames(conn);
1215
+ if (opts.mode === 'pull' && synced?.lastCheckpointId === target && opts.force !== true) {
1216
+ const cp = cps.find((c) => c.checkpointId === target) ?? null;
1217
+ warnings.push(...conn.state.drainWarnings());
1218
+ return {
1219
+ apiUrl: conn.apiUrl,
1220
+ scope: t.scope,
1221
+ streamId: t.streamId,
1222
+ status: 'up-to-date',
1223
+ snapshot: cp ? snapshotOf(cp, names) : null,
1224
+ target: t.storeRoot,
1225
+ verified: false,
1226
+ tables: 0,
1227
+ safetyBackup: null,
1228
+ warnings,
1229
+ };
1230
+ }
1231
+ // A snapshot hashed under another vault manifest format cannot be checked
1232
+ // against its manifest here; say so instead of reporting a mismatch (T13034).
1233
+ const targetCp = trusted.find((c) => c.checkpointId === target);
1234
+ const format = targetCp ? vaultManifestFormat(targetCp.manifest, hashKeyOf(t.dataKey)) : null;
1235
+ if (format !== null && format !== VAULT_MANIFEST_FORMAT_VERSION) {
1236
+ throw vaultError('E_NEXUS_VAULT_VERIFY_FAILED', format === 'unknown'
1237
+ ? `snapshot ${target} records a vault manifest format this CLEO does not recognise (this CLEO hashes format ${VAULT_MANIFEST_FORMAT_VERSION}; a different vault format wrote it, most likely a newer CLEO), so it cannot be verified here; nothing was restored`
1238
+ : `snapshot ${target} was hashed under vault manifest format ${format}, older than this CLEO's (${VAULT_MANIFEST_FORMAT_VERSION}), so it cannot be verified here; nothing was restored`, format === 'unknown'
1239
+ ? 'if a newer CLEO pushed it, upgrade CLEO on this machine, then run the command again (`cleo cloud vault` shows which device pushed it)'
1240
+ : 'push a new snapshot from a device that holds this data with a current CLEO, then run the command again');
1241
+ }
1242
+ const git = gitTracking(t);
1243
+ if (git.mode === 'unknown')
1244
+ warnings.push(gitUnknownWarning(t));
1245
+ // Never overwrite unsynced local work without --force: tables, other
1246
+ // databases and files alike (T12969).
1247
+ const hasLocal = fs.existsSync(t.dbPath);
1248
+ const force = opts.force === true;
1249
+ const last = synced ? trusted.find((c) => c.checkpointId === synced.lastCheckpointId) : undefined;
1250
+ const local = hasLocal && !force ? await localManifest(t, git, untrackedComparison(t, git, synced)) : null;
1251
+ if (local !== null) {
1252
+ const hasRows = Object.entries(local.manifest.tables).some(([name, x]) => x.rows > 0 && name !== VAULT_FILES_KEY);
1253
+ const fileChanges = fileChangesSinceSync(local.files, t, git, synced);
1254
+ const rows = last ? compareWithSynced(local.manifest, last.manifest, git, fileChanges) : [];
1255
+ if ((!last && hasRows) || rows.some((d) => !d.match)) {
1256
+ const changed = rows
1257
+ .filter((d) => !d.match)
1258
+ .map((d) => d.table === VAULT_FILES_KEY && fileChanges !== null && fileChanges.length > 0
1259
+ ? `${d.table}: ${fileChanges.slice(0, 5).join(', ')}${fileChanges.length > 5 ? ', …' : ''}`
1260
+ : d.table);
1261
+ throw vaultError('E_NEXUS_VAULT_LOCAL_CHANGES', last
1262
+ ? `this store changed since its last cloud sync (${changed.slice(0, 8).join(', ')}); restoring would overwrite those changes`
1263
+ : 'this store holds data that was never synced with this cloud snapshot; restoring would replace it', isJournalSnapshot(targetCp)
1264
+ ? `to keep them: ${JOURNAL_STREAM_REMEDY}; or pass --force to restore anyway (a safety backup is taken first)`
1265
+ : 'run `cleo cloud push` to keep them (as a fork with --force if another device pushed since), or pass --force to restore anyway (a safety backup is taken first)');
1266
+ }
1267
+ }
1268
+ // Local files this store never synced (no synced snapshot to judge them by)
1269
+ // are kept as they are without --force: never overwritten, never removed (T13020).
1270
+ const keepUnsynced = hasLocal && !force && !last;
1271
+ const restored = await journal.restoreCheckpoint(target, key.signers, {
1272
+ minCoversSeq: opts.checkpointId ? 0 : (synced?.lastCoversSeq ?? 0),
1273
+ });
1274
+ const work = tempDir('cleo-vault-restore-');
1275
+ try {
1276
+ const bundlePath = path.join(work, 'snapshot.cleobundle.tar.gz');
1277
+ fs.writeFileSync(bundlePath, restored.bundle);
1278
+ if (hasLocal)
1279
+ await assertStoreQuiescent(t);
1280
+ let safetyBackup = null;
1281
+ let safety = new Map();
1282
+ if (hasLocal) {
1283
+ const dir = t.scope === 'global'
1284
+ ? path.join(t.storeRoot, 'backups', 'vault')
1285
+ : path.join(t.storeRoot, '.cleo', 'backups', 'vault');
1286
+ fs.mkdirSync(dir, { recursive: true });
1287
+ safetyBackup = path.join(dir, `pre-restore-${new Date().toISOString().replace(/[:.]/g, '-')}.cleobundle.tar.gz`);
1288
+ await exportPortableBundle({
1289
+ scope: t.scope === 'global' ? 'global' : 'project',
1290
+ ...(t.scope === 'project' ? { projectRoot: t.storeRoot } : {}),
1291
+ outputPath: safetyBackup,
1292
+ label: 'cloud-vault-pre-restore',
1293
+ });
1294
+ safety = await safetyInventory(safetyBackup, t);
1295
+ rotateSafetyBundles(dir);
1296
+ }
1297
+ let tables = 0;
1298
+ // Every path the snapshot lists in its section; the rest of the inventory is removed.
1299
+ const listed = new Set();
1300
+ // Paths the pusher's git tracks: placed only into a directory that is not a checkout (T13019).
1301
+ const snapshotTracked = new Set();
1302
+ // The snapshot's plain files, path to digest, recorded with the sync (T13038).
1303
+ const snapshotFiles = new Map();
1304
+ // Never-synced local files kept in place of the snapshot's copies (T13020).
1305
+ const keptUnsynced = [];
1306
+ const placement = (rel) => {
1307
+ const own = machineLocalFile(rel);
1308
+ if (own !== null)
1309
+ return own;
1310
+ if (git.mode !== 'none' && (snapshotTracked.has(rel) || git.tracked.has(rel)))
1311
+ return 'skip';
1312
+ return git.mode === 'unknown' || keepUnsynced ? 'keep' : null;
1313
+ };
1314
+ const globalConfig = t.scope === 'global' ? path.join(t.storeRoot, 'config.json') : null;
1315
+ const ownConfigKeys = globalConfig !== null && fs.existsSync(globalConfig)
1316
+ ? readGlobalConfigLocalKeys(globalConfig)
1317
+ : new Map();
1318
+ if (t.scope === 'project')
1319
+ fs.mkdirSync(t.storeRoot, { recursive: true });
1320
+ const place = () => importPortableBundle({
1321
+ bundlePath,
1322
+ ...(t.scope === 'project' ? { target: t.storeRoot } : {}),
1323
+ force: true,
1324
+ cwd: t.storeRoot,
1325
+ confirmOwnerStore: true,
1326
+ requireLossless: false,
1327
+ // This machine keeps its own link, worktree index, scratch, git-tracked
1328
+ // files and never-synced files (T13005, T13019, T13020).
1329
+ machineLocalFile: placement,
1330
+ onStaged: async (extractDir, manifest) => {
1331
+ // A project restored onto this machine never lands on another project (T12976).
1332
+ if (opts.projectId && !force) {
1333
+ const present = readDeclaredProjectIdentity(t.storeRoot);
1334
+ const incoming = manifest.projects[0]?.projectId ?? null;
1335
+ if (present && incoming !== null && present.projectId !== incoming) {
1336
+ throw vaultError('E_NEXUS_VAULT_TARGET_OCCUPIED', `${t.storeRoot} already holds project ${present.projectId}, not ${incoming}`, 'pass --into an empty directory, or --force to replace it (a safety backup is taken first)');
1337
+ }
1338
+ }
1339
+ const staged = await snapshotManifest(extractDir, manifest, t);
1340
+ const diff = compareVaultManifests(staged.vault, restored.checkpoint.manifest).filter((d) => !d.match);
1341
+ if (diff.length > 0) {
1342
+ throw vaultError('E_NEXUS_VAULT_VERIFY_FAILED', `snapshot ${target} does not match its manifest (${diff.map((d) => d.table).join(', ')}); nothing was restored`);
1343
+ }
1344
+ tables = Object.keys(restored.checkpoint.manifest.tables).filter((k) => !isVaultAnnotationKey(k)).length;
1345
+ const section = t.scope === 'global' ? manifest.global?.home : manifest.projects[0];
1346
+ for (const e of [
1347
+ ...(section?.files ?? []),
1348
+ ...(section?.databases ?? []),
1349
+ ...(section?.symlinks ?? []),
1350
+ ]) {
1351
+ listed.add(e.relPath);
1352
+ }
1353
+ for (const p of snapshotGitTracked(manifest, t))
1354
+ snapshotTracked.add(p);
1355
+ for (const [p, digest] of staged.files)
1356
+ snapshotFiles.set(p, digest);
1357
+ if (keepUnsynced && git.mode !== 'unknown') {
1358
+ const sectionRoot = sectionScan(t).sectionRoot;
1359
+ const snapshotRoot = manifest.projects[0]?.originalPath ?? null;
1360
+ for (const f of section?.files ?? []) {
1361
+ const here = path.join(sectionRoot, f.relPath);
1362
+ if (placement(f.relPath) !== 'keep' || machineLocalFile(f.relPath) !== null)
1363
+ continue;
1364
+ if (!fs.existsSync(here))
1365
+ continue;
1366
+ const scope = tableScopeOf(t);
1367
+ const mine = await vaultFileDigest(here, f.relPath, {
1368
+ scope,
1369
+ root: t.scope === 'global' ? null : t.storeRoot,
1370
+ });
1371
+ const theirs = await vaultFileDigest(path.join(extractDir, f.bundlePath), f.relPath, {
1372
+ scope,
1373
+ root: t.scope === 'global' ? null : snapshotRoot,
1374
+ sha256: f.sha256,
1375
+ });
1376
+ if (mine !== theirs)
1377
+ keptUnsynced.push(f.relPath);
1378
+ }
1379
+ }
1380
+ // Checked again under the lock.
1381
+ if (hasLocal)
1382
+ await assertStoreQuiescent(t);
1383
+ // Keep this machine's own state: local-only tables and columns,
1384
+ // credentials (T12966, T12967). A store new to this machine is
1385
+ // carried against none, so no machine state of the pusher's arrives
1386
+ // (T13007).
1387
+ warnings.push(...carryWarnings(carryMachineState(staged.dbPath, hasLocal ? t.dbPath : null, tableScopeOf(t), {
1388
+ snapshotRoot: t.scope === 'global' ? null : (manifest.projects[0]?.originalPath ?? null),
1389
+ })));
1390
+ },
1391
+ });
1392
+ const placeAndPrune = async () => {
1393
+ await place();
1394
+ // The snapshot's install id never becomes this machine's (T13022).
1395
+ if (globalConfig !== null && fs.existsSync(globalConfig)) {
1396
+ keepGlobalConfigLocalKeys(globalConfig, ownConfigKeys);
1397
+ }
1398
+ // Deletions propagate: what the snapshot no longer lists goes (T13004);
1399
+ // without a synced snapshot, only --force removes anything (T13020).
1400
+ if (!hasLocal || keepUnsynced)
1401
+ return;
1402
+ const removed = await pruneUnlisted(t, listed, git, safety);
1403
+ if (removed.length > 0) {
1404
+ warnings.push({
1405
+ code: 'W_NEXUS_VAULT_REMOVED',
1406
+ message: `removed ${removed.length} file(s) the snapshot no longer has (the safety backup ${safetyBackup} keeps a copy): ${removed.slice(0, 8).join(', ')}${removed.length > 8 ? ', …' : ''}`,
1407
+ });
1408
+ }
1409
+ };
1410
+ // Serialise with first-open migrations and auto-recovery of the same store.
1411
+ if (hasLocal) {
1412
+ await withLock(t.dbPath + FIRST_OPEN_LOCK_SUFFIX, placeAndPrune);
1413
+ }
1414
+ else {
1415
+ await placeAndPrune();
1416
+ }
1417
+ if (keptUnsynced.length > 0) {
1418
+ warnings.push({
1419
+ code: 'W_NEXUS_VAULT_KEPT_LOCAL',
1420
+ message: `kept ${keptUnsynced.length} local file(s) this store never synced with the cloud, in place of the snapshot's copies: ${keptUnsynced.slice(0, 8).join(', ')}${keptUnsynced.length > 8 ? ', …' : ''}; ${isJournalSnapshot(restored.checkpoint) ? `to keep them: ${JOURNAL_STREAM_REMEDY}; or` : 'push to keep them, or'} pull with --force to take the snapshot's`,
1421
+ });
1422
+ }
1423
+ saveSynced(conn, t, restored.checkpoint, {
1424
+ gitTracked: [...snapshotTracked],
1425
+ files: syncFiles(snapshotFiles),
1426
+ });
1427
+ if (t.scope === 'project' && opts.relink) {
1428
+ for (const message of await opts.relink(t.storeRoot)) {
1429
+ warnings.push({ code: 'W_NEXUS_VAULT_RELINK', message });
1430
+ }
1431
+ // Later commands key this store by the stream its link names (T13007).
1432
+ const linked = readNexusProjectLink(t.storeRoot, conn.apiUrl);
1433
+ if (linked?.streamId && linked.streamId !== t.streamId) {
1434
+ saveSynced(conn, t, restored.checkpoint, {
1435
+ gitTracked: [...snapshotTracked],
1436
+ files: syncFiles(snapshotFiles),
1437
+ streamId: linked.streamId,
1438
+ });
1439
+ }
1440
+ }
1441
+ warnings.push(...conn.state.drainWarnings());
1442
+ return {
1443
+ apiUrl: conn.apiUrl,
1444
+ scope: t.scope,
1445
+ streamId: t.streamId,
1446
+ status: 'restored',
1447
+ snapshot: snapshotOf(restored.checkpoint, names),
1448
+ target: t.storeRoot,
1449
+ verified: true,
1450
+ tables,
1451
+ safetyBackup,
1452
+ warnings,
1453
+ };
1454
+ }
1455
+ finally {
1456
+ fs.rmSync(work, { recursive: true, force: true });
1457
+ }
1458
+ }
1459
+ /** `cleo cloud vault`: lineage, last push per device, pending changes, leases. */
1460
+ async function nexusVaultStatusImpl(opts = {}) {
1461
+ const conn = await connectNexusVault(opts);
1462
+ const key = await unlockNexusAccountKey(conn, { readOnly: true });
1463
+ const t = await resolveTarget(conn, key.masterKey, opts, 'read');
1464
+ const warnings = [...conn.warnings];
1465
+ const names = await deviceNames(conn);
1466
+ const head = await streamHead(conn, t.streamId);
1467
+ const cps = await listCheckpoints(conn, t.streamId);
1468
+ const lineage = cps.map((c) => snapshotOf(c, names));
1469
+ const byDevice = new Map();
1470
+ for (const s of lineage)
1471
+ if (!byDevice.has(s.deviceId))
1472
+ byDevice.set(s.deviceId, s);
1473
+ const trusted = trustedCheckpoints(journalFor(conn, t), cps, key.signers, warnings);
1474
+ const { state: synced } = syncedState(conn, t, trusted, head.headCheckpointId);
1475
+ const last = synced ? trusted.find((c) => c.checkpointId === synced.lastCheckpointId) : undefined;
1476
+ const git = gitTracking(t);
1477
+ if (git.mode === 'unknown')
1478
+ warnings.push(gitUnknownWarning(t));
1479
+ const local = await localManifest(t, git, untrackedComparison(t, git, synced));
1480
+ const pendingChanges = local && last
1481
+ ? compareWithSynced(local.manifest, last.manifest, git, fileChangesSinceSync(local.files, t, git, synced)).filter((d) => !d.match)
1482
+ : [];
1483
+ const lastFormat = formatWarning(t, last);
1484
+ if (lastFormat)
1485
+ warnings.push(lastFormat);
1486
+ // On a stream the change journal writes, these changes do not go out with a push (T13034).
1487
+ const headCp = trusted.find((c) => c.checkpointId === head.headCheckpointId);
1488
+ if (headCp && isJournalSnapshot(headCp) && pendingChanges.length > 0) {
1489
+ warnings.push({
1490
+ code: 'W_NEXUS_VAULT_JOURNAL_STREAM',
1491
+ message: `${pendingChanges.length} table(s) changed since the last sync, and the newest snapshot ${headCp.checkpointId} is checkpoint/v3 (the change journal writes this stream): ${JOURNAL_STREAM_REMEDY}`,
1492
+ });
1493
+ }
1494
+ return {
1495
+ apiUrl: conn.apiUrl,
1496
+ scope: t.scope,
1497
+ streamId: t.streamId,
1498
+ headSeq: head.headSeq,
1499
+ head: lineage.find((s) => s.checkpointId === head.headCheckpointId) ?? null,
1500
+ lineage,
1501
+ lastPushByDevice: [...byDevice.values()].map((s) => ({
1502
+ deviceId: s.deviceId,
1503
+ deviceName: s.deviceName,
1504
+ checkpointId: s.checkpointId,
1505
+ createdAt: s.createdAt,
1506
+ })),
1507
+ pendingChanges,
1508
+ lastSynced: synced?.lastCheckpointId ?? null,
1509
+ leases: await leasesOf(conn, t, names, warnings),
1510
+ warnings: [...warnings, ...conn.state.drainWarnings()],
1511
+ };
1512
+ }
1513
+ /**
1514
+ * The fork labels this store has not synced past: labelled snapshots
1515
+ * ({@link VAULT_FORK_KEY}) on the lineage from the head back to the
1516
+ * snapshot this store last synced, exclusive, by another device (T13007).
1517
+ * A store that never synced sees only the head's label.
1518
+ */
1519
+ function unsyncedForks(trusted, headCheckpointId, syncedCheckpointId, deviceId) {
1520
+ const byId = new Map(trusted.map((c) => [c.checkpointId, c]));
1521
+ const out = [];
1522
+ let id = headCheckpointId;
1523
+ for (let walked = 0; id !== null && id !== syncedCheckpointId && walked < byId.size; walked++) {
1524
+ const cp = byId.get(id);
1525
+ if (!cp)
1526
+ break;
1527
+ if (isVaultForkManifest(cp.manifest) && cp.deviceId !== deviceId)
1528
+ out.push(cp);
1529
+ if (syncedCheckpointId === null)
1530
+ break;
1531
+ id = cp.parentCheckpointId;
1532
+ }
1533
+ return out;
1534
+ }
1535
+ /** `cleo cloud verify`: local integrity, local vs head per table, and every device's newest snapshot vs the head. */
1536
+ async function verifyNexusVaultImpl(opts = {}) {
1537
+ const conn = await connectNexusVault(opts);
1538
+ const key = await unlockNexusAccountKey(conn, { readOnly: true });
1539
+ const t = await resolveTarget(conn, key.masterKey, opts, 'read');
1540
+ const warnings = [...conn.warnings];
1541
+ const names = await deviceNames(conn);
1542
+ const journal = journalFor(conn, t);
1543
+ const head = await streamHead(conn, t.streamId);
1544
+ const cps = await listCheckpoints(conn, t.streamId);
1545
+ // A snapshot whose signature does not verify is reported and decides nothing (T13007).
1546
+ const trusted = trustedCheckpoints(journal, cps, key.signers, warnings);
1547
+ const headCp = trusted.find((c) => c.checkpointId === head.headCheckpointId) ?? null;
1548
+ const headUntrusted = head.headCheckpointId !== null && headCp === null;
1549
+ const localIntegrity = fs.existsSync(t.dbPath) ? integrityCheck(t.dbPath) === 'ok' : false;
1550
+ const { state: synced } = syncedState(conn, t, trusted, head.headCheckpointId);
1551
+ const git = gitTracking(t);
1552
+ if (git.mode === 'unknown')
1553
+ warnings.push(gitUnknownWarning(t));
1554
+ const read = await localManifest(t, git, untrackedComparison(t, git, synced));
1555
+ const local = read?.manifest ?? { schemaVersion: SYNC_SCHEMA_VERSION, tables: {} };
1556
+ // Files changed since the last sync, path by path (T13038).
1557
+ const fileChanges = read ? fileChangesSinceSync(read.files, t, git, synced) : null;
1558
+ const last = synced ? trusted.find((c) => c.checkpointId === synced.lastCheckpointId) : undefined;
1559
+ const tables = headCp
1560
+ ? compareWithSynced(local, headCp.manifest, git, headCp.checkpointId === last?.checkpointId ? fileChanges : null)
1561
+ : [];
1562
+ let verdict;
1563
+ if (headUntrusted)
1564
+ verdict = 'untrusted';
1565
+ else if (!headCp)
1566
+ verdict = 'empty';
1567
+ else if (tables.every((x) => x.match))
1568
+ verdict = 'match';
1569
+ else {
1570
+ const localChanged = last
1571
+ ? compareWithSynced(local, last.manifest, git, fileChanges).some((d) => !d.match)
1572
+ : true;
1573
+ const cloudAdvanced = synced?.lastCheckpointId !== headCp.checkpointId;
1574
+ verdict = localChanged && cloudAdvanced ? 'diverged' : cloudAdvanced ? 'behind' : 'ahead';
1575
+ }
1576
+ const newest = new Map();
1577
+ for (const cp of trusted)
1578
+ if (!newest.has(cp.deviceId))
1579
+ newest.set(cp.deviceId, cp);
1580
+ // Only a fork this machine has not synced past is news to it (T12976). The
1581
+ // label is on the checkpoint, so it outlives the lease (T13007); a forced
1582
+ // lease whose push has not landed yet is reported from the lease.
1583
+ const fork = unsyncedForks(trusted, headCp?.checkpointId ?? null, synced?.lastCheckpointId ?? null, conn.deviceId)[0];
1584
+ const forkedLease = (await leasesOf(conn, t, names, warnings)).find((l) => l.forkedFromReplicaId !== null &&
1585
+ !l.mine &&
1586
+ (synced === null ||
1587
+ l.acquiredAt === null ||
1588
+ l.acquiredAt === undefined ||
1589
+ l.acquiredAt > synced.updatedAt));
1590
+ if (fork) {
1591
+ const over = trusted.find((c) => c.checkpointId === fork.parentCheckpointId);
1592
+ warnings.push({
1593
+ code: 'W_NEXUS_VAULT_FORK',
1594
+ message: `snapshot ${fork.checkpointId} by ${names.get(fork.deviceId) ?? fork.deviceId} is a labelled fork: pushed with --force over snapshot ${fork.parentCheckpointId}${over ? ` (replica ${over.replicaId})` : ''}, which that device had not synced${forkedLease ? `; the write lease was taken by force from replica ${forkedLease.forkedFromReplicaId}` : ''}`,
1595
+ });
1596
+ }
1597
+ else if (forkedLease) {
1598
+ warnings.push({
1599
+ code: 'W_NEXUS_VAULT_FORK',
1600
+ message: `the write lease was taken by force from replica ${forkedLease.forkedFromReplicaId}: the newest snapshot is a labelled fork`,
1601
+ });
1602
+ }
1603
+ // A head hashed another way compares as changed everywhere; a head the change
1604
+ // journal writes takes no push (T13034).
1605
+ const headFormat = formatWarning(t, headCp);
1606
+ if (headFormat)
1607
+ warnings.push(headFormat);
1608
+ const journalHead = isJournalSnapshot(headCp);
1609
+ const remedy = verdict === 'untrusted'
1610
+ ? `the newest snapshot ${head.headCheckpointId} is not signed by a device this account trusts: do not pull it; see which device pushed it with \`cleo cloud vault\` and \`cleo cloud activity\`, and revoke that device if you do not recognise it`
1611
+ : headCp && vaultManifestFormat(headCp.manifest, hashKeyOf(t.dataKey)) === 'unknown'
1612
+ ? `the newest snapshot records a vault manifest format this CLEO does not recognise (this CLEO hashes format ${VAULT_MANIFEST_FORMAT_VERSION}): if a newer CLEO pushed it, upgrade CLEO on this machine, then verify again`
1613
+ : verdict === 'behind'
1614
+ ? 'run `cleo cloud pull` to bring this machine to the newest snapshot'
1615
+ : verdict === 'ahead'
1616
+ ? journalHead
1617
+ ? `this machine changed since the newest snapshot, which is checkpoint/v3 (the change journal writes this stream): ${JOURNAL_STREAM_REMEDY}`
1618
+ : 'run `cleo cloud push` to back up the local changes'
1619
+ : verdict === 'diverged'
1620
+ ? journalHead
1621
+ ? `both sides changed, and the newest snapshot is checkpoint/v3 (the change journal writes this stream): ${JOURNAL_STREAM_REMEDY}; or \`cleo cloud pull --force\` to take the cloud (a safety backup is taken first)`
1622
+ : 'both sides changed: run `cleo cloud push --force` to keep this machine (a labelled fork), or `cleo cloud pull --force` to take the cloud (a safety backup is taken first)'
1623
+ : verdict === 'empty'
1624
+ ? 'run `cleo cloud push` to make the first snapshot'
1625
+ : !localIntegrity
1626
+ ? 'the local store failed its integrity check: run `cleo doctor` and `cleo cloud restore`'
1627
+ : null;
1628
+ return {
1629
+ apiUrl: conn.apiUrl,
1630
+ scope: t.scope,
1631
+ streamId: t.streamId,
1632
+ verdict,
1633
+ remedy,
1634
+ localIntegrity,
1635
+ head: headCp ? snapshotOf(headCp, names) : null,
1636
+ lastSynced: synced?.lastCheckpointId ?? null,
1637
+ tables,
1638
+ devices: [...newest.values()].map((cp) => ({
1639
+ deviceId: cp.deviceId,
1640
+ deviceName: names.get(cp.deviceId) ?? null,
1641
+ checkpointId: cp.checkpointId,
1642
+ createdAt: cp.createdAt ?? null,
1643
+ matchesHead: headCp ? sameVaultManifest(cp.manifest, headCp.manifest) : false,
1644
+ })),
1645
+ warnings: [...warnings, ...conn.state.drainWarnings()],
1646
+ };
1647
+ }
1648
+ /** `cleo cloud lease release`: hand the write lease back so another device can push. */
1649
+ async function releaseNexusVaultLeaseImpl(opts = {}) {
1650
+ const conn = await connectNexusVault(opts);
1651
+ const key = await unlockNexusAccountKey(conn, { readOnly: true });
1652
+ const t = await resolveTarget(conn, key.masterKey, opts, 'read');
1653
+ if (!t.replicaId) {
1654
+ throw vaultError('E_NEXUS_VAULT_NOT_LINKED', 'this copy is not attached from this device', 'run `cleo project link`');
1655
+ }
1656
+ let released = true;
1657
+ try {
1658
+ await conn.raw('DELETE', `${streamPath(t.streamId)}/leases/${NEXUS_VAULT_LEASE_ROLE}?replicaId=${encodeURIComponent(t.replicaId)}`, z.looseObject({}));
1659
+ }
1660
+ catch (err) {
1661
+ if (!(err instanceof NexusError && err.status === 404))
1662
+ throw err;
1663
+ released = false;
1664
+ }
1665
+ return {
1666
+ apiUrl: conn.apiUrl,
1667
+ scope: t.scope,
1668
+ streamId: t.streamId,
1669
+ released,
1670
+ warnings: [...conn.warnings],
1671
+ };
1672
+ }
1673
+ /** Run a vault command; a raw API failure becomes a mapped {@link NexusAccountError}. */
1674
+ async function mapped(run) {
1675
+ try {
1676
+ return await run();
1677
+ }
1678
+ catch (err) {
1679
+ throw err instanceof NexusError ? nexusApiErrorToAccountError(err) : err;
1680
+ }
1681
+ }
1682
+ /**
1683
+ * `cleo cloud push`: snapshot this store into the vault under the write lease.
1684
+ *
1685
+ * @param opts - Scope, `force` (take the lease, push over a newer head as a labelled fork),
1686
+ * `hold` (keep the writer lease after the push instead of releasing it) and overrides.
1687
+ * @returns What was pushed.
1688
+ * @throws {NexusAccountError} `E_NEXUS_VAULT_LEASE_HELD`, `E_NEXUS_VAULT_BEHIND`, `E_NEXUS_VAULT_REFUSED`,
1689
+ * `E_NEXUS_VAULT_STREAM_UPGRADED`, `E_NEXUS_VAULT_NOT_LINKED`, `E_NEXUS_VAULT_KEY_UNAVAILABLE`, or a
1690
+ * mapped API error.
1691
+ */
1692
+ export function pushNexusVault(opts = {}) {
1693
+ return mapped(() => pushNexusVaultImpl(opts));
1694
+ }
1695
+ /**
1696
+ * `cleo cloud pull` / `cleo cloud restore`: verify and activate a snapshot.
1697
+ *
1698
+ * @param opts - Mode, snapshot, project and target overrides.
1699
+ * @returns What was restored.
1700
+ * @throws {NexusAccountError} `E_NEXUS_VAULT_LOCAL_CHANGES`, `E_NEXUS_VAULT_VERIFY_FAILED`,
1701
+ * `E_NEXUS_VAULT_EMPTY`, `E_NEXUS_VAULT_KEY_UNAVAILABLE`, or a mapped API error.
1702
+ */
1703
+ export function restoreNexusVault(opts) {
1704
+ return mapped(() => restoreNexusVaultImpl(opts));
1705
+ }
1706
+ /**
1707
+ * `cleo cloud vault`: the vault's lineage, last push per device, pending changes and leases.
1708
+ *
1709
+ * @param opts - Scope and overrides.
1710
+ * @returns The vault status.
1711
+ */
1712
+ export function nexusVaultStatus(opts = {}) {
1713
+ return mapped(() => nexusVaultStatusImpl(opts));
1714
+ }
1715
+ /**
1716
+ * `cleo cloud verify`: compare this store with the cloud and every device's newest snapshot.
1717
+ *
1718
+ * @param opts - Scope and overrides.
1719
+ * @returns The verdict and per-table comparison.
1720
+ */
1721
+ export function verifyNexusVault(opts = {}) {
1722
+ return mapped(() => verifyNexusVaultImpl(opts));
1723
+ }
1724
+ /**
1725
+ * `cleo cloud lease release`: hand the write lease back.
1726
+ *
1727
+ * @param opts - Scope and overrides.
1728
+ * @returns Whether a lease was released.
1729
+ */
1730
+ export function releaseNexusVaultLease(opts = {}) {
1731
+ return mapped(() => releaseNexusVaultLeaseImpl(opts));
1732
+ }
1733
+ //# sourceMappingURL=nexus-vault.js.map