@vellumai/assistant 0.6.3 → 0.6.4

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 (667) hide show
  1. package/ARCHITECTURE.md +273 -10
  2. package/Dockerfile +2 -3
  3. package/bun.lock +5 -13
  4. package/docs/backup-troubleshooting.md +52 -0
  5. package/docs/browser-use-architecture-phase2.md +174 -0
  6. package/docs/stt-provider-onboarding.md +120 -0
  7. package/knip.json +12 -2
  8. package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
  9. package/node_modules/@vellumai/ces-contracts/package.json +3 -3
  10. package/openapi.yaml +982 -72
  11. package/package.json +4 -6
  12. package/scripts/generate-openapi.ts +0 -1
  13. package/scripts/test.sh +73 -18
  14. package/src/__tests__/agent-image-optimize.test.ts +28 -0
  15. package/src/__tests__/agent-loop.test.ts +123 -0
  16. package/src/__tests__/anthropic-provider.test.ts +263 -10
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
  18. package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
  19. package/src/__tests__/browser-fill-credential.test.ts +11 -0
  20. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  21. package/src/__tests__/browser-skill-endstate.test.ts +31 -7
  22. package/src/__tests__/btw-routes.test.ts +7 -0
  23. package/src/__tests__/call-controller.test.ts +581 -20
  24. package/src/__tests__/catalog-files.test.ts +138 -0
  25. package/src/__tests__/channel-invite-transport.test.ts +2 -2
  26. package/src/__tests__/channel-readiness-routes.test.ts +16 -20
  27. package/src/__tests__/channel-readiness-service.test.ts +12 -7
  28. package/src/__tests__/checker.test.ts +157 -10
  29. package/src/__tests__/clawhub-files.test.ts +347 -0
  30. package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
  31. package/src/__tests__/config-analysis.test.ts +100 -0
  32. package/src/__tests__/config-schema.test.ts +1013 -66
  33. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
  34. package/src/__tests__/config-watcher.test.ts +43 -8
  35. package/src/__tests__/contact-store-user-file.test.ts +512 -0
  36. package/src/__tests__/contacts-write.test.ts +197 -0
  37. package/src/__tests__/context-window-manager.test.ts +88 -0
  38. package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
  39. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  40. package/src/__tests__/conversation-agent-loop.test.ts +98 -2
  41. package/src/__tests__/conversation-confirmation-signals.test.ts +135 -0
  42. package/src/__tests__/conversation-error.test.ts +70 -0
  43. package/src/__tests__/conversation-history-web-search.test.ts +11 -4
  44. package/src/__tests__/conversation-init.benchmark.test.ts +6 -1
  45. package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
  46. package/src/__tests__/conversation-list-source.test.ts +145 -0
  47. package/src/__tests__/conversation-pre-run-repair.test.ts +2 -0
  48. package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
  49. package/src/__tests__/conversation-queue.test.ts +901 -60
  50. package/src/__tests__/conversation-routes-disk-view.test.ts +270 -0
  51. package/src/__tests__/conversation-runtime-assembly.test.ts +55 -0
  52. package/src/__tests__/conversation-skill-tools.test.ts +7 -4
  53. package/src/__tests__/conversation-slash-commands.test.ts +33 -0
  54. package/src/__tests__/conversation-slash-queue.test.ts +89 -18
  55. package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
  56. package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
  57. package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
  58. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
  59. package/src/__tests__/credential-health-service.test.ts +352 -0
  60. package/src/__tests__/credential-security-invariants.test.ts +5 -3
  61. package/src/__tests__/credential-vault-unit.test.ts +379 -3
  62. package/src/__tests__/credentials-cli.test.ts +40 -16
  63. package/src/__tests__/cross-provider-web-search.test.ts +146 -35
  64. package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
  65. package/src/__tests__/device-id.test.ts +112 -0
  66. package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
  67. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
  68. package/src/__tests__/email-html-renderer.test.ts +71 -0
  69. package/src/__tests__/email-invite-adapter.test.ts +36 -32
  70. package/src/__tests__/emit-event-signal.test.ts +71 -0
  71. package/src/__tests__/extension-id-sync-guard.test.ts +75 -8
  72. package/src/__tests__/fixtures/mock-chrome-extension.ts +11 -0
  73. package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
  74. package/src/__tests__/gateway-only-guard.test.ts +0 -1
  75. package/src/__tests__/gemini-provider.test.ts +64 -0
  76. package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
  77. package/src/__tests__/gmail-archive-fallback.test.ts +193 -0
  78. package/src/__tests__/gmail-archive-gate.test.ts +246 -0
  79. package/src/__tests__/gmail-preferences.test.ts +117 -0
  80. package/src/__tests__/headless-browser-interactions.test.ts +43 -0
  81. package/src/__tests__/headless-browser-mode.test.ts +614 -0
  82. package/src/__tests__/headless-browser-navigate.test.ts +142 -5
  83. package/src/__tests__/headless-browser-read-tools.test.ts +11 -0
  84. package/src/__tests__/headless-browser-snapshot.test.ts +10 -0
  85. package/src/__tests__/heartbeat-service.test.ts +70 -17
  86. package/src/__tests__/home-state-routes.test.ts +162 -0
  87. package/src/__tests__/host-bash-proxy.test.ts +0 -5
  88. package/src/__tests__/host-browser-e2e-cloud.test.ts +138 -4
  89. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +4 -4
  90. package/src/__tests__/host-browser-ws-events-e2e.test.ts +103 -0
  91. package/src/__tests__/host-cu-proxy.test.ts +0 -5
  92. package/src/__tests__/identity-intro-cache.test.ts +40 -10
  93. package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
  94. package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
  95. package/src/__tests__/llm-context-normalization.test.ts +488 -0
  96. package/src/__tests__/llm-context-route-provider.test.ts +86 -5
  97. package/src/__tests__/llm-usage-store.test.ts +363 -0
  98. package/src/__tests__/media-stream-output.test.ts +555 -0
  99. package/src/__tests__/media-stream-parser.test.ts +374 -0
  100. package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
  101. package/src/__tests__/media-stream-stt-session.test.ts +588 -0
  102. package/src/__tests__/media-turn-detector.test.ts +440 -0
  103. package/src/__tests__/message-queue.test.ts +125 -0
  104. package/src/__tests__/migration-export-http.test.ts +6 -6
  105. package/src/__tests__/migration-import-commit-http.test.ts +8 -6
  106. package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
  107. package/src/__tests__/migration-validate-http.test.ts +3 -3
  108. package/src/__tests__/mock-gateway-ipc.ts +151 -0
  109. package/src/__tests__/model-intents.test.ts +2 -2
  110. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  111. package/src/__tests__/oauth-cli.test.ts +2 -0
  112. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  113. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  114. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  115. package/src/__tests__/oauth-store.test.ts +85 -0
  116. package/src/__tests__/oauth2-gateway-transport.test.ts +249 -6
  117. package/src/__tests__/onboarding-template-contract.test.ts +6 -13
  118. package/src/__tests__/openai-provider.test.ts +176 -0
  119. package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
  120. package/src/__tests__/openai-responses-provider.test.ts +1105 -0
  121. package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
  122. package/src/__tests__/outlook-unsubscribe.test.ts +31 -2
  123. package/src/__tests__/persona-resolver.test.ts +251 -0
  124. package/src/__tests__/platform-bash-auto-approve.test.ts +4 -0
  125. package/src/__tests__/platform.test.ts +92 -1
  126. package/src/__tests__/post-turn-tool-result-truncation.test.ts +47 -0
  127. package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
  128. package/src/__tests__/pricing.test.ts +174 -0
  129. package/src/__tests__/qdrant-manager.test.ts +29 -8
  130. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
  131. package/src/__tests__/relationship-state-contract.test.ts +175 -0
  132. package/src/__tests__/relay-server.test.ts +423 -5
  133. package/src/__tests__/search-skills-unified.test.ts +118 -0
  134. package/src/__tests__/secret-scanner-executor.test.ts +4 -0
  135. package/src/__tests__/secure-keys.test.ts +107 -0
  136. package/src/__tests__/send-endpoint-busy.test.ts +5 -1
  137. package/src/__tests__/sequence-store.test.ts +1 -1
  138. package/src/__tests__/server-history-render.test.ts +49 -0
  139. package/src/__tests__/settings-routes.test.ts +201 -0
  140. package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
  141. package/src/__tests__/skills-file-content-endpoint.test.ts +276 -145
  142. package/src/__tests__/skills-files-catalog-fallback.test.ts +381 -93
  143. package/src/__tests__/skills.test.ts +5 -2
  144. package/src/__tests__/skillssh-files.test.ts +446 -0
  145. package/src/__tests__/slack-block-formatting.test.ts +110 -0
  146. package/src/__tests__/slack-channel-config.test.ts +564 -1
  147. package/src/__tests__/stt-catalog-parity.test.ts +282 -0
  148. package/src/__tests__/stt-stream-session.test.ts +535 -0
  149. package/src/__tests__/system-prompt.test.ts +112 -26
  150. package/src/__tests__/telephony-stt-routing.test.ts +329 -0
  151. package/src/__tests__/terminal-tools.test.ts +18 -7
  152. package/src/__tests__/test-preload.ts +18 -0
  153. package/src/__tests__/test-support/browser-skill-harness.ts +4 -1
  154. package/src/__tests__/tool-executor-lifecycle-events.test.ts +9 -5
  155. package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
  156. package/src/__tests__/tool-executor.test.ts +33 -24
  157. package/src/__tests__/tool-result-truncation.test.ts +36 -0
  158. package/src/__tests__/trust-store.test.ts +7 -1
  159. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
  160. package/src/__tests__/tts-catalog-parity.test.ts +345 -0
  161. package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
  162. package/src/__tests__/twilio-routes.test.ts +376 -0
  163. package/src/__tests__/unicode.test.ts +293 -0
  164. package/src/__tests__/update-bulletin-format.test.ts +59 -0
  165. package/src/__tests__/update-bulletin.test.ts +206 -5
  166. package/src/__tests__/usage-routes.test.ts +25 -4
  167. package/src/__tests__/user-reference.test.ts +46 -61
  168. package/src/__tests__/verification-control-plane-policy.test.ts +4 -0
  169. package/src/__tests__/voice-config-update.test.ts +403 -0
  170. package/src/__tests__/voice-quality.test.ts +434 -19
  171. package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
  172. package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
  173. package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
  174. package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
  175. package/src/__tests__/workspace-migration-meets.test.ts +244 -0
  176. package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
  177. package/src/__tests__/workspace-policy.test.ts +2 -0
  178. package/src/agent/image-optimize.ts +24 -12
  179. package/src/agent/loop.ts +43 -3
  180. package/src/backup/__tests__/backup-key.test.ts +152 -0
  181. package/src/backup/__tests__/backup-worker.test.ts +767 -0
  182. package/src/backup/__tests__/list-snapshots.test.ts +87 -0
  183. package/src/backup/__tests__/local-writer.test.ts +218 -0
  184. package/src/backup/__tests__/offsite-writer.test.ts +641 -0
  185. package/src/backup/__tests__/paths.test.ts +300 -0
  186. package/src/backup/__tests__/restore.test.ts +498 -0
  187. package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
  188. package/src/backup/__tests__/stream-crypt.test.ts +228 -0
  189. package/src/backup/backup-key.ts +137 -0
  190. package/src/backup/backup-worker.ts +459 -0
  191. package/src/backup/list-snapshots.ts +147 -0
  192. package/src/backup/local-writer.ts +133 -0
  193. package/src/backup/offsite-writer.ts +222 -0
  194. package/src/backup/paths.ts +226 -0
  195. package/src/backup/restore.ts +322 -0
  196. package/src/backup/snapshot-lock.ts +431 -0
  197. package/src/backup/stream-crypt.ts +263 -0
  198. package/src/bundler/package-resolver.ts +4 -0
  199. package/src/calls/audio-store.ts +11 -5
  200. package/src/calls/call-controller.ts +226 -71
  201. package/src/calls/call-domain.ts +9 -0
  202. package/src/calls/call-speech-output.ts +190 -0
  203. package/src/calls/call-transport.ts +77 -0
  204. package/src/calls/media-stream-audio-transcode.ts +173 -0
  205. package/src/calls/media-stream-output.ts +660 -0
  206. package/src/calls/media-stream-parser.ts +300 -0
  207. package/src/calls/media-stream-protocol.ts +166 -0
  208. package/src/calls/media-stream-server.ts +592 -0
  209. package/src/calls/media-stream-stt-session.ts +460 -0
  210. package/src/calls/media-turn-detector.ts +230 -0
  211. package/src/calls/relay-server.ts +90 -75
  212. package/src/calls/resolve-call-tts-provider.ts +136 -0
  213. package/src/calls/telephony-stt-routing.ts +145 -0
  214. package/src/calls/tts-call-strategy.ts +161 -0
  215. package/src/calls/tts-text-sanitizer.ts +32 -16
  216. package/src/calls/twilio-routes.ts +281 -17
  217. package/src/calls/voice-quality.ts +78 -35
  218. package/src/calls/voice-session-bridge.ts +8 -1
  219. package/src/channels/types.ts +16 -0
  220. package/src/cli/__tests__/run-assistant-command.ts +11 -1
  221. package/src/cli/commands/__tests__/backup.test.ts +1165 -0
  222. package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
  223. package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
  224. package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
  225. package/src/cli/commands/__tests__/email-download.test.ts +16 -1
  226. package/src/cli/commands/__tests__/email-list.test.ts +22 -4
  227. package/src/cli/commands/__tests__/email-register.test.ts +4 -4
  228. package/src/cli/commands/__tests__/email-send.test.ts +37 -4
  229. package/src/cli/commands/__tests__/email-status.test.ts +5 -1
  230. package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
  231. package/src/cli/commands/backup.ts +993 -0
  232. package/src/cli/commands/conversations.ts +77 -0
  233. package/src/cli/commands/credentials.ts +0 -1
  234. package/src/cli/commands/domain.ts +210 -0
  235. package/src/cli/commands/email.ts +255 -3
  236. package/src/cli/commands/oauth/__tests__/connect.test.ts +12 -0
  237. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +1 -0
  238. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -0
  239. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -0
  240. package/src/cli/commands/oauth/mode.ts +12 -3
  241. package/src/cli/commands/oauth/providers.ts +15 -0
  242. package/src/cli/commands/oauth/shared.ts +2 -1
  243. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +4 -9
  244. package/src/cli/commands/platform/__tests__/connect.test.ts +6 -0
  245. package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
  246. package/src/cli/commands/platform/__tests__/status.test.ts +6 -0
  247. package/src/cli/program.ts +30 -4
  248. package/src/config/__tests__/backup-schema.test.ts +134 -0
  249. package/src/config/assistant-feature-flags.ts +61 -62
  250. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +37 -1
  251. package/src/config/bundled-skills/browser/SKILL.md +30 -5
  252. package/src/config/bundled-skills/browser/TOOLS.json +123 -0
  253. package/src/config/bundled-skills/browser/tools/browser-attach.ts +12 -0
  254. package/src/config/bundled-skills/browser/tools/browser-detach.ts +12 -0
  255. package/src/config/bundled-skills/browser/tools/browser-status.ts +12 -0
  256. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +17 -0
  257. package/src/config/bundled-skills/contacts/SKILL.md +2 -2
  258. package/src/config/bundled-skills/gmail/SKILL.md +53 -7
  259. package/src/config/bundled-skills/gmail/TOOLS.json +33 -3
  260. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +116 -9
  261. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +138 -11
  262. package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +59 -0
  263. package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +82 -0
  264. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +113 -17
  265. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -2
  266. package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
  267. package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
  268. package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
  269. package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
  270. package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
  271. package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
  272. package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
  273. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
  274. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  275. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
  276. package/src/config/bundled-skills/outlook/SKILL.md +2 -2
  277. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +2 -2
  278. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  279. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +27 -18
  280. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  281. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  282. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
  283. package/src/config/bundled-skills/slack/SKILL.md +1 -0
  284. package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
  285. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
  286. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
  287. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
  288. package/src/config/bundled-tool-registry.ts +8 -0
  289. package/src/config/env-registry.ts +24 -0
  290. package/src/config/env.ts +34 -10
  291. package/src/config/feature-flag-registry.json +46 -14
  292. package/src/config/loader.ts +26 -12
  293. package/src/config/schema.ts +35 -10
  294. package/src/config/schemas/__tests__/stt.test.ts +43 -0
  295. package/src/config/schemas/analysis.ts +51 -0
  296. package/src/config/schemas/backup.ts +72 -0
  297. package/src/config/schemas/calls.ts +1 -26
  298. package/src/config/schemas/elevenlabs.ts +0 -59
  299. package/src/config/schemas/filing.ts +47 -7
  300. package/src/config/schemas/heartbeat.ts +27 -5
  301. package/src/config/schemas/host-browser.ts +47 -1
  302. package/src/config/schemas/inference.ts +1 -1
  303. package/src/config/schemas/memory-lifecycle.ts +14 -2
  304. package/src/config/schemas/services.ts +44 -0
  305. package/src/config/schemas/stt.ts +59 -0
  306. package/src/config/schemas/tts.ts +230 -0
  307. package/src/config/schemas/updates.ts +14 -0
  308. package/src/config/skills.ts +4 -0
  309. package/src/config/types.ts +4 -0
  310. package/src/contacts/contact-store.ts +56 -11
  311. package/src/contacts/contacts-write.ts +38 -1
  312. package/src/context/post-turn-tool-result-truncation.ts +3 -2
  313. package/src/context/tool-result-truncation.ts +2 -1
  314. package/src/context/window-manager.ts +45 -12
  315. package/src/credential-execution/executable-discovery.ts +12 -2
  316. package/src/credential-execution/process-manager.ts +33 -2
  317. package/src/credential-health/credential-health-service.ts +366 -0
  318. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
  319. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
  320. package/src/daemon/__tests__/conversation-tool-setup.test.ts +17 -8
  321. package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
  322. package/src/daemon/config-watcher.ts +99 -5
  323. package/src/daemon/conversation-agent-loop-handlers.ts +6 -0
  324. package/src/daemon/conversation-agent-loop.ts +101 -24
  325. package/src/daemon/conversation-error.ts +11 -0
  326. package/src/daemon/conversation-history.ts +40 -6
  327. package/src/daemon/conversation-launch.ts +220 -0
  328. package/src/daemon/conversation-lifecycle.ts +59 -9
  329. package/src/daemon/conversation-messaging.ts +37 -3
  330. package/src/daemon/conversation-notifiers.ts +5 -0
  331. package/src/daemon/conversation-process.ts +581 -19
  332. package/src/daemon/conversation-queue-manager.ts +24 -0
  333. package/src/daemon/conversation-runtime-assembly.ts +11 -1
  334. package/src/daemon/conversation-slash.ts +36 -0
  335. package/src/daemon/conversation-surfaces.ts +94 -4
  336. package/src/daemon/conversation-tool-setup.ts +25 -0
  337. package/src/daemon/conversation-usage.ts +7 -4
  338. package/src/daemon/conversation.ts +86 -28
  339. package/src/daemon/handlers/config-slack-channel.ts +269 -94
  340. package/src/daemon/handlers/conversations.ts +4 -1
  341. package/src/daemon/handlers/shared.ts +22 -0
  342. package/src/daemon/handlers/skills.ts +321 -77
  343. package/src/daemon/host-browser-proxy.ts +2 -1
  344. package/src/daemon/lifecycle.ts +122 -25
  345. package/src/daemon/message-protocol.ts +6 -0
  346. package/src/daemon/message-types/conversations.ts +34 -1
  347. package/src/daemon/message-types/home.ts +40 -0
  348. package/src/daemon/message-types/meet.ts +143 -0
  349. package/src/daemon/message-types/messages.ts +14 -0
  350. package/src/daemon/message-types/schedules.ts +34 -2
  351. package/src/daemon/message-types/skills.ts +16 -0
  352. package/src/daemon/message-types/surfaces.ts +2 -0
  353. package/src/daemon/server.ts +347 -2
  354. package/src/daemon/shutdown-handlers.ts +32 -4
  355. package/src/daemon/shutdown-registry.ts +40 -0
  356. package/src/daemon/tool-side-effects.ts +9 -0
  357. package/src/email/html-renderer.ts +76 -0
  358. package/src/heartbeat/heartbeat-service.ts +93 -7
  359. package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
  360. package/src/home/__tests__/emit-feed-event.test.ts +169 -0
  361. package/src/home/__tests__/feed-scheduler.test.ts +194 -0
  362. package/src/home/__tests__/feed-types.test.ts +275 -0
  363. package/src/home/__tests__/feed-writer.test.ts +688 -0
  364. package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
  365. package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
  366. package/src/home/__tests__/progress-formula.test.ts +213 -0
  367. package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
  368. package/src/home/__tests__/rollup-producer.test.ts +398 -0
  369. package/src/home/assistant-feed-authoring.ts +124 -0
  370. package/src/home/emit-feed-event.ts +158 -0
  371. package/src/home/feed-scheduler.ts +247 -0
  372. package/src/home/feed-types.ts +181 -0
  373. package/src/home/feed-writer.ts +469 -0
  374. package/src/home/platform-gmail-digest.ts +163 -0
  375. package/src/home/progress-formula.ts +86 -0
  376. package/src/home/relationship-state-writer.ts +824 -0
  377. package/src/home/relationship-state.ts +143 -0
  378. package/src/home/rollup-producer.ts +384 -0
  379. package/src/hooks/runner.ts +7 -0
  380. package/src/inbound/platform-callback-registration.ts +12 -3
  381. package/src/inbound/public-ingress-urls.ts +12 -0
  382. package/src/instrument.ts +1 -1
  383. package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
  384. package/src/ipc/cli-client.ts +151 -0
  385. package/src/ipc/cli-server.ts +234 -0
  386. package/src/ipc/gateway-client.ts +180 -0
  387. package/src/ipc/routes/index.ts +5 -0
  388. package/src/ipc/routes/wake-conversation.ts +19 -0
  389. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
  390. package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
  391. package/src/memory/__tests__/conversation-analyze-job.test.ts +232 -0
  392. package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
  393. package/src/memory/app-store.ts +1 -1
  394. package/src/memory/attachments-store.ts +70 -0
  395. package/src/memory/auto-analysis-enqueue.ts +127 -0
  396. package/src/memory/auto-analysis-guard.ts +27 -0
  397. package/src/memory/cleanup-schedule-state.ts +37 -0
  398. package/src/memory/conversation-analyze-job.ts +73 -0
  399. package/src/memory/conversation-crud.ts +99 -0
  400. package/src/memory/conversation-disk-view.ts +7 -0
  401. package/src/memory/conversation-group-migration.ts +34 -2
  402. package/src/memory/conversation-queries.ts +6 -5
  403. package/src/memory/db-init.ts +6 -0
  404. package/src/memory/db-maintenance.ts +108 -0
  405. package/src/memory/db.ts +1 -0
  406. package/src/memory/graph/conversation-graph-memory.ts +15 -0
  407. package/src/memory/graph/extraction.test.ts +23 -0
  408. package/src/memory/graph/extraction.ts +8 -0
  409. package/src/memory/graph/retriever.ts +27 -18
  410. package/src/memory/graph/scoring.test.ts +186 -0
  411. package/src/memory/graph/scoring.ts +31 -1
  412. package/src/memory/graph/tools.ts +1 -1
  413. package/src/memory/group-crud.ts +6 -1
  414. package/src/memory/indexer.ts +95 -16
  415. package/src/memory/job-handlers/cleanup.ts +11 -8
  416. package/src/memory/job-handlers/conversation-starters.ts +16 -10
  417. package/src/memory/jobs-store.ts +64 -4
  418. package/src/memory/jobs-worker.ts +22 -9
  419. package/src/memory/llm-usage-store.ts +92 -56
  420. package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
  421. package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
  422. package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
  423. package/src/memory/migrations/index.ts +6 -0
  424. package/src/memory/migrations/registry.ts +8 -0
  425. package/src/memory/qdrant-manager.ts +43 -16
  426. package/src/memory/schema/conversations.ts +2 -0
  427. package/src/memory/schema/oauth.ts +3 -0
  428. package/src/memory/usage-buckets.ts +396 -0
  429. package/src/messaging/providers/gmail/client.ts +57 -6
  430. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
  431. package/src/messaging/providers/slack/adapter.ts +143 -38
  432. package/src/messaging/providers/slack/client.ts +16 -0
  433. package/src/messaging/providers/slack/types.ts +4 -0
  434. package/src/notifications/decision-engine.ts +3 -3
  435. package/src/notifications/signal.ts +5 -0
  436. package/src/oauth/__tests__/identity-verifier.test.ts +1 -0
  437. package/src/oauth/byo-connection.test.ts +18 -1
  438. package/src/oauth/byo-connection.ts +3 -1
  439. package/src/oauth/connect-orchestrator.ts +2 -0
  440. package/src/oauth/connection-resolver.ts +6 -2
  441. package/src/oauth/connection.ts +2 -0
  442. package/src/oauth/oauth-store.ts +9 -0
  443. package/src/oauth/platform-connection.test.ts +98 -0
  444. package/src/oauth/platform-connection.ts +52 -31
  445. package/src/oauth/seed-providers.ts +7 -0
  446. package/src/permissions/checker.ts +16 -6
  447. package/src/permissions/defaults.ts +49 -1
  448. package/src/permissions/trust-store.ts +3 -3
  449. package/src/permissions/workspace-policy.ts +3 -0
  450. package/src/platform/client.test.ts +10 -0
  451. package/src/platform/sync-identity.ts +129 -0
  452. package/src/prompts/persona-resolver.ts +126 -2
  453. package/src/prompts/system-prompt.ts +59 -18
  454. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  455. package/src/prompts/templates/SOUL.md +3 -1
  456. package/src/prompts/templates/UPDATES.md +12 -0
  457. package/src/prompts/templates/channels/slack.md +20 -0
  458. package/src/prompts/update-bulletin-format.ts +26 -9
  459. package/src/prompts/update-bulletin.ts +34 -23
  460. package/src/prompts/user-reference.ts +20 -17
  461. package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
  462. package/src/providers/anthropic/client.ts +157 -61
  463. package/src/providers/fireworks/client.ts +2 -2
  464. package/src/providers/gemini/client.ts +9 -1
  465. package/src/providers/model-catalog.ts +6 -0
  466. package/src/providers/model-intents.ts +4 -4
  467. package/src/providers/ollama/client.ts +2 -2
  468. package/src/providers/openai/chat-completions-provider.ts +474 -0
  469. package/src/providers/openai/client.ts +25 -440
  470. package/src/providers/openai/responses-provider.ts +502 -0
  471. package/src/providers/openrouter/client.ts +101 -4
  472. package/src/providers/provider-secret-catalog.ts +139 -0
  473. package/src/providers/registry.ts +2 -2
  474. package/src/providers/retry.ts +14 -3
  475. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
  476. package/src/providers/speech-to-text/__tests__/resolve.test.ts +828 -0
  477. package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
  478. package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
  479. package/src/providers/speech-to-text/deepgram.test.ts +332 -0
  480. package/src/providers/speech-to-text/deepgram.ts +115 -0
  481. package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
  482. package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
  483. package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
  484. package/src/providers/speech-to-text/google-gemini.ts +101 -0
  485. package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
  486. package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
  487. package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
  488. package/src/providers/speech-to-text/openai-whisper.ts +63 -33
  489. package/src/providers/speech-to-text/provider-catalog.ts +306 -0
  490. package/src/providers/speech-to-text/resolve.ts +386 -6
  491. package/src/providers/types.ts +9 -0
  492. package/src/runtime/AGENTS.md +43 -1
  493. package/src/runtime/__tests__/agent-wake.test.ts +831 -0
  494. package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
  495. package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
  496. package/src/runtime/agent-wake.ts +512 -0
  497. package/src/runtime/auth/__tests__/route-policy.test.ts +40 -0
  498. package/src/runtime/auth/route-policy.ts +30 -5
  499. package/src/runtime/auth/token-service.ts +56 -1
  500. package/src/runtime/btw-sidechain.ts +2 -0
  501. package/src/runtime/capability-tokens.ts +10 -10
  502. package/src/runtime/channel-invite-transport.ts +1 -1
  503. package/src/runtime/channel-invite-transports/email.ts +14 -6
  504. package/src/runtime/channel-readiness-service.ts +12 -22
  505. package/src/runtime/chrome-extension-registry.ts +38 -2
  506. package/src/runtime/http-server.ts +395 -10
  507. package/src/runtime/http-types.ts +6 -2
  508. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +36 -0
  509. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
  510. package/src/runtime/migrations/migration-transport.ts +1 -0
  511. package/src/runtime/migrations/migration-wizard.ts +1 -0
  512. package/src/runtime/migrations/vbundle-import-analyzer.ts +77 -1
  513. package/src/runtime/migrations/vbundle-importer.ts +34 -0
  514. package/src/runtime/pending-interactions.ts +0 -11
  515. package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
  516. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +507 -0
  517. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +208 -0
  518. package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
  519. package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
  520. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
  521. package/src/runtime/routes/app-management-routes.ts +12 -18
  522. package/src/runtime/routes/attachment-routes.test.ts +9 -3
  523. package/src/runtime/routes/attachment-routes.ts +216 -17
  524. package/src/runtime/routes/backup-routes.ts +519 -0
  525. package/src/runtime/routes/browser-extension-pair-routes.ts +82 -23
  526. package/src/runtime/routes/btw-routes.ts +8 -6
  527. package/src/runtime/routes/contact-routes.test.ts +298 -0
  528. package/src/runtime/routes/contact-routes.ts +132 -5
  529. package/src/runtime/routes/conversation-analysis-routes.ts +22 -142
  530. package/src/runtime/routes/conversation-management-routes.ts +115 -0
  531. package/src/runtime/routes/conversation-routes.ts +367 -146
  532. package/src/runtime/routes/filing-routes.ts +93 -0
  533. package/src/runtime/routes/home-feed-routes.ts +334 -0
  534. package/src/runtime/routes/home-state-routes.ts +138 -0
  535. package/src/runtime/routes/host-browser-routes.ts +3 -14
  536. package/src/runtime/routes/identity-intro-cache.ts +7 -3
  537. package/src/runtime/routes/identity-routes.ts +3 -17
  538. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
  539. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
  540. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
  541. package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
  542. package/src/runtime/routes/integrations/slack/channel.ts +11 -3
  543. package/src/runtime/routes/integrations/slack/share.ts +45 -7
  544. package/src/runtime/routes/llm-context-normalization.ts +303 -0
  545. package/src/runtime/routes/memory-item-routes.test.ts +3 -2
  546. package/src/runtime/routes/migration-routes.ts +40 -5
  547. package/src/runtime/routes/settings-routes.ts +22 -5
  548. package/src/runtime/routes/skills-routes.ts +76 -7
  549. package/src/runtime/routes/stt-routes.ts +233 -0
  550. package/src/runtime/routes/surface-action-routes.ts +41 -2
  551. package/src/runtime/routes/tts-routes.ts +108 -24
  552. package/src/runtime/routes/usage-routes.ts +30 -2
  553. package/src/runtime/routes/user-route-dispatcher.ts +50 -5
  554. package/src/runtime/routes/user-routes.ts +13 -1
  555. package/src/runtime/routes/work-items-routes.ts +8 -1
  556. package/src/runtime/runtime-mode.ts +33 -0
  557. package/src/runtime/services/__tests__/analyze-conversation.test.ts +444 -0
  558. package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
  559. package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
  560. package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
  561. package/src/runtime/services/analyze-conversation.ts +344 -0
  562. package/src/runtime/services/analyze-deps-singleton.ts +32 -0
  563. package/src/runtime/services/auto-analysis-prompt.ts +55 -0
  564. package/src/runtime/skill-route-registry.ts +49 -0
  565. package/src/runtime/slack-block-formatting.ts +437 -10
  566. package/src/schedule/scheduler.ts +50 -0
  567. package/src/security/oauth2.ts +26 -4
  568. package/src/security/secure-keys.ts +25 -2
  569. package/src/security/token-manager.ts +8 -0
  570. package/src/sequence/engine.ts +23 -0
  571. package/src/sequence/types.ts +1 -1
  572. package/src/skills/catalog-files.ts +64 -2
  573. package/src/skills/category-inference.ts +122 -0
  574. package/src/skills/clawhub-files.ts +213 -0
  575. package/src/skills/clawhub.ts +84 -23
  576. package/src/skills/skill-file-provider.ts +40 -0
  577. package/src/skills/skillssh-files.ts +395 -0
  578. package/src/skills/skillssh-registry.ts +4 -4
  579. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +392 -0
  580. package/src/stt/__tests__/types.test.ts +89 -0
  581. package/src/stt/daemon-batch-transcriber.ts +195 -0
  582. package/src/stt/stt-stream-session.ts +499 -0
  583. package/src/stt/types.ts +330 -0
  584. package/src/stt/wav-encoder.test.ts +373 -0
  585. package/src/stt/wav-encoder.ts +175 -0
  586. package/src/subagent/manager.ts +38 -14
  587. package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
  588. package/src/tools/browser/__tests__/browser-status.test.ts +123 -0
  589. package/src/tools/browser/browser-execution.ts +1163 -23
  590. package/src/tools/browser/browser-manager.ts +45 -0
  591. package/src/tools/browser/browser-mode-constants.ts +12 -0
  592. package/src/tools/browser/browser-mode.ts +92 -0
  593. package/src/tools/browser/browser-status-constants.ts +33 -0
  594. package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +393 -0
  595. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +29 -0
  596. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1648 -32
  597. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +264 -0
  598. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +183 -17
  599. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +254 -21
  600. package/src/tools/browser/cdp-client/errors.ts +15 -0
  601. package/src/tools/browser/cdp-client/extension-cdp-client.ts +39 -16
  602. package/src/tools/browser/cdp-client/factory.ts +797 -87
  603. package/src/tools/browser/cdp-client/index.ts +16 -2
  604. package/src/tools/browser/cdp-client/types.ts +68 -0
  605. package/src/tools/credentials/vault.ts +35 -6
  606. package/src/tools/network/web-fetch.ts +5 -2
  607. package/src/tools/network/web-search.ts +5 -2
  608. package/src/tools/shared/shell-output.ts +3 -1
  609. package/src/tools/side-effects.ts +2 -0
  610. package/src/tools/skills/sandbox-runner.ts +3 -2
  611. package/src/tools/terminal/safe-env.ts +10 -2
  612. package/src/tools/terminal/shell.ts +15 -4
  613. package/src/tools/tool-manifest.ts +21 -0
  614. package/src/tools/types.ts +17 -0
  615. package/src/tools/ui-surface/definitions.ts +6 -1
  616. package/src/tts/__tests__/provider-adapters.test.ts +834 -0
  617. package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
  618. package/src/tts/__tests__/provider-catalog.test.ts +183 -0
  619. package/src/tts/__tests__/provider-registry.test.ts +90 -0
  620. package/src/tts/provider-catalog.ts +201 -0
  621. package/src/tts/provider-registry.ts +73 -0
  622. package/src/tts/providers/deepgram-provider.ts +219 -0
  623. package/src/tts/providers/elevenlabs-provider.ts +211 -0
  624. package/src/tts/providers/fish-audio-provider.ts +183 -0
  625. package/src/tts/providers/index.ts +42 -0
  626. package/src/tts/providers/register-builtins.ts +130 -0
  627. package/src/tts/synthesize-text.ts +110 -0
  628. package/src/tts/tts-config-resolver.ts +78 -0
  629. package/src/tts/types.ts +153 -0
  630. package/src/types/onboarding-context.ts +7 -0
  631. package/src/util/abort-reasons.ts +58 -0
  632. package/src/util/device-id.ts +32 -16
  633. package/src/util/errors.ts +9 -1
  634. package/src/util/platform.ts +54 -10
  635. package/src/util/pricing.ts +66 -3
  636. package/src/util/spawn.ts +1 -1
  637. package/src/util/truncate.ts +4 -2
  638. package/src/util/unicode.ts +201 -0
  639. package/src/version.ts +19 -24
  640. package/src/watcher/engine.ts +23 -0
  641. package/src/watcher/watcher-store.ts +31 -0
  642. package/src/workspace/migrations/003-seed-device-id.ts +9 -3
  643. package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
  644. package/src/workspace/migrations/029-seed-pkb.ts +1 -1
  645. package/src/workspace/migrations/031-drop-user-md.ts +317 -0
  646. package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
  647. package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
  648. package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
  649. package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
  650. package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
  651. package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
  652. package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
  653. package/src/workspace/migrations/registry.ts +16 -0
  654. package/src/workspace/top-level-renderer.ts +13 -1
  655. package/src/workspace/turn-commit.ts +31 -0
  656. package/src/__tests__/email-cli.test.ts +0 -297
  657. package/src/__tests__/email-service-config-fallback.test.ts +0 -102
  658. package/src/cli/commands/browser-relay.ts +0 -466
  659. package/src/email/guardrails.ts +0 -221
  660. package/src/email/provider.ts +0 -117
  661. package/src/email/providers/agentmail.ts +0 -361
  662. package/src/email/providers/index.ts +0 -65
  663. package/src/email/service.ts +0 -384
  664. package/src/email/types.ts +0 -126
  665. package/src/prompts/templates/USER.md +0 -13
  666. package/src/providers/speech-to-text/types.ts +0 -17
  667. package/src/runtime/routes/browser-cdp-routes.ts +0 -229
@@ -0,0 +1,1165 @@
1
+ /**
2
+ * Tests for the `vellum backup` CLI command tree.
3
+ *
4
+ * These tests mock out the config loader (so state lives in an in-memory
5
+ * record), the backup/restore libraries (so we don't touch the filesystem or
6
+ * rely on a real workspace), and the memory checkpoint store (so `status`
7
+ * can be driven with a known last-run timestamp). Each test drives the
8
+ * handlers either directly (for fine-grained assertions on persisted state)
9
+ * or via a commander program (for end-to-end arg parsing).
10
+ */
11
+
12
+ import { Readable } from "node:stream";
13
+ import {
14
+ afterEach,
15
+ beforeEach,
16
+ describe,
17
+ expect,
18
+ mock,
19
+ test,
20
+ } from "bun:test";
21
+
22
+ import { Command } from "commander";
23
+
24
+ import type {
25
+ BackupConfig,
26
+ BackupDestination,
27
+ } from "../../../config/schema.js";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Mock state
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** The raw `config.json` record the command sees, shared across mocks. */
34
+ let mockRawConfig: Record<string, unknown> = {};
35
+
36
+ /** History of `saveRawConfig` calls so tests can assert persist-order. */
37
+ let mockSaveRawConfigCalls: Array<Record<string, unknown>> = [];
38
+
39
+ /** Memory checkpoint the mocked `getMemoryCheckpoint` should return. */
40
+ let mockLastRunAt: string | null = null;
41
+
42
+ /** Snapshot listings per directory, keyed by absolute path. */
43
+ let mockSnapshots: Record<
44
+ string,
45
+ Array<{
46
+ path: string;
47
+ filename: string;
48
+ createdAt: Date;
49
+ sizeBytes: number;
50
+ encrypted: boolean;
51
+ }>
52
+ > = {};
53
+
54
+ /** Result returned by the stubbed `verifySnapshot`. */
55
+ let mockVerifyResult: {
56
+ valid: boolean;
57
+ manifest?: {
58
+ schema_version: string;
59
+ created_at: string;
60
+ source?: string;
61
+ description?: string;
62
+ files: unknown[];
63
+ manifest_sha256: string;
64
+ };
65
+ error?: string;
66
+ } = { valid: true };
67
+
68
+ /** Result returned by the stubbed `restoreFromSnapshot`. */
69
+ let mockRestoreResult: {
70
+ manifest: {
71
+ schema_version: string;
72
+ created_at: string;
73
+ source?: string;
74
+ files: unknown[];
75
+ manifest_sha256: string;
76
+ };
77
+ restoredFiles: number;
78
+ } = {
79
+ manifest: {
80
+ schema_version: "1.0.0",
81
+ created_at: "2026-04-11T09:30:00Z",
82
+ source: "test",
83
+ files: [],
84
+ manifest_sha256: "abc",
85
+ },
86
+ restoredFiles: 42,
87
+ };
88
+
89
+ /** Result returned by the stubbed `createSnapshotNow`. */
90
+ let mockCreateSnapshotResult: {
91
+ local: {
92
+ path: string;
93
+ filename: string;
94
+ createdAt: Date;
95
+ sizeBytes: number;
96
+ encrypted: boolean;
97
+ };
98
+ offsite: Array<{
99
+ destination: BackupDestination;
100
+ entry: {
101
+ path: string;
102
+ filename: string;
103
+ createdAt: Date;
104
+ sizeBytes: number;
105
+ encrypted: boolean;
106
+ } | null;
107
+ skipped?: "parent-missing";
108
+ error?: string;
109
+ }>;
110
+ durationMs: number;
111
+ } = {
112
+ local: {
113
+ path: "/tmp/local/backup-20260411-093000.vbundle",
114
+ filename: "backup-20260411-093000.vbundle",
115
+ createdAt: new Date("2026-04-11T09:30:00Z"),
116
+ sizeBytes: 1024,
117
+ encrypted: false,
118
+ },
119
+ offsite: [],
120
+ durationMs: 123,
121
+ };
122
+
123
+ /** Whether `createSnapshotNow` should throw concurrency error. */
124
+ let mockCreateShouldThrow: Error | null = null;
125
+
126
+ /** Whether the stubbed `isDaemonRunning` should report the assistant as alive. */
127
+ let mockDaemonRunning = false;
128
+
129
+ /** Sequence of recovery calls so tests can assert ordering. */
130
+ const recoveryCallOrder: string[] = [];
131
+
132
+ /** Number of times each recovery helper was invoked. */
133
+ let mockResetDbCalls = 0;
134
+ let mockInvalidateConfigCacheCalls = 0;
135
+ let mockClearTrustCacheCalls = 0;
136
+
137
+ /** Log calls captured by the mocked logger. */
138
+ let mockLogInfo: string[] = [];
139
+ let mockLogError: string[] = [];
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Mocks (must be registered before importing the module under test)
143
+ // ---------------------------------------------------------------------------
144
+
145
+ mock.module("../../../config/loader.js", () => ({
146
+ loadRawConfig: () => mockRawConfig,
147
+ saveRawConfig: (config: Record<string, unknown>) => {
148
+ mockRawConfig = structuredClone(config);
149
+ mockSaveRawConfigCalls.push(structuredClone(config));
150
+ },
151
+ setNestedValue: (
152
+ obj: Record<string, unknown>,
153
+ path: string,
154
+ value: unknown,
155
+ ) => {
156
+ const keys = path.split(".");
157
+ let current: Record<string, unknown> = obj;
158
+ for (let i = 0; i < keys.length - 1; i++) {
159
+ const key = keys[i]!;
160
+ if (current[key] == null || typeof current[key] !== "object") {
161
+ current[key] = {};
162
+ }
163
+ current = current[key] as Record<string, unknown>;
164
+ }
165
+ current[keys[keys.length - 1]!] = value;
166
+ },
167
+ getConfig: () => ({
168
+ backup: getComputedBackupConfig(),
169
+ }),
170
+ invalidateConfigCache: () => {
171
+ mockInvalidateConfigCacheCalls += 1;
172
+ recoveryCallOrder.push("invalidateConfigCache");
173
+ },
174
+ }));
175
+
176
+ mock.module("../../../daemon/daemon-control.js", () => ({
177
+ isDaemonRunning: () => mockDaemonRunning,
178
+ }));
179
+
180
+ mock.module("../../../memory/db-connection.js", () => ({
181
+ resetDb: () => {
182
+ mockResetDbCalls += 1;
183
+ recoveryCallOrder.push("resetDb");
184
+ },
185
+ }));
186
+
187
+ mock.module("../../../permissions/trust-store.js", () => ({
188
+ clearCache: () => {
189
+ mockClearTrustCacheCalls += 1;
190
+ recoveryCallOrder.push("clearTrustCache");
191
+ },
192
+ }));
193
+
194
+ mock.module("../../../memory/checkpoints.js", () => ({
195
+ getMemoryCheckpoint: (key: string) =>
196
+ key === "backup:last_run_at" ? mockLastRunAt : null,
197
+ }));
198
+
199
+ mock.module("../../../backup/list-snapshots.js", () => ({
200
+ listSnapshotsInDir: async (dir: string) => mockSnapshots[dir] ?? [],
201
+ }));
202
+
203
+ mock.module("../../../backup/paths.js", () => ({
204
+ getLocalBackupsDir: (override?: string | null) =>
205
+ override ?? "/tmp/local",
206
+ getBackupKeyPath: () => "/tmp/backup.key",
207
+ resolveOffsiteDestinations: (
208
+ override?: BackupDestination[] | null,
209
+ ): BackupDestination[] => {
210
+ if (override == null) {
211
+ return [{ path: "/icloud/default", encrypt: true }];
212
+ }
213
+ return override;
214
+ },
215
+ getDefaultOffsiteBackupsDir: () => "/icloud/default",
216
+ formatBackupFilename: (
217
+ date: Date,
218
+ { encrypted }: { encrypted: boolean },
219
+ ) => `backup-${date.toISOString()}${encrypted ? ".vbundle.enc" : ".vbundle"}`,
220
+ parseBackupTimestamp: () => null,
221
+ }));
222
+
223
+ mock.module("../../../backup/backup-key.js", () => ({
224
+ readBackupKey: async () => Buffer.alloc(32),
225
+ ensureBackupKey: async () => Buffer.alloc(32),
226
+ }));
227
+
228
+ mock.module("../../../backup/restore.js", () => ({
229
+ verifySnapshot: async () => mockVerifyResult,
230
+ restoreFromSnapshot: async () => {
231
+ recoveryCallOrder.push("restoreFromSnapshot");
232
+ return mockRestoreResult;
233
+ },
234
+ }));
235
+
236
+ mock.module("../../../backup/backup-worker.js", () => ({
237
+ createSnapshotNow: async () => {
238
+ if (mockCreateShouldThrow) throw mockCreateShouldThrow;
239
+ return mockCreateSnapshotResult;
240
+ },
241
+ }));
242
+
243
+ mock.module("../../../runtime/migrations/vbundle-import-analyzer.js", () => ({
244
+ DefaultPathResolver: class {
245
+ constructor(..._args: unknown[]) {}
246
+ resolve(): null {
247
+ return null;
248
+ }
249
+ },
250
+ }));
251
+
252
+ mock.module("../../../util/platform.js", () => ({
253
+ getWorkspaceDir: () => "/tmp/workspace",
254
+ getWorkspaceHooksDir: () => "/tmp/workspace/hooks",
255
+ }));
256
+
257
+ mock.module("../../../util/logger.js", () => ({
258
+ getLogger: () => ({
259
+ info: () => {},
260
+ warn: () => {},
261
+ error: () => {},
262
+ debug: () => {},
263
+ }),
264
+ getCliLogger: () => ({
265
+ info: (msg: string) => mockLogInfo.push(msg),
266
+ warn: () => {},
267
+ error: (msg: string) => mockLogError.push(msg),
268
+ debug: () => {},
269
+ }),
270
+ }));
271
+
272
+ mock.module("../../logger.js", () => ({
273
+ log: {
274
+ info: (msg: string) => mockLogInfo.push(msg),
275
+ warn: () => {},
276
+ error: (msg: string) => mockLogError.push(msg),
277
+ debug: () => {},
278
+ },
279
+ getCliLogger: () => ({
280
+ info: (msg: string) => mockLogInfo.push(msg),
281
+ warn: () => {},
282
+ error: (msg: string) => mockLogError.push(msg),
283
+ debug: () => {},
284
+ }),
285
+ }));
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Helpers
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Compute the "validated" backup config the command sees via `getConfig`.
293
+ * Reads the shared `mockRawConfig` record and applies schema defaults for
294
+ * any missing keys. We can't use the real Zod schema here because the
295
+ * config/loader mock above intercepts the whole module.
296
+ */
297
+ function getComputedBackupConfig(): BackupConfig {
298
+ const raw =
299
+ (mockRawConfig.backup as Record<string, unknown> | undefined) ?? {};
300
+ const offsite = (raw.offsite as Record<string, unknown> | undefined) ?? {};
301
+ return {
302
+ enabled: (raw.enabled as boolean | undefined) ?? false,
303
+ intervalHours: (raw.intervalHours as number | undefined) ?? 6,
304
+ retention: (raw.retention as number | undefined) ?? 7,
305
+ offsite: {
306
+ enabled: (offsite.enabled as boolean | undefined) ?? true,
307
+ destinations:
308
+ (offsite.destinations as BackupDestination[] | null | undefined) ??
309
+ null,
310
+ },
311
+ localDirectory:
312
+ (raw.localDirectory as string | null | undefined) ?? null,
313
+ };
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Import module under test (after mocks are registered)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ const backupMod = await import("../backup.js");
321
+ const {
322
+ handleEnable,
323
+ handleDisable,
324
+ handleDestinationsAdd,
325
+ handleDestinationsRemove,
326
+ handleDestinationsSetEncrypt,
327
+ handleDestinationsList,
328
+ handleStatus,
329
+ handleList,
330
+ handleCreate,
331
+ handleVerify,
332
+ handleRestore,
333
+ registerBackupCommand,
334
+ } = backupMod;
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Reset between tests
338
+ // ---------------------------------------------------------------------------
339
+
340
+ beforeEach(() => {
341
+ mockRawConfig = {};
342
+ mockSaveRawConfigCalls = [];
343
+ mockLastRunAt = null;
344
+ mockSnapshots = {};
345
+ mockLogInfo = [];
346
+ mockLogError = [];
347
+ mockCreateShouldThrow = null;
348
+ mockDaemonRunning = false;
349
+ mockResetDbCalls = 0;
350
+ mockInvalidateConfigCacheCalls = 0;
351
+ mockClearTrustCacheCalls = 0;
352
+ recoveryCallOrder.length = 0;
353
+ process.exitCode = 0;
354
+ mockVerifyResult = { valid: true };
355
+ mockRestoreResult = {
356
+ manifest: {
357
+ schema_version: "1.0.0",
358
+ created_at: "2026-04-11T09:30:00Z",
359
+ source: "test",
360
+ files: [],
361
+ manifest_sha256: "abc",
362
+ },
363
+ restoredFiles: 42,
364
+ };
365
+ mockCreateSnapshotResult = {
366
+ local: {
367
+ path: "/tmp/local/backup-20260411-093000.vbundle",
368
+ filename: "backup-20260411-093000.vbundle",
369
+ createdAt: new Date("2026-04-11T09:30:00Z"),
370
+ sizeBytes: 1024,
371
+ encrypted: false,
372
+ },
373
+ offsite: [],
374
+ durationMs: 123,
375
+ };
376
+ });
377
+
378
+ afterEach(() => {
379
+ process.exitCode = 0;
380
+ });
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // enable / disable
384
+ // ---------------------------------------------------------------------------
385
+
386
+ describe("handleEnable", () => {
387
+ test("persists backup.enabled=true and no other overrides", () => {
388
+ handleEnable({});
389
+ expect(mockSaveRawConfigCalls.length).toBe(1);
390
+ const saved = mockSaveRawConfigCalls[0]!;
391
+ expect(
392
+ (saved.backup as Record<string, unknown>).enabled,
393
+ ).toBe(true);
394
+ expect(
395
+ (saved.backup as Record<string, unknown>).intervalHours,
396
+ ).toBeUndefined();
397
+ });
398
+
399
+ test("applies --interval and --retention overrides", () => {
400
+ handleEnable({ interval: "12", retention: "14" });
401
+ const saved = mockSaveRawConfigCalls[0]!;
402
+ const cfg = saved.backup as Record<string, unknown>;
403
+ expect(cfg.enabled).toBe(true);
404
+ expect(cfg.intervalHours).toBe(12);
405
+ expect(cfg.retention).toBe(14);
406
+ });
407
+
408
+ test("rejects non-numeric interval", () => {
409
+ handleEnable({ interval: "abc" });
410
+ expect(process.exitCode).toBe(1);
411
+ expect(mockSaveRawConfigCalls.length).toBe(0);
412
+ expect(mockLogError.some((m) => m.includes("--interval"))).toBe(true);
413
+ });
414
+
415
+ test("rejects zero retention", () => {
416
+ handleEnable({ retention: "0" });
417
+ expect(process.exitCode).toBe(1);
418
+ expect(mockSaveRawConfigCalls.length).toBe(0);
419
+ });
420
+
421
+ test("--no-offsite sets offsite.enabled=false but leaves destinations untouched", () => {
422
+ mockRawConfig = {
423
+ backup: {
424
+ offsite: {
425
+ destinations: [{ path: "/tmp/x", encrypt: true }],
426
+ },
427
+ },
428
+ };
429
+ handleEnable({ offsite: false });
430
+ const saved = mockSaveRawConfigCalls[0]!;
431
+ const cfg = saved.backup as Record<string, unknown>;
432
+ expect(cfg.enabled).toBe(true);
433
+ expect(
434
+ (cfg.offsite as Record<string, unknown>).enabled,
435
+ ).toBe(false);
436
+ // destinations preserved exactly as-is
437
+ expect(
438
+ (cfg.offsite as Record<string, unknown>).destinations,
439
+ ).toEqual([{ path: "/tmp/x", encrypt: true }]);
440
+ });
441
+ });
442
+
443
+ describe("handleDisable", () => {
444
+ test("persists backup.enabled=false", () => {
445
+ mockRawConfig = { backup: { enabled: true, intervalHours: 6 } };
446
+ handleDisable();
447
+ const saved = mockSaveRawConfigCalls[0]!;
448
+ const cfg = saved.backup as Record<string, unknown>;
449
+ expect(cfg.enabled).toBe(false);
450
+ // other fields preserved
451
+ expect(cfg.intervalHours).toBe(6);
452
+ });
453
+ });
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // destinations add / remove / set-encrypt
457
+ // ---------------------------------------------------------------------------
458
+
459
+ describe("handleDestinationsAdd", () => {
460
+ test("on null destinations: materializes iCloud default then appends", () => {
461
+ // Start with completely empty config — destinations resolves to iCloud default.
462
+ handleDestinationsAdd("/tmp/x", {});
463
+ const saved = mockSaveRawConfigCalls[0]!;
464
+ const destinations = (
465
+ (saved.backup as Record<string, unknown>).offsite as Record<
466
+ string,
467
+ unknown
468
+ >
469
+ ).destinations as BackupDestination[];
470
+ expect(destinations).toHaveLength(2);
471
+ expect(destinations[0]).toEqual({
472
+ path: "/icloud/default",
473
+ encrypt: true,
474
+ });
475
+ expect(destinations[1]).toEqual({ path: "/tmp/x", encrypt: true });
476
+ });
477
+
478
+ test("--plaintext stores encrypt: false", () => {
479
+ handleDestinationsAdd("/tmp/x", { plaintext: true });
480
+ const saved = mockSaveRawConfigCalls[0]!;
481
+ const destinations = (
482
+ (saved.backup as Record<string, unknown>).offsite as Record<
483
+ string,
484
+ unknown
485
+ >
486
+ ).destinations as BackupDestination[];
487
+ // 2 entries: iCloud default + new plaintext /tmp/x
488
+ const tmpEntry = destinations.find((d) => d.path === "/tmp/x")!;
489
+ expect(tmpEntry).toEqual({ path: "/tmp/x", encrypt: false });
490
+ });
491
+
492
+ test("appends to existing explicit array without re-materializing default", () => {
493
+ mockRawConfig = {
494
+ backup: {
495
+ offsite: {
496
+ destinations: [{ path: "/existing", encrypt: true }],
497
+ },
498
+ },
499
+ };
500
+ handleDestinationsAdd("/new", { plaintext: true });
501
+ const saved = mockSaveRawConfigCalls[0]!;
502
+ const destinations = (
503
+ (saved.backup as Record<string, unknown>).offsite as Record<
504
+ string,
505
+ unknown
506
+ >
507
+ ).destinations as BackupDestination[];
508
+ expect(destinations).toEqual([
509
+ { path: "/existing", encrypt: true },
510
+ { path: "/new", encrypt: false },
511
+ ]);
512
+ });
513
+
514
+ test("duplicate path errors", () => {
515
+ mockRawConfig = {
516
+ backup: {
517
+ offsite: {
518
+ destinations: [{ path: "/dup", encrypt: true }],
519
+ },
520
+ },
521
+ };
522
+ handleDestinationsAdd("/dup", {});
523
+ expect(process.exitCode).toBe(1);
524
+ expect(mockSaveRawConfigCalls.length).toBe(0);
525
+ });
526
+ });
527
+
528
+ describe("handleDestinationsRemove", () => {
529
+ test("removes matching entry", () => {
530
+ mockRawConfig = {
531
+ backup: {
532
+ offsite: {
533
+ destinations: [
534
+ { path: "/a", encrypt: true },
535
+ { path: "/b", encrypt: false },
536
+ ],
537
+ },
538
+ },
539
+ };
540
+ handleDestinationsRemove("/a");
541
+ const saved = mockSaveRawConfigCalls[0]!;
542
+ const destinations = (
543
+ (saved.backup as Record<string, unknown>).offsite as Record<
544
+ string,
545
+ unknown
546
+ >
547
+ ).destinations as BackupDestination[];
548
+ expect(destinations).toEqual([{ path: "/b", encrypt: false }]);
549
+ });
550
+
551
+ test("errors on nonexistent path", () => {
552
+ mockRawConfig = {
553
+ backup: {
554
+ offsite: {
555
+ destinations: [{ path: "/a", encrypt: true }],
556
+ },
557
+ },
558
+ };
559
+ handleDestinationsRemove("/nonexistent");
560
+ expect(process.exitCode).toBe(1);
561
+ expect(mockSaveRawConfigCalls.length).toBe(0);
562
+ expect(mockLogError.some((m) => m.includes("not found"))).toBe(true);
563
+ });
564
+ });
565
+
566
+ describe("handleDestinationsSetEncrypt", () => {
567
+ test("flips encrypt flag to false", () => {
568
+ mockRawConfig = {
569
+ backup: {
570
+ offsite: {
571
+ destinations: [{ path: "/x", encrypt: true }],
572
+ },
573
+ },
574
+ };
575
+ handleDestinationsSetEncrypt("/x", "false");
576
+ const saved = mockSaveRawConfigCalls[0]!;
577
+ const destinations = (
578
+ (saved.backup as Record<string, unknown>).offsite as Record<
579
+ string,
580
+ unknown
581
+ >
582
+ ).destinations as BackupDestination[];
583
+ expect(destinations).toEqual([{ path: "/x", encrypt: false }]);
584
+ });
585
+
586
+ test("flips encrypt flag to true", () => {
587
+ mockRawConfig = {
588
+ backup: {
589
+ offsite: {
590
+ destinations: [{ path: "/x", encrypt: false }],
591
+ },
592
+ },
593
+ };
594
+ handleDestinationsSetEncrypt("/x", "true");
595
+ const saved = mockSaveRawConfigCalls[0]!;
596
+ const destinations = (
597
+ (saved.backup as Record<string, unknown>).offsite as Record<
598
+ string,
599
+ unknown
600
+ >
601
+ ).destinations as BackupDestination[];
602
+ expect(destinations[0]!.encrypt).toBe(true);
603
+ });
604
+
605
+ test("rejects non-boolean value", () => {
606
+ mockRawConfig = {
607
+ backup: {
608
+ offsite: {
609
+ destinations: [{ path: "/x", encrypt: false }],
610
+ },
611
+ },
612
+ };
613
+ handleDestinationsSetEncrypt("/x", "yes");
614
+ expect(process.exitCode).toBe(1);
615
+ expect(mockSaveRawConfigCalls.length).toBe(0);
616
+ });
617
+
618
+ test("errors on nonexistent path", () => {
619
+ handleDestinationsSetEncrypt("/missing", "true");
620
+ expect(process.exitCode).toBe(1);
621
+ expect(mockSaveRawConfigCalls.length).toBe(0);
622
+ });
623
+ });
624
+
625
+ describe("handleDestinationsList", () => {
626
+ test("empty state shows friendly message", async () => {
627
+ mockRawConfig = {
628
+ backup: {
629
+ offsite: {
630
+ destinations: [],
631
+ },
632
+ },
633
+ };
634
+ await handleDestinationsList();
635
+ expect(
636
+ mockLogInfo.some((m) => m.includes("No offsite destinations")),
637
+ ).toBe(true);
638
+ });
639
+
640
+ test("lists all destinations with encryption flag", async () => {
641
+ mockRawConfig = {
642
+ backup: {
643
+ offsite: {
644
+ destinations: [
645
+ { path: "/a", encrypt: true },
646
+ { path: "/b", encrypt: false },
647
+ ],
648
+ },
649
+ },
650
+ };
651
+ await handleDestinationsList();
652
+ const out = mockLogInfo.join("\n");
653
+ expect(out).toContain("/a");
654
+ expect(out).toContain("/b");
655
+ expect(out).toContain("yes");
656
+ expect(out).toContain("no");
657
+ });
658
+ });
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // status
662
+ // ---------------------------------------------------------------------------
663
+
664
+ describe("handleStatus", () => {
665
+ test("disabled state renders header", async () => {
666
+ await handleStatus();
667
+ const out = mockLogInfo.join("\n");
668
+ expect(out).toContain("Automatic backups: disabled");
669
+ expect(out).toContain("Interval:");
670
+ expect(out).toContain("Retention:");
671
+ expect(out).toContain("Last run:");
672
+ });
673
+
674
+ test("enabled state with last-run checkpoint and mixed destinations", async () => {
675
+ mockRawConfig = {
676
+ backup: {
677
+ enabled: true,
678
+ intervalHours: 6,
679
+ retention: 7,
680
+ offsite: {
681
+ enabled: true,
682
+ destinations: [
683
+ { path: "/reachable", encrypt: true },
684
+ { path: "/unreachable/path", encrypt: false },
685
+ ],
686
+ },
687
+ },
688
+ };
689
+ // Make the first destination reachable by putting a snapshot at its dir.
690
+ // Our mocked list-snapshots returns whatever is in mockSnapshots — but
691
+ // reachability is probed via fs/promises.stat(dirname(path)), which is
692
+ // real. Instead, we rely on: both dirs won't exist → both "[unreachable]"
693
+ // in production. For this test we just check the lines render.
694
+ mockLastRunAt = String(Date.now() - 60 * 60 * 1000); // 1h ago
695
+ await handleStatus();
696
+ const out = mockLogInfo.join("\n");
697
+ expect(out).toContain("Automatic backups: enabled");
698
+ expect(out).toContain("Last run:");
699
+ expect(out).toContain("/reachable");
700
+ expect(out).toContain("/unreachable/path");
701
+ });
702
+
703
+ test("offsite disabled shows (disabled) line", async () => {
704
+ mockRawConfig = {
705
+ backup: {
706
+ enabled: true,
707
+ offsite: { enabled: false },
708
+ },
709
+ };
710
+ await handleStatus();
711
+ const out = mockLogInfo.join("\n");
712
+ expect(out).toContain("(disabled)");
713
+ });
714
+ });
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // list
718
+ // ---------------------------------------------------------------------------
719
+
720
+ describe("handleList", () => {
721
+ test("empty-state renders per-group '(none)'", async () => {
722
+ await handleList();
723
+ const out = mockLogInfo.join("\n");
724
+ expect(out).toContain("Local:");
725
+ // iCloud default is included when offsite.enabled=true by default
726
+ expect(out).toContain("(none)");
727
+ });
728
+
729
+ test("populated local pool renders table rows", async () => {
730
+ mockSnapshots["/tmp/local"] = [
731
+ {
732
+ path: "/tmp/local/backup-20260411-093000.vbundle",
733
+ filename: "backup-20260411-093000.vbundle",
734
+ createdAt: new Date("2026-04-11T09:30:00Z"),
735
+ sizeBytes: 1024,
736
+ encrypted: false,
737
+ },
738
+ ];
739
+ await handleList();
740
+ const out = mockLogInfo.join("\n");
741
+ expect(out).toContain("backup-20260411-093000.vbundle");
742
+ expect(out).toContain("Local:");
743
+ expect(out).toContain("2026-04-11 09:30 UTC");
744
+ });
745
+
746
+ test("per-destination grouping with explicit offsite", async () => {
747
+ mockRawConfig = {
748
+ backup: {
749
+ enabled: true,
750
+ offsite: {
751
+ enabled: true,
752
+ destinations: [{ path: "/off1", encrypt: true }],
753
+ },
754
+ },
755
+ };
756
+ mockSnapshots["/off1"] = [
757
+ {
758
+ path: "/off1/backup.vbundle.enc",
759
+ filename: "backup.vbundle.enc",
760
+ createdAt: new Date("2026-04-11T09:30:00Z"),
761
+ sizeBytes: 2048,
762
+ encrypted: true,
763
+ },
764
+ ];
765
+ await handleList();
766
+ const out = mockLogInfo.join("\n");
767
+ expect(out).toContain("Offsite: /off1");
768
+ expect(out).toContain("encrypted");
769
+ });
770
+ });
771
+
772
+ // ---------------------------------------------------------------------------
773
+ // create
774
+ // ---------------------------------------------------------------------------
775
+
776
+ describe("handleCreate", () => {
777
+ test("renders successful snapshot result with empty offsite", async () => {
778
+ await handleCreate();
779
+ const out = mockLogInfo.join("\n");
780
+ expect(out).toContain("Created snapshot:");
781
+ expect(out).toContain("backup-20260411-093000.vbundle");
782
+ expect(out).toContain("offsite: (none)");
783
+ });
784
+
785
+ test("renders per-destination outcome mix (ok, skipped, error)", async () => {
786
+ mockCreateSnapshotResult = {
787
+ ...mockCreateSnapshotResult,
788
+ offsite: [
789
+ {
790
+ destination: { path: "/ok", encrypt: true },
791
+ entry: {
792
+ path: "/ok/f.vbundle.enc",
793
+ filename: "f.vbundle.enc",
794
+ createdAt: new Date(),
795
+ sizeBytes: 100,
796
+ encrypted: true,
797
+ },
798
+ },
799
+ {
800
+ destination: { path: "/skipped", encrypt: true },
801
+ entry: null,
802
+ skipped: "parent-missing",
803
+ },
804
+ {
805
+ destination: { path: "/broken", encrypt: false },
806
+ entry: null,
807
+ error: "disk full",
808
+ },
809
+ ],
810
+ };
811
+ await handleCreate();
812
+ const out = mockLogInfo.join("\n");
813
+ expect(out).toContain("ok /ok");
814
+ expect(out).toContain("skipped /skipped");
815
+ expect(out).toContain("error /broken");
816
+ expect(out).toContain("disk full");
817
+ });
818
+
819
+ test("concurrency error prints clear message", async () => {
820
+ mockCreateShouldThrow = new Error("snapshot in progress");
821
+ await handleCreate();
822
+ expect(process.exitCode).toBe(1);
823
+ expect(
824
+ mockLogError.some((m) =>
825
+ m.toLowerCase().includes("already running"),
826
+ ),
827
+ ).toBe(true);
828
+ });
829
+ });
830
+
831
+ // ---------------------------------------------------------------------------
832
+ // verify
833
+ // ---------------------------------------------------------------------------
834
+
835
+ describe("handleVerify", () => {
836
+ test("returns valid result from verifySnapshot", async () => {
837
+ mockVerifyResult = {
838
+ valid: true,
839
+ manifest: {
840
+ schema_version: "1.0.0",
841
+ created_at: "2026-04-11T09:30:00Z",
842
+ source: "backup-worker",
843
+ files: [],
844
+ manifest_sha256: "abc",
845
+ },
846
+ };
847
+ await handleVerify("/tmp/local/backup.vbundle");
848
+ const out = mockLogInfo.join("\n");
849
+ expect(out).toContain("OK:");
850
+ expect(out).toContain("1.0.0");
851
+ expect(process.exitCode).toBe(0);
852
+ });
853
+
854
+ test("propagates invalid result with error details", async () => {
855
+ mockVerifyResult = { valid: false, error: "bad checksum" };
856
+ await handleVerify("/tmp/local/backup.vbundle");
857
+ expect(process.exitCode).toBe(1);
858
+ expect(
859
+ mockLogError.some((m) => m.includes("Invalid:")),
860
+ ).toBe(true);
861
+ expect(
862
+ mockLogError.some((m) => m.includes("bad checksum")),
863
+ ).toBe(true);
864
+ });
865
+ });
866
+
867
+ // ---------------------------------------------------------------------------
868
+ // restore
869
+ // ---------------------------------------------------------------------------
870
+
871
+ /**
872
+ * Replace `process.stdin` with a readable stream that emits the given line.
873
+ * `readline.createInterface` consumes this as a single line so prompt-based
874
+ * confirmation tests stay deterministic.
875
+ */
876
+ function stubStdin(input: string): () => void {
877
+ const original = process.stdin;
878
+ const stream = Readable.from([input + "\n"]) as NodeJS.ReadableStream;
879
+ Object.defineProperty(process, "stdin", {
880
+ value: stream,
881
+ writable: true,
882
+ configurable: true,
883
+ });
884
+ return () => {
885
+ Object.defineProperty(process, "stdin", {
886
+ value: original,
887
+ writable: true,
888
+ configurable: true,
889
+ });
890
+ };
891
+ }
892
+
893
+ describe("handleRestore", () => {
894
+ test("--yes flag bypasses confirmation and calls restoreFromSnapshot", async () => {
895
+ await handleRestore({
896
+ path: "/tmp/local/backup-20260411-093000.vbundle",
897
+ yes: true,
898
+ });
899
+ expect(process.exitCode).toBe(0);
900
+ const out = mockLogInfo.join("\n");
901
+ expect(out).toContain("Restored from");
902
+ expect(out).toContain("files restored: 42");
903
+ });
904
+
905
+ test("without --yes and 'n' answer aborts", async () => {
906
+ const restore = stubStdin("n");
907
+ try {
908
+ await handleRestore({
909
+ path: "/tmp/local/backup-20260411-093000.vbundle",
910
+ });
911
+ } finally {
912
+ restore();
913
+ }
914
+ expect(process.exitCode).toBe(0);
915
+ const out = mockLogInfo.join("\n");
916
+ expect(out).toContain("Restore cancelled");
917
+ });
918
+
919
+ test("without --path and without --latest errors", async () => {
920
+ await handleRestore({});
921
+ expect(process.exitCode).toBe(1);
922
+ expect(
923
+ mockLogError.some((m) => m.includes("--path")),
924
+ ).toBe(true);
925
+ });
926
+
927
+ test("both --path and --latest errors", async () => {
928
+ await handleRestore({
929
+ path: "/tmp/x.vbundle",
930
+ latest: true,
931
+ yes: true,
932
+ });
933
+ expect(process.exitCode).toBe(1);
934
+ expect(
935
+ mockLogError.some((m) => m.includes("Cannot combine")),
936
+ ).toBe(true);
937
+ });
938
+
939
+ test("--latest with no local snapshots errors", async () => {
940
+ mockSnapshots["/tmp/local"] = [];
941
+ await handleRestore({ latest: true, yes: true });
942
+ expect(process.exitCode).toBe(1);
943
+ expect(
944
+ mockLogError.some((m) => m.includes("No local snapshots")),
945
+ ).toBe(true);
946
+ });
947
+
948
+ test("--latest picks newest local snapshot", async () => {
949
+ mockSnapshots["/tmp/local"] = [
950
+ {
951
+ path: "/tmp/local/newest.vbundle",
952
+ filename: "newest.vbundle",
953
+ createdAt: new Date("2026-04-11T12:00:00Z"),
954
+ sizeBytes: 1024,
955
+ encrypted: false,
956
+ },
957
+ {
958
+ path: "/tmp/local/older.vbundle",
959
+ filename: "older.vbundle",
960
+ createdAt: new Date("2026-04-10T12:00:00Z"),
961
+ sizeBytes: 1024,
962
+ encrypted: false,
963
+ },
964
+ ];
965
+ await handleRestore({ latest: true, yes: true });
966
+ expect(process.exitCode).toBe(0);
967
+ expect(mockLogInfo.some((m) => m.includes("newest.vbundle"))).toBe(
968
+ true,
969
+ );
970
+ });
971
+
972
+ test("refuses to run while the assistant is running (no --force)", async () => {
973
+ // Safety gate: the CLI must refuse to restore against a live assistant
974
+ // unless --force is passed. Restoring under a running assistant is
975
+ // dangerous — the open SQLite handle, cached config, and cached trust
976
+ // rules all contradict the on-disk state after the bundle is written.
977
+ mockDaemonRunning = true;
978
+
979
+ await handleRestore({
980
+ path: "/tmp/local/backup-20260411-093000.vbundle",
981
+ yes: true,
982
+ });
983
+
984
+ expect(process.exitCode).toBe(1);
985
+ expect(
986
+ mockLogError.some((m) =>
987
+ m.toLowerCase().includes("assistant is running"),
988
+ ),
989
+ ).toBe(true);
990
+ // restoreFromSnapshot must not have been called — the safety gate
991
+ // bails before reaching the recovery sequence.
992
+ expect(mockResetDbCalls).toBe(0);
993
+ expect(recoveryCallOrder).toEqual([]);
994
+ });
995
+
996
+ test("--force overrides the daemon-running refusal and still runs recovery sequence", async () => {
997
+ mockDaemonRunning = true;
998
+
999
+ await handleRestore({
1000
+ path: "/tmp/local/backup-20260411-093000.vbundle",
1001
+ yes: true,
1002
+ force: true,
1003
+ });
1004
+
1005
+ expect(process.exitCode).toBe(0);
1006
+ // Recovery sequence must still run even with --force — the flag only
1007
+ // overrides the running-assistant refusal, not the DB reset or cache
1008
+ // invalidation.
1009
+ expect(mockResetDbCalls).toBe(1);
1010
+ expect(mockInvalidateConfigCacheCalls).toBe(1);
1011
+ expect(mockClearTrustCacheCalls).toBe(1);
1012
+ expect(recoveryCallOrder).toEqual([
1013
+ "resetDb",
1014
+ "restoreFromSnapshot",
1015
+ "invalidateConfigCache",
1016
+ "clearTrustCache",
1017
+ ]);
1018
+ });
1019
+
1020
+ test("successful restore runs resetDb, restore, then cache invalidation in order", async () => {
1021
+ // Regression test for the restore-corrupts-daemon-state gap. The CLI
1022
+ // handler must mirror the HTTP handler's recovery sequence so an
1023
+ // in-process CLI invocation leaves the shared runtime in a consistent
1024
+ // state.
1025
+ mockDaemonRunning = false;
1026
+
1027
+ await handleRestore({
1028
+ path: "/tmp/local/backup-20260411-093000.vbundle",
1029
+ yes: true,
1030
+ });
1031
+
1032
+ expect(process.exitCode).toBe(0);
1033
+ expect(mockResetDbCalls).toBe(1);
1034
+ expect(mockInvalidateConfigCacheCalls).toBe(1);
1035
+ expect(mockClearTrustCacheCalls).toBe(1);
1036
+ expect(recoveryCallOrder).toEqual([
1037
+ "resetDb",
1038
+ "restoreFromSnapshot",
1039
+ "invalidateConfigCache",
1040
+ "clearTrustCache",
1041
+ ]);
1042
+ });
1043
+ });
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // End-to-end: via Commander program
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ async function runProgram(
1050
+ args: string[],
1051
+ ): Promise<{ exitCode: number }> {
1052
+ process.exitCode = 0;
1053
+ try {
1054
+ const program = new Command();
1055
+ program.exitOverride();
1056
+ program.configureOutput({
1057
+ writeErr: () => {},
1058
+ writeOut: () => {},
1059
+ });
1060
+ registerBackupCommand(program);
1061
+ await program.parseAsync(["node", "assistant", ...args]);
1062
+ } catch {
1063
+ if (process.exitCode === 0) process.exitCode = 1;
1064
+ }
1065
+ const exitCode = process.exitCode ?? 0;
1066
+ return { exitCode };
1067
+ }
1068
+
1069
+ describe("registerBackupCommand (end-to-end)", () => {
1070
+ test("vellum backup enable persists enabled=true via commander", async () => {
1071
+ const { exitCode } = await runProgram(["backup", "enable"]);
1072
+ expect(exitCode).toBe(0);
1073
+ expect(
1074
+ mockSaveRawConfigCalls.length,
1075
+ ).toBeGreaterThanOrEqual(1);
1076
+ expect(
1077
+ (mockSaveRawConfigCalls.at(-1)!.backup as Record<string, unknown>)
1078
+ .enabled,
1079
+ ).toBe(true);
1080
+ });
1081
+
1082
+ test("vellum backup destinations add on null field materializes iCloud default", async () => {
1083
+ const { exitCode } = await runProgram([
1084
+ "backup",
1085
+ "destinations",
1086
+ "add",
1087
+ "/tmp/x",
1088
+ ]);
1089
+ expect(exitCode).toBe(0);
1090
+ const saved = mockSaveRawConfigCalls.at(-1)!;
1091
+ const destinations = (
1092
+ (saved.backup as Record<string, unknown>).offsite as Record<
1093
+ string,
1094
+ unknown
1095
+ >
1096
+ ).destinations as BackupDestination[];
1097
+ expect(destinations).toHaveLength(2);
1098
+ expect(destinations[0]!.path).toBe("/icloud/default");
1099
+ expect(destinations[1]).toEqual({ path: "/tmp/x", encrypt: true });
1100
+ });
1101
+
1102
+ test("vellum backup destinations add --plaintext stores encrypt=false", async () => {
1103
+ const { exitCode } = await runProgram([
1104
+ "backup",
1105
+ "destinations",
1106
+ "add",
1107
+ "/tmp/ssd",
1108
+ "--plaintext",
1109
+ ]);
1110
+ expect(exitCode).toBe(0);
1111
+ const saved = mockSaveRawConfigCalls.at(-1)!;
1112
+ const destinations = (
1113
+ (saved.backup as Record<string, unknown>).offsite as Record<
1114
+ string,
1115
+ unknown
1116
+ >
1117
+ ).destinations as BackupDestination[];
1118
+ const added = destinations.find((d) => d.path === "/tmp/ssd");
1119
+ expect(added).toEqual({ path: "/tmp/ssd", encrypt: false });
1120
+ });
1121
+
1122
+ test("vellum backup destinations remove /nonexistent exits with error", async () => {
1123
+ mockRawConfig = {
1124
+ backup: {
1125
+ offsite: {
1126
+ destinations: [{ path: "/existing", encrypt: true }],
1127
+ },
1128
+ },
1129
+ };
1130
+ const { exitCode } = await runProgram([
1131
+ "backup",
1132
+ "destinations",
1133
+ "remove",
1134
+ "/nonexistent",
1135
+ ]);
1136
+ expect(exitCode).toBe(1);
1137
+ expect(mockSaveRawConfigCalls.length).toBe(0);
1138
+ });
1139
+
1140
+ test("vellum backup destinations set-encrypt flips flag", async () => {
1141
+ mockRawConfig = {
1142
+ backup: {
1143
+ offsite: {
1144
+ destinations: [{ path: "/tmp/x", encrypt: true }],
1145
+ },
1146
+ },
1147
+ };
1148
+ const { exitCode } = await runProgram([
1149
+ "backup",
1150
+ "destinations",
1151
+ "set-encrypt",
1152
+ "/tmp/x",
1153
+ "false",
1154
+ ]);
1155
+ expect(exitCode).toBe(0);
1156
+ const saved = mockSaveRawConfigCalls.at(-1)!;
1157
+ const destinations = (
1158
+ (saved.backup as Record<string, unknown>).offsite as Record<
1159
+ string,
1160
+ unknown
1161
+ >
1162
+ ).destinations as BackupDestination[];
1163
+ expect(destinations[0]!.encrypt).toBe(false);
1164
+ });
1165
+ });