vibecarbon 0.1.0

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 (495) hide show
  1. package/LICENSE +663 -0
  2. package/README.md +188 -0
  3. package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
  4. package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
  5. package/carbon/.claude/settings.json +29 -0
  6. package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
  7. package/carbon/.dockerignore +57 -0
  8. package/carbon/.env.example +219 -0
  9. package/carbon/.github/copilot-instructions.md +3 -0
  10. package/carbon/.github/workflows/deploy.yml +346 -0
  11. package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
  12. package/carbon/.windsurfrules +74 -0
  13. package/carbon/AGENTS.md +422 -0
  14. package/carbon/CLAUDE.md +3 -0
  15. package/carbon/DEVELOPMENT.md +187 -0
  16. package/carbon/Dockerfile +98 -0
  17. package/carbon/LICENSE +21 -0
  18. package/carbon/PRODUCTION.md +364 -0
  19. package/carbon/README.md +193 -0
  20. package/carbon/TESTING.md +138 -0
  21. package/carbon/backup/Dockerfile +75 -0
  22. package/carbon/backup/backup.sh +95 -0
  23. package/carbon/backup/compose-backup.sh +140 -0
  24. package/carbon/biome.json +83 -0
  25. package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
  26. package/carbon/cloud-init/k3s/master-init.sh +215 -0
  27. package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
  28. package/carbon/cloud-init/k3s/worker-init.sh +147 -0
  29. package/carbon/components.json +24 -0
  30. package/carbon/content/blog/authentication-guide.mdx +34 -0
  31. package/carbon/content/blog/getting-started.mdx +41 -0
  32. package/carbon/content/changelog/v0-1-0.mdx +40 -0
  33. package/carbon/content/docs/analytics.mdx +90 -0
  34. package/carbon/content/docs/authentication.mdx +231 -0
  35. package/carbon/content/docs/background-jobs.mdx +116 -0
  36. package/carbon/content/docs/cli.mdx +630 -0
  37. package/carbon/content/docs/database.mdx +236 -0
  38. package/carbon/content/docs/deployment.mdx +227 -0
  39. package/carbon/content/docs/development.mdx +238 -0
  40. package/carbon/content/docs/environments.mdx +84 -0
  41. package/carbon/content/docs/getting-started.mdx +112 -0
  42. package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
  43. package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
  44. package/carbon/content/docs/optional-services.mdx +160 -0
  45. package/carbon/db/Dockerfile +23 -0
  46. package/carbon/docker-compose.dev-init.yml +5 -0
  47. package/carbon/docker-compose.metabase.override.yml +14 -0
  48. package/carbon/docker-compose.metabase.prod.yml +28 -0
  49. package/carbon/docker-compose.metabase.yml +84 -0
  50. package/carbon/docker-compose.n8n.override.yml +14 -0
  51. package/carbon/docker-compose.n8n.prod.yml +31 -0
  52. package/carbon/docker-compose.n8n.yml +97 -0
  53. package/carbon/docker-compose.observability.override.yml +18 -0
  54. package/carbon/docker-compose.observability.prod.yml +28 -0
  55. package/carbon/docker-compose.observability.yml +125 -0
  56. package/carbon/docker-compose.override.yml +28 -0
  57. package/carbon/docker-compose.prod.yml +294 -0
  58. package/carbon/docker-compose.yml +508 -0
  59. package/carbon/docker-entrypoint.sh +12 -0
  60. package/carbon/ha/activate-standby.sh +52 -0
  61. package/carbon/ha/primary-init.sql +36 -0
  62. package/carbon/ha/standby-init.sh +74 -0
  63. package/carbon/k8s/LICENSE +99 -0
  64. package/carbon/k8s/README.md +272 -0
  65. package/carbon/k8s/base/app/deployment.yaml +130 -0
  66. package/carbon/k8s/base/app/hpa.yaml +44 -0
  67. package/carbon/k8s/base/app/kustomization.yaml +10 -0
  68. package/carbon/k8s/base/app/network-policy.yaml +124 -0
  69. package/carbon/k8s/base/app/pdb.yaml +14 -0
  70. package/carbon/k8s/base/app/rbac.yaml +36 -0
  71. package/carbon/k8s/base/app/service.yaml +16 -0
  72. package/carbon/k8s/base/backup/cronjob.yaml +120 -0
  73. package/carbon/k8s/base/backup/kustomization.yaml +7 -0
  74. package/carbon/k8s/base/backup/network-policy.yaml +31 -0
  75. package/carbon/k8s/base/backup/rbac.yaml +7 -0
  76. package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
  77. package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
  78. package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
  79. package/carbon/k8s/base/config/configmap.yaml +48 -0
  80. package/carbon/k8s/base/config/kustomization.yaml +8 -0
  81. package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
  82. package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
  83. package/carbon/k8s/base/kustomization.yaml +43 -0
  84. package/carbon/k8s/base/namespace.yaml +7 -0
  85. package/carbon/k8s/base/network-policies.yaml +95 -0
  86. package/carbon/k8s/base/registry/kustomization.yaml +5 -0
  87. package/carbon/k8s/base/registry/local-registry.yaml +193 -0
  88. package/carbon/k8s/base/traefik/certificate.yaml +21 -0
  89. package/carbon/k8s/base/traefik/configmap.yaml +13 -0
  90. package/carbon/k8s/base/traefik/deployment.yaml +165 -0
  91. package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
  92. package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
  93. package/carbon/k8s/base/traefik/middleware.yaml +109 -0
  94. package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
  95. package/carbon/k8s/base/traefik/service.yaml +38 -0
  96. package/carbon/k8s/flux/README.md +49 -0
  97. package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
  98. package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
  99. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
  100. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
  101. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
  102. package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
  103. package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
  104. package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
  105. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
  106. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
  107. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
  108. package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
  109. package/carbon/k8s/infra/kustomization.yaml +21 -0
  110. package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
  111. package/carbon/k8s/overlays/local/configmap.yaml +23 -0
  112. package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
  113. package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
  114. package/carbon/k8s/overlays/local/namespace.yaml +8 -0
  115. package/carbon/k8s/overlays/local/secrets.yaml +32 -0
  116. package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
  117. package/carbon/k8s/test-local.sh +318 -0
  118. package/carbon/k8s/values/supabase.values.yaml +133 -0
  119. package/carbon/package.json +154 -0
  120. package/carbon/runtime.Dockerfile +17 -0
  121. package/carbon/scripts/_dev-jwt.mjs +45 -0
  122. package/carbon/scripts/check-rls.ts +53 -0
  123. package/carbon/scripts/dev-init.js +191 -0
  124. package/carbon/scripts/dev.js +191 -0
  125. package/carbon/scripts/docker-down.js +63 -0
  126. package/carbon/scripts/docker-logs.js +59 -0
  127. package/carbon/scripts/docker-up.js +222 -0
  128. package/carbon/scripts/generate-dev-configs.sh +131 -0
  129. package/carbon/scripts/generate-rss.ts +116 -0
  130. package/carbon/scripts/generate-sitemap.ts +102 -0
  131. package/carbon/scripts/k8s-apply.js +71 -0
  132. package/carbon/scripts/k8s-delete.js +75 -0
  133. package/carbon/scripts/lib/manifest.js +176 -0
  134. package/carbon/scripts/secret-scan.mjs +278 -0
  135. package/carbon/scripts/validate-dev-configs.sh +101 -0
  136. package/carbon/src/client/App.tsx +202 -0
  137. package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
  138. package/carbon/src/client/assets/hyperformant-light.svg +29 -0
  139. package/carbon/src/client/assets/logos/aider.svg +1 -0
  140. package/carbon/src/client/assets/logos/antigravity.svg +1 -0
  141. package/carbon/src/client/assets/logos/bolt.svg +1 -0
  142. package/carbon/src/client/assets/logos/claude-code.svg +1 -0
  143. package/carbon/src/client/assets/logos/copilot.svg +1 -0
  144. package/carbon/src/client/assets/logos/cursor.svg +1 -0
  145. package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
  146. package/carbon/src/client/assets/logos/lovable.svg +1 -0
  147. package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
  148. package/carbon/src/client/assets/logos/v0.svg +1 -0
  149. package/carbon/src/client/assets/logos/windsurf.svg +1 -0
  150. package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
  151. package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
  152. package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
  153. package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
  154. package/carbon/src/client/components/AppSidebar.tsx +760 -0
  155. package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
  156. package/carbon/src/client/components/CTAFooter.tsx +59 -0
  157. package/carbon/src/client/components/ComparisonSection.tsx +132 -0
  158. package/carbon/src/client/components/ContentPanel.tsx +66 -0
  159. package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
  160. package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
  161. package/carbon/src/client/components/FAQSection.tsx +76 -0
  162. package/carbon/src/client/components/FileUpload.tsx +210 -0
  163. package/carbon/src/client/components/HeaderActions.tsx +17 -0
  164. package/carbon/src/client/components/Hero.tsx +608 -0
  165. package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
  166. package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
  167. package/carbon/src/client/components/Logo.tsx +87 -0
  168. package/carbon/src/client/components/LogoStrip.tsx +76 -0
  169. package/carbon/src/client/components/MetricsStrip.tsx +48 -0
  170. package/carbon/src/client/components/Nav.tsx +195 -0
  171. package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
  172. package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
  173. package/carbon/src/client/components/PageHeader.tsx +24 -0
  174. package/carbon/src/client/components/PlanGate.tsx +36 -0
  175. package/carbon/src/client/components/PricingSection.tsx +371 -0
  176. package/carbon/src/client/components/SEO.tsx +34 -0
  177. package/carbon/src/client/components/SmoothScroll.tsx +45 -0
  178. package/carbon/src/client/components/TechStackSection.tsx +165 -0
  179. package/carbon/src/client/components/WorkflowSection.tsx +604 -0
  180. package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
  181. package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
  182. package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
  183. package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
  184. package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
  185. package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
  186. package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
  187. package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
  188. package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
  189. package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
  190. package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
  191. package/carbon/src/client/components/effects/index.ts +2 -0
  192. package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
  193. package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
  194. package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
  195. package/carbon/src/client/components/scroll/index.ts +2 -0
  196. package/carbon/src/client/components/ui/accordion.tsx +71 -0
  197. package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
  198. package/carbon/src/client/components/ui/alert.tsx +73 -0
  199. package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
  200. package/carbon/src/client/components/ui/avatar.tsx +91 -0
  201. package/carbon/src/client/components/ui/badge.tsx +50 -0
  202. package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
  203. package/carbon/src/client/components/ui/button-group.tsx +78 -0
  204. package/carbon/src/client/components/ui/button.tsx +120 -0
  205. package/carbon/src/client/components/ui/calendar.tsx +182 -0
  206. package/carbon/src/client/components/ui/card.tsx +85 -0
  207. package/carbon/src/client/components/ui/carousel.tsx +227 -0
  208. package/carbon/src/client/components/ui/chart.tsx +357 -0
  209. package/carbon/src/client/components/ui/checkbox.tsx +27 -0
  210. package/carbon/src/client/components/ui/collapsible.tsx +15 -0
  211. package/carbon/src/client/components/ui/command.tsx +178 -0
  212. package/carbon/src/client/components/ui/context-menu.tsx +233 -0
  213. package/carbon/src/client/components/ui/dialog.tsx +132 -0
  214. package/carbon/src/client/components/ui/drawer.tsx +118 -0
  215. package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
  216. package/carbon/src/client/components/ui/empty.tsx +94 -0
  217. package/carbon/src/client/components/ui/field.tsx +226 -0
  218. package/carbon/src/client/components/ui/hover-card.tsx +44 -0
  219. package/carbon/src/client/components/ui/input-group.tsx +146 -0
  220. package/carbon/src/client/components/ui/input-otp.tsx +83 -0
  221. package/carbon/src/client/components/ui/input.tsx +20 -0
  222. package/carbon/src/client/components/ui/item.tsx +188 -0
  223. package/carbon/src/client/components/ui/kbd.tsx +26 -0
  224. package/carbon/src/client/components/ui/label.tsx +21 -0
  225. package/carbon/src/client/components/ui/menubar.tsx +250 -0
  226. package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
  227. package/carbon/src/client/components/ui/pagination.tsx +102 -0
  228. package/carbon/src/client/components/ui/popover.tsx +75 -0
  229. package/carbon/src/client/components/ui/progress.tsx +66 -0
  230. package/carbon/src/client/components/ui/radio-group.tsx +36 -0
  231. package/carbon/src/client/components/ui/resizable.tsx +46 -0
  232. package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
  233. package/carbon/src/client/components/ui/select.tsx +186 -0
  234. package/carbon/src/client/components/ui/separator.tsx +21 -0
  235. package/carbon/src/client/components/ui/sheet.tsx +124 -0
  236. package/carbon/src/client/components/ui/sidebar.tsx +702 -0
  237. package/carbon/src/client/components/ui/skeleton.tsx +13 -0
  238. package/carbon/src/client/components/ui/slider.tsx +57 -0
  239. package/carbon/src/client/components/ui/sonner.tsx +45 -0
  240. package/carbon/src/client/components/ui/spinner.tsx +15 -0
  241. package/carbon/src/client/components/ui/switch.tsx +30 -0
  242. package/carbon/src/client/components/ui/table.tsx +89 -0
  243. package/carbon/src/client/components/ui/tabs.tsx +73 -0
  244. package/carbon/src/client/components/ui/textarea.tsx +18 -0
  245. package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
  246. package/carbon/src/client/components/ui/toggle.tsx +44 -0
  247. package/carbon/src/client/components/ui/tooltip.tsx +56 -0
  248. package/carbon/src/client/hooks/api/index.ts +14 -0
  249. package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
  250. package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
  251. package/carbon/src/client/hooks/use-mobile.ts +21 -0
  252. package/carbon/src/client/hooks/useMousePosition.ts +91 -0
  253. package/carbon/src/client/hooks/useNotifications.tsx +124 -0
  254. package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
  255. package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
  256. package/carbon/src/client/hooks/usePackageManager.ts +69 -0
  257. package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
  258. package/carbon/src/client/hooks/useRunningServices.ts +114 -0
  259. package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
  260. package/carbon/src/client/index.css +467 -0
  261. package/carbon/src/client/index.html +56 -0
  262. package/carbon/src/client/lib/admin-services.ts +151 -0
  263. package/carbon/src/client/lib/api.ts +32 -0
  264. package/carbon/src/client/lib/blog.ts +35 -0
  265. package/carbon/src/client/lib/changelog.ts +33 -0
  266. package/carbon/src/client/lib/docs-search.ts +101 -0
  267. package/carbon/src/client/lib/docs.ts +37 -0
  268. package/carbon/src/client/lib/i18n.ts +32 -0
  269. package/carbon/src/client/lib/supabase.ts +72 -0
  270. package/carbon/src/client/lib/tailwind-colors.ts +357 -0
  271. package/carbon/src/client/lib/theme.ts +117 -0
  272. package/carbon/src/client/lib/utils.ts +22 -0
  273. package/carbon/src/client/locales/de.json +529 -0
  274. package/carbon/src/client/locales/en.json +461 -0
  275. package/carbon/src/client/locales/es.json +529 -0
  276. package/carbon/src/client/locales/fr.json +529 -0
  277. package/carbon/src/client/locales/pt.json +529 -0
  278. package/carbon/src/client/main.tsx +56 -0
  279. package/carbon/src/client/mdx.d.ts +13 -0
  280. package/carbon/src/client/pages/ApiDocs.tsx +76 -0
  281. package/carbon/src/client/pages/AuthCallback.tsx +34 -0
  282. package/carbon/src/client/pages/Blog.tsx +167 -0
  283. package/carbon/src/client/pages/Changelog.tsx +171 -0
  284. package/carbon/src/client/pages/Charts.tsx +388 -0
  285. package/carbon/src/client/pages/Checkout.tsx +227 -0
  286. package/carbon/src/client/pages/Contact.tsx +174 -0
  287. package/carbon/src/client/pages/Dashboard.tsx +368 -0
  288. package/carbon/src/client/pages/Docs.tsx +372 -0
  289. package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
  290. package/carbon/src/client/pages/Home.tsx +187 -0
  291. package/carbon/src/client/pages/Legal.tsx +100 -0
  292. package/carbon/src/client/pages/Login.tsx +408 -0
  293. package/carbon/src/client/pages/MFAVerify.tsx +156 -0
  294. package/carbon/src/client/pages/NotFound.tsx +21 -0
  295. package/carbon/src/client/pages/Onboarding.tsx +246 -0
  296. package/carbon/src/client/pages/ResetPassword.tsx +200 -0
  297. package/carbon/src/client/pages/UIComponents.tsx +390 -0
  298. package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
  299. package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
  300. package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
  301. package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
  302. package/carbon/src/client/pages/admin/Logs.tsx +18 -0
  303. package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
  304. package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
  305. package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
  306. package/carbon/src/client/pages/admin/Settings.tsx +314 -0
  307. package/carbon/src/client/pages/admin/Theme.tsx +465 -0
  308. package/carbon/src/client/pages/admin/Users.tsx +365 -0
  309. package/carbon/src/client/pages/api-docs.css +156 -0
  310. package/carbon/src/client/pages/organizations/Details.tsx +200 -0
  311. package/carbon/src/client/pages/organizations/Members.tsx +402 -0
  312. package/carbon/src/client/pages/settings/Billing.tsx +473 -0
  313. package/carbon/src/client/pages/settings/Profile.tsx +160 -0
  314. package/carbon/src/client/pages/settings/Security.tsx +341 -0
  315. package/carbon/src/client/public/favicon.svg +19 -0
  316. package/carbon/src/client/public/robots.txt +8 -0
  317. package/carbon/src/server/billing/index.ts +104 -0
  318. package/carbon/src/server/billing/provider.ts +81 -0
  319. package/carbon/src/server/billing/providers/paddle.ts +314 -0
  320. package/carbon/src/server/billing/providers/polar.ts +325 -0
  321. package/carbon/src/server/billing/providers/stripe.ts +233 -0
  322. package/carbon/src/server/emails/templates.ts +116 -0
  323. package/carbon/src/server/index.ts +554 -0
  324. package/carbon/src/server/lib/auth.ts +6 -0
  325. package/carbon/src/server/lib/email.ts +64 -0
  326. package/carbon/src/server/lib/env.ts +112 -0
  327. package/carbon/src/server/lib/errors.ts +21 -0
  328. package/carbon/src/server/lib/logger.ts +17 -0
  329. package/carbon/src/server/lib/rate-limiter.ts +288 -0
  330. package/carbon/src/server/lib/request.ts +34 -0
  331. package/carbon/src/server/lib/stripe.ts +42 -0
  332. package/carbon/src/server/lib/supabase.ts +65 -0
  333. package/carbon/src/server/middleware/requirePlan.ts +80 -0
  334. package/carbon/src/server/routes/_internal/services-status.ts +958 -0
  335. package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
  336. package/carbon/src/server/routes/health.ts +48 -0
  337. package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
  338. package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
  339. package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
  340. package/carbon/src/server/routes/v1/auth.ts +390 -0
  341. package/carbon/src/server/routes/v1/billing.ts +718 -0
  342. package/carbon/src/server/routes/v1/contact.ts +93 -0
  343. package/carbon/src/server/routes/v1/index.ts +1333 -0
  344. package/carbon/src/server/routes/v1/newsletter.ts +181 -0
  345. package/carbon/src/server/routes/v1/performance.ts +157 -0
  346. package/carbon/src/server/routes/v1/stats.ts +170 -0
  347. package/carbon/src/server/routes/v1/theme.ts +106 -0
  348. package/carbon/src/server/routes/webhooks/billing.ts +376 -0
  349. package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
  350. package/carbon/src/server/types.ts +11 -0
  351. package/carbon/src/shared/pricing.ts +155 -0
  352. package/carbon/src/shared/types.ts +338 -0
  353. package/carbon/supabase/migrations/00001_init.sql +717 -0
  354. package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
  355. package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
  356. package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
  357. package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
  358. package/carbon/supabase/seed.sql +16 -0
  359. package/carbon/tests/_helpers/app.ts +45 -0
  360. package/carbon/tests/_helpers/env.ts +37 -0
  361. package/carbon/tests/_helpers/factories.ts +69 -0
  362. package/carbon/tests/_helpers/jwt.ts +23 -0
  363. package/carbon/tests/_helpers/setup-integration.ts +20 -0
  364. package/carbon/tests/_helpers/setup-rtl.ts +12 -0
  365. package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
  366. package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
  367. package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
  368. package/carbon/tests/integration/server/routes/health.test.ts +61 -0
  369. package/carbon/tests/structural/i18n-parity.test.ts +42 -0
  370. package/carbon/tests/unit/client/utils.test.ts +49 -0
  371. package/carbon/tests/unit/shared/pricing.test.ts +93 -0
  372. package/carbon/tsconfig.json +27 -0
  373. package/carbon/tsconfig.server.json +27 -0
  374. package/carbon/tsconfig.test.json +9 -0
  375. package/carbon/vite.config.ts +110 -0
  376. package/carbon/vitest.config.ts +74 -0
  377. package/carbon/volumes/db/jwt.sql +57 -0
  378. package/carbon/volumes/db/metabase-init.sh +29 -0
  379. package/carbon/volumes/db/n8n-init.sh +25 -0
  380. package/carbon/volumes/db/realtime.sql +33 -0
  381. package/carbon/volumes/db/roles.sql +93 -0
  382. package/carbon/volumes/db/set-passwords.sh +12 -0
  383. package/carbon/volumes/db/super-admin.dev.sql +113 -0
  384. package/carbon/volumes/db/super-admin.generated.sql +113 -0
  385. package/carbon/volumes/db/super-admin.sql +114 -0
  386. package/carbon/volumes/grafana/dashboards/logs.json +179 -0
  387. package/carbon/volumes/grafana/dashboards/overview.json +523 -0
  388. package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
  389. package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
  390. package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
  391. package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
  392. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
  393. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
  394. package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
  395. package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
  396. package/carbon/volumes/kong/kong.yml +208 -0
  397. package/carbon/volumes/loki/loki-config.yml +58 -0
  398. package/carbon/volumes/n8n/hooks.js +131 -0
  399. package/carbon/volumes/n8n/scripts/setup.sh +66 -0
  400. package/carbon/volumes/prometheus/prometheus.yml +43 -0
  401. package/carbon/volumes/promtail/promtail-config.yml +64 -0
  402. package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
  403. package/carbon/volumes/traefik/middlewares.yml +95 -0
  404. package/carbon/volumes/traefik/vite-dev.yml +20 -0
  405. package/package.json +95 -0
  406. package/src/access.js +354 -0
  407. package/src/activate.js +187 -0
  408. package/src/add.js +718 -0
  409. package/src/backup.js +786 -0
  410. package/src/cli.js +350 -0
  411. package/src/configure.js +967 -0
  412. package/src/console.js +155 -0
  413. package/src/create.js +1499 -0
  414. package/src/deploy.js +311 -0
  415. package/src/destroy.js +2033 -0
  416. package/src/diagnose.js +735 -0
  417. package/src/down.js +80 -0
  418. package/src/failover.js +1032 -0
  419. package/src/lib/backup-s3.js +179 -0
  420. package/src/lib/build.js +33 -0
  421. package/src/lib/checksum.js +28 -0
  422. package/src/lib/ci-setup.js +666 -0
  423. package/src/lib/cli/help.js +129 -0
  424. package/src/lib/cli/parse-flags.js +160 -0
  425. package/src/lib/cli/select-action.js +65 -0
  426. package/src/lib/cli/select-environment.js +108 -0
  427. package/src/lib/cli/tty-guard.js +75 -0
  428. package/src/lib/cloudflare.js +447 -0
  429. package/src/lib/colors.js +52 -0
  430. package/src/lib/command.js +361 -0
  431. package/src/lib/config.js +359 -0
  432. package/src/lib/cost.js +103 -0
  433. package/src/lib/deploy/bundle.js +231 -0
  434. package/src/lib/deploy/compose/acme-verify.js +121 -0
  435. package/src/lib/deploy/compose/build-args.js +78 -0
  436. package/src/lib/deploy/compose/ha.js +1874 -0
  437. package/src/lib/deploy/compose/index.js +1294 -0
  438. package/src/lib/deploy/compose/reconcile.js +74 -0
  439. package/src/lib/deploy/github.js +382 -0
  440. package/src/lib/deploy/image.js +191 -0
  441. package/src/lib/deploy/index.js +119 -0
  442. package/src/lib/deploy/k8s/LICENSE +99 -0
  443. package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
  444. package/src/lib/deploy/k8s/ha/index.js +1000 -0
  445. package/src/lib/deploy/k8s/index.js +62 -0
  446. package/src/lib/deploy/k8s/k3s.js +2515 -0
  447. package/src/lib/deploy/orchestrator.js +1401 -0
  448. package/src/lib/deploy/prompts.js +545 -0
  449. package/src/lib/deploy/remote-build.js +130 -0
  450. package/src/lib/deploy/state.js +120 -0
  451. package/src/lib/deploy/utils.js +328 -0
  452. package/src/lib/deploy-logger.js +93 -0
  453. package/src/lib/dns-propagation.js +48 -0
  454. package/src/lib/environment.js +58 -0
  455. package/src/lib/fetch-retry.js +103 -0
  456. package/src/lib/github-environments.js +335 -0
  457. package/src/lib/hetzner-dns.js +377 -0
  458. package/src/lib/hetzner-guided-setup.js +275 -0
  459. package/src/lib/host-keys.js +37 -0
  460. package/src/lib/iac/cloud-init.js +52 -0
  461. package/src/lib/iac/index.js +325 -0
  462. package/src/lib/iac/programs/hetzner-compose.js +130 -0
  463. package/src/lib/iac/programs/hetzner-k8s.js +320 -0
  464. package/src/lib/kubectl.js +81 -0
  465. package/src/lib/licensing/index.js +259 -0
  466. package/src/lib/licensing/tiers.js +181 -0
  467. package/src/lib/licensing/validator.js +171 -0
  468. package/src/lib/merge-package-json.js +90 -0
  469. package/src/lib/operator-ip.js +381 -0
  470. package/src/lib/package-manager.js +111 -0
  471. package/src/lib/perf.js +71 -0
  472. package/src/lib/project-guard.js +39 -0
  473. package/src/lib/project.js +334 -0
  474. package/src/lib/providers/base.js +276 -0
  475. package/src/lib/providers/hetzner-s3.js +656 -0
  476. package/src/lib/providers/hetzner.js +755 -0
  477. package/src/lib/providers/index.js +164 -0
  478. package/src/lib/s3.js +510 -0
  479. package/src/lib/secret-scan.js +583 -0
  480. package/src/lib/secrets.js +63 -0
  481. package/src/lib/server-types.js +195 -0
  482. package/src/lib/shell.js +91 -0
  483. package/src/lib/ssh.js +241 -0
  484. package/src/lib/tracker.js +242 -0
  485. package/src/lib/upgrade-policy.js +170 -0
  486. package/src/lib/validators.js +105 -0
  487. package/src/lib/version.js +5 -0
  488. package/src/remove.js +292 -0
  489. package/src/reset.js +97 -0
  490. package/src/restore.js +871 -0
  491. package/src/scale.js +1734 -0
  492. package/src/shell.js +222 -0
  493. package/src/status.js +981 -0
  494. package/src/up.js +264 -0
  495. package/src/upgrade.js +721 -0
@@ -0,0 +1,1874 @@
1
+ /**
2
+ * Docker Compose HA Deployment Module
3
+ *
4
+ * Deploys Vibecarbon to 2 VPS in different regions with:
5
+ * - Docker Compose on both servers
6
+ * - PostgreSQL streaming replication (primary → standby)
7
+ * - Cloudflare health checks + failover (or manual DNS instructions)
8
+ *
9
+ * Available on Diamond and Fullerene tiers.
10
+ */
11
+
12
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import * as p from '@clack/prompts';
15
+ import { setupHA as setupCloudflareHA } from '../../cloudflare.js';
16
+ import { c } from '../../colors.js';
17
+ import { runCommand } from '../../command.js';
18
+ import {
19
+ loadCredentials,
20
+ loadProjectConfig,
21
+ registerProject,
22
+ saveProjectConfig,
23
+ } from '../../config.js';
24
+ import { fetchWithRetry } from '../../fetch-retry.js';
25
+ import { perfAsync } from '../../perf.js';
26
+ import { getProvider } from '../../providers/index.js';
27
+ import {
28
+ createAdminUser,
29
+ dockerLoginOnServer,
30
+ isLocalOnlyImageTag,
31
+ pullComposeImages,
32
+ runMigrations,
33
+ setupServer,
34
+ setupServerFiles,
35
+ sshRunAsync,
36
+ startComposeStack,
37
+ waitForSSH,
38
+ } from './index.js';
39
+
40
+ const SSH_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes';
41
+
42
+ /**
43
+ * Run a command on a remote server via SSH
44
+ */
45
+ function sshRun(ip, sshKeyPath, command, options = {}) {
46
+ const { timeout = 120_000 } = options;
47
+ return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
48
+ stdio: 'pipe',
49
+ timeout,
50
+ encoding: 'utf-8',
51
+ ...options,
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Look up a Hetzner firewall by exact name and DELETE it. Polls afterwards
57
+ * until the GET returns nothing (firewall actually gone) or the deadline
58
+ * lapses. Uses the `?name=<exact>` filter so the listing is one row deep
59
+ * — pagination (Hetzner default per_page=25) was the original bug: when
60
+ * 25+ leaked firewalls accumulated in the project, the project's firewalls
61
+ * weren't on page 1, the destroy filter found nothing, and the next deploy
62
+ * collided on "name is already used (uniqueness_error)".
63
+ *
64
+ * Returns true if the firewall was deleted (or never existed), false on
65
+ * timeout. Non-throwing — destroy paths swallow the failure but it's
66
+ * surfaced to the caller for verification.
67
+ */
68
+ /**
69
+ * Resolve the VPS set that destroyComposeHA should delete.
70
+ *
71
+ * envConfig.servers is the canonical source when deploy completed and wrote
72
+ * it back. But when deploy gets canceled after Pulumi creates the VMs and
73
+ * before envConfig is persisted (the 2026-05-16 compose-ha matrix stall at
74
+ * "Waiting for primary services to initialize"), .vibecarbon.json never
75
+ * gets the IDs — destroy then sees `servers: []`, deletes nothing, and the
76
+ * test runner's orphan sweep has to mop up the leak.
77
+ *
78
+ * Fall back to name-based discovery: the Pulumi program names servers
79
+ * deterministically as `${projectName}-${environment}-(primary|standby)`,
80
+ * so a fresh Hetzner lookup recovers what envConfig is missing. Mirrors
81
+ * the pattern already used for firewall cleanup below.
82
+ *
83
+ * Exported for unit-test access; in production it's called only from
84
+ * destroyComposeHA.
85
+ *
86
+ * @param {object} args
87
+ * @param {string} args.projectName
88
+ * @param {string} args.environment
89
+ * @param {object} args.envConfig
90
+ * @param {string|null} args.hetznerToken
91
+ * @param {object} args.hetznerHeaders
92
+ * @returns {Promise<Array<{id: number|string, name?: string, hetznerName?: string}>>}
93
+ */
94
+ export async function resolveHaServers({
95
+ projectName,
96
+ environment,
97
+ envConfig,
98
+ hetznerToken,
99
+ hetznerHeaders,
100
+ }) {
101
+ const expectedNames = [
102
+ `${projectName}-${environment}-primary`,
103
+ `${projectName}-${environment}-standby`,
104
+ ];
105
+
106
+ const knownById = new Map(
107
+ (envConfig?.servers || []).filter((s) => s?.id && hetznerToken).map((s) => [String(s.id), s]),
108
+ );
109
+
110
+ if (!hetznerToken) return Array.from(knownById.values());
111
+
112
+ for (const name of expectedNames) {
113
+ const alreadyKnown = [...knownById.values()].some(
114
+ (s) => s.hetznerName === name || s.name === name,
115
+ );
116
+ if (alreadyKnown) continue;
117
+ try {
118
+ const resp = await fetchWithRetry(
119
+ `https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(name)}`,
120
+ { headers: hetznerHeaders },
121
+ );
122
+ const { servers } = await resp.json();
123
+ for (const found of servers || []) {
124
+ knownById.set(String(found.id), { id: found.id, name, hetznerName: name });
125
+ }
126
+ } catch {
127
+ // Discovery is best-effort — keep moving with whatever envConfig had.
128
+ }
129
+ }
130
+
131
+ return Array.from(knownById.values());
132
+ }
133
+
134
+ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
135
+ const lookupUrl = `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(name)}`;
136
+ const start = Date.now();
137
+ while (Date.now() - start < deadlineMs) {
138
+ const listResp = await fetchWithRetry(lookupUrl, { headers: hetznerHeaders });
139
+ const { firewalls } = await listResp.json();
140
+ const fw = firewalls?.[0];
141
+ if (!fw) return true;
142
+ if (fw.applied_to?.length > 0) {
143
+ const serverResources = fw.applied_to
144
+ .filter((a) => a.type === 'server' && a.server?.id)
145
+ .map((a) => ({ type: 'server', server: { id: a.server.id } }));
146
+ if (serverResources.length > 0) {
147
+ try {
148
+ await fetchWithRetry(
149
+ `https://api.hetzner.cloud/v1/firewalls/${fw.id}/actions/remove_from_resources`,
150
+ {
151
+ method: 'POST',
152
+ headers: { ...hetznerHeaders, 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({ remove_from: serverResources }),
154
+ },
155
+ );
156
+ } catch {
157
+ // Servers may already be gone — proceed to DELETE anyway
158
+ }
159
+ }
160
+ }
161
+ const delResp = await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${fw.id}`, {
162
+ method: 'DELETE',
163
+ headers: hetznerHeaders,
164
+ });
165
+ if (delResp.ok || delResp.status === 404) return true;
166
+ // 409 = still attached; back off and retry the whole lookup so we get
167
+ // a fresh applied_to list (servers may have finished detaching).
168
+ await new Promise((r) => setTimeout(r, 3_000));
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Read REPL_PASSWORD — process.env first (CI may export it), then .env.local
175
+ * (where `vibecarbon create` writes it at project-init time). Every HA code
176
+ * path funnels through this so one fix handles all three call sites that
177
+ * previously threw `REPL_PASSWORD is not set` when run without the env var.
178
+ * Returns null if the value is not present anywhere.
179
+ */
180
+ function readReplPassword(cwd = process.cwd()) {
181
+ if (process.env.REPL_PASSWORD) return process.env.REPL_PASSWORD;
182
+ const envLocalPath = join(cwd, '.env.local');
183
+ if (!existsSync(envLocalPath)) return null;
184
+ const content = readFileSync(envLocalPath, 'utf-8');
185
+ // Accept both double-quoted (machine secrets) and single-quoted
186
+ // (escapeDotenv'd user secrets) forms, matching create.js output.
187
+ const m =
188
+ content.match(/^REPL_PASSWORD="([^"]+)"/m) || content.match(/^REPL_PASSWORD='([^']+)'/m);
189
+ return m ? m[1] : null;
190
+ }
191
+
192
+ /**
193
+ * Generate SSH key pair for deployment
194
+ */
195
+ function generateSSHKeyPair(sshKeyPath) {
196
+ if (!existsSync(sshKeyPath)) {
197
+ runCommand(
198
+ ['ssh-keygen', '-t', 'ed25519', '-f', sshKeyPath, '-N', '', '-C', 'vibecarbon-deploy'],
199
+ { stdio: 'pipe' },
200
+ );
201
+ }
202
+ }
203
+
204
+ // ============================================================================
205
+ // POSTGRESQL STREAMING REPLICATION SETUP
206
+ // ============================================================================
207
+
208
+ /**
209
+ * Configure the primary PostgreSQL server for streaming replication.
210
+ *
211
+ * 1. Create a dedicated replication user
212
+ * 2. Allow the standby IP in pg_hba.conf
213
+ * 3. Set wal_level=replica and max_wal_senders
214
+ * 4. Restart PostgreSQL to apply changes
215
+ */
216
+ function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
217
+ const remoteDir = `/opt/${projectName}`;
218
+ // replPassword is generated at create time from crypto.randomBytes and is
219
+ // restricted to base64url characters — no shell escaping needed.
220
+ // Read from process.env first (CI), then .env.local (the `vibecarbon create`
221
+ // default write target). Without the .env.local fallback, HA deploys fail
222
+ // the moment the CLI is invoked without the env var pre-exported.
223
+ const replPassword = readReplPassword();
224
+ if (!replPassword) {
225
+ throw new Error(
226
+ 'REPL_PASSWORD is not set in process.env or .env.local. Regenerate your .env.local or run `vibecarbon configure` — HA deploys require a replication password generated at create time.',
227
+ );
228
+ }
229
+
230
+ // Create or update replication user + physical replication slot.
231
+ //
232
+ // Without a replication slot, the standby's WAL follow position isn't
233
+ // persisted on the primary — if the standby falls behind by more than
234
+ // `wal_keep_size` (512MB), primary recycles WAL segments and streaming
235
+ // breaks silently. The standby keeps appearing "up" but stops receiving
236
+ // updates. A physical slot (vibecarbon_standby_slot) tells primary to
237
+ // hold WAL until the standby confirms receipt, closing the drift
238
+ // window.
239
+ //
240
+ // The SQL is base64-encoded so the password never appears in argv —
241
+ // the remote shell only sees the base64 blob.
242
+ const createRoleSql = `
243
+ DO $$
244
+ BEGIN
245
+ IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'replicator') THEN
246
+ CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '${replPassword}';
247
+ ELSE
248
+ ALTER ROLE replicator WITH PASSWORD '${replPassword}';
249
+ END IF;
250
+ END $$;
251
+
252
+ -- Create the physical replication slot if missing. pg_create_physical_replication_slot
253
+ -- returns a row when successful, so wrap in a DO block that swallows the
254
+ -- 'already exists' error for idempotent reruns.
255
+ DO $$
256
+ BEGIN
257
+ IF NOT EXISTS (
258
+ SELECT 1 FROM pg_replication_slots WHERE slot_name = 'vibecarbon_standby_slot'
259
+ ) THEN
260
+ PERFORM pg_create_physical_replication_slot('vibecarbon_standby_slot');
261
+ END IF;
262
+ END $$;
263
+ `;
264
+ const createRoleB64 = Buffer.from(createRoleSql).toString('base64');
265
+ sshRun(
266
+ primaryIp,
267
+ sshKeyPath,
268
+ `cd ${remoteDir} && echo ${createRoleB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
269
+ { timeout: 30_000 },
270
+ );
271
+
272
+ // Update pg_hba.conf to allow replication from standby. The supabase/postgres
273
+ // image overrides hba_file to /etc/postgresql/pg_hba.conf (NOT the default
274
+ // /var/lib/postgresql/data/pg_hba.conf). RCA: compose-ha 2026-05-01 fanout12
275
+ // — appended to the wrong file, postgres never saw the rule, every
276
+ // replication connection was rejected with "no pg_hba.conf entry" while
277
+ // pg_hba_file_rules reflected the actual /etc loaded contents (no replicator
278
+ // entry). Resolve the path at runtime via SHOW hba_file so we don't break
279
+ // again if the image moves the file.
280
+ sshRun(
281
+ primaryIp,
282
+ sshKeyPath,
283
+ `cd ${remoteDir} && HBA_FILE=$(docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SHOW hba_file;' | tr -d '[:space:]') && ` +
284
+ `docker compose exec -T db sh -c "echo 'host replication replicator ${standbyIp}/32 md5' >> $HBA_FILE && ` +
285
+ `psql -U supabase_admin -d postgres -tAc 'SELECT pg_reload_conf();'"`,
286
+ { timeout: 15_000 },
287
+ );
288
+
289
+ // Set WAL settings for replication
290
+ sshRun(
291
+ primaryIp,
292
+ sshKeyPath,
293
+ `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_level = 'replica';" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET max_wal_senders = 5;" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_keep_size = '512MB';"`,
294
+ { timeout: 30_000 },
295
+ );
296
+
297
+ // Restart PostgreSQL to apply WAL changes
298
+ sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
299
+ timeout: 60_000,
300
+ });
301
+
302
+ // Wait for PostgreSQL to be ready again
303
+ for (let i = 0; i < 15; i++) {
304
+ try {
305
+ sshRun(
306
+ primaryIp,
307
+ sshKeyPath,
308
+ `cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres`,
309
+ { timeout: 10_000 },
310
+ );
311
+ return;
312
+ } catch {
313
+ // Retry
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Configure the standby PostgreSQL server as a hot standby replica.
320
+ *
321
+ * 1. Stop the standby database
322
+ * 2. Wipe the data directory and run pg_basebackup from the primary
323
+ * 3. Write standby.signal and configure primary_conninfo
324
+ * 4. Restart as hot standby
325
+ */
326
+ async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
327
+ const remoteDir = `/opt/${projectName}`;
328
+ // replPassword is generated at create time from crypto.randomBytes and is
329
+ // restricted to base64url characters — no shell escaping needed.
330
+ // Read from process.env first (CI), then .env.local (the `vibecarbon create`
331
+ // default write target). Without the .env.local fallback, HA deploys fail
332
+ // the moment the CLI is invoked without the env var pre-exported.
333
+ const replPassword = readReplPassword();
334
+ if (!replPassword) {
335
+ throw new Error(
336
+ 'REPL_PASSWORD is not set in process.env or .env.local. Regenerate your .env.local or run `vibecarbon configure` — HA deploys require a replication password generated at create time.',
337
+ );
338
+ }
339
+
340
+ // Stop the standby database
341
+ sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
342
+ timeout: 60_000,
343
+ });
344
+
345
+ // Run pg_basebackup from primary into standby's data directory.
346
+ //
347
+ // -Fp: plain format (writes directly to PGDATA)
348
+ // -Xs: stream WAL during backup
349
+ // -R: auto-create standby.signal and write primary_conninfo to postgresql.auto.conf
350
+ // -C --slot: use the physical replication slot created by
351
+ // configurePrimaryReplication. This makes the primary hold
352
+ // WAL for this standby specifically — so streaming stays
353
+ // caught up indefinitely instead of silently falling behind
354
+ // when wal_keep_size runs out.
355
+ //
356
+ // After pg_basebackup, append `primary_slot_name` to postgresql.auto.conf
357
+ // so the continuous-streaming connection (after initial backup) also uses
358
+ // the slot. `-R` writes primary_conninfo but not primary_slot_name.
359
+ //
360
+ // `set -e -o pipefail` is load-bearing: without it, a failed
361
+ // pg_basebackup leaves the data dir with only `postgresql.auto.conf`
362
+ // (because the trailing `>>` succeeds creating it from scratch), no
363
+ // `PG_VERSION`. The next `docker compose start db` then loops forever
364
+ // on `initdb: directory exists but is not empty` (RCA from compose-ha
365
+ // failover failure 2026-04-30). With `set -e`, basebackup failure
366
+ // exits the script non-zero, sshRun throws, the caller can react.
367
+ const basebackupScript = `
368
+ set -e -o pipefail
369
+ rm -rf /var/lib/postgresql/data/*
370
+ PGPASSWORD='${replPassword}' pg_basebackup -h ${primaryIp} -p 5433 -U replicator -D /var/lib/postgresql/data -Fp -Xs -P -R -S vibecarbon_standby_slot -v
371
+ # pg_basebackup -R is supposed to (a) create standby.signal and (b)
372
+ # write primary_conninfo to postgresql.auto.conf. RCA from compose-ha
373
+ # fanout9 2026-05-01: in supabase/postgres:15.8.1.085, neither happened
374
+ # — postgresql.auto.conf came back at 88 bytes (just the ALTER SYSTEM
375
+ # header) and standby.signal was missing. Postgres on next start saw
376
+ # no standby.signal, came up as PRIMARY, started archiving WAL via
377
+ # wal-g, and pg_is_in_recovery() returned 'f' for the entire 60s probe
378
+ # window. Write everything ourselves — don't trust -R.
379
+ touch /var/lib/postgresql/data/standby.signal
380
+ # Replace postgresql.auto.conf wholesale (don't append — the upstream
381
+ # image's first start can ALTER SYSTEM RESET ALL and wipe whatever -R
382
+ # claimed to write). Both primary_conninfo and primary_slot_name are
383
+ # load-bearing: missing primary_conninfo means recovery has nowhere
384
+ # to read WAL from, missing primary_slot_name means primary won't
385
+ # hold WAL for this standby and streaming silently falls behind.
386
+ cat > /var/lib/postgresql/data/postgresql.auto.conf <<EOF
387
+ # Do not edit this file manually!
388
+ # It will be overwritten by the ALTER SYSTEM command.
389
+ primary_conninfo = 'host=${primaryIp} port=5433 user=replicator password=${replPassword} application_name=standby'
390
+ primary_slot_name = 'vibecarbon_standby_slot'
391
+ EOF
392
+ chown -R postgres:postgres /var/lib/postgresql/data
393
+ # Final sanity checks. Each one is the postcondition for a different
394
+ # downstream failure mode: PG_VERSION → docker compose start initdb
395
+ # loop; standby.signal → postgres comes up as primary; primary_conninfo
396
+ # in auto.conf → recovery has no source.
397
+ test -f /var/lib/postgresql/data/PG_VERSION
398
+ test -f /var/lib/postgresql/data/standby.signal
399
+ grep -q '^primary_conninfo' /var/lib/postgresql/data/postgresql.auto.conf
400
+ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
401
+ `;
402
+ const basebackupB64 = Buffer.from(basebackupScript).toString('base64');
403
+ try {
404
+ sshRun(
405
+ standbyIp,
406
+ sshKeyPath,
407
+ // Use bash, not sh: the supabase/postgres image symlinks /bin/sh →
408
+ // dash, which rejects `set -o pipefail` with "Illegal option" and
409
+ // — because the offending line *is* the one that activates `set -e`
410
+ // — silently neuters every subsequent failure check (chown, grep,
411
+ // test). The basebackup itself still succeeds, but the "verify
412
+ // recovery conf is in place" sanity checks become no-ops, and a
413
+ // missing standby.signal / wiped postgresql.auto.conf goes
414
+ // undetected until pg_is_in_recovery() returns 'f' an entire
415
+ // basebackup later. RCA: compose-ha 2026-05-01 fanout10 failover.
416
+ `cd ${remoteDir} && echo ${basebackupB64} | base64 -d | docker compose run --rm -T --entrypoint bash db 2>&1`,
417
+ // silent:true makes sshRun THROW on non-zero exit instead of returning
418
+ // false. Without it, a failed basebackup (e.g. pg_hba.conf rejecting
419
+ // the connection) is silently swallowed: try-catch never enters, code
420
+ // proceeds to docker compose start db, probe loop times out, and the
421
+ // operator sees "did not enter recovery mode within 60s" with no clue
422
+ // that pg_basebackup never even ran. RCA: fanout12 2026-05-01.
423
+ { timeout: 300_000, silent: true },
424
+ );
425
+ } catch (err) {
426
+ // Surface the actual basebackup output. sshRun on failure attaches
427
+ // stdout to the error; without re-raising it deliberately the caller
428
+ // only sees "Command failed" and has to ssh in to root-cause.
429
+ const detail = err.stdout?.toString?.()?.trim?.() || err.message;
430
+ throw new Error(
431
+ `configureStandbyReplication: pg_basebackup failed on ${standbyIp}. ` +
432
+ `The standby data dir may now be in an inconsistent state ` +
433
+ `(non-empty without PG_VERSION) and require manual recovery.\n` +
434
+ `--- pg_basebackup output ---\n${detail}`,
435
+ );
436
+ }
437
+
438
+ // Start the standby database
439
+ sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose start db`, {
440
+ timeout: 60_000,
441
+ });
442
+
443
+ // Verify the standby is in recovery mode.
444
+ //
445
+ // Two independent signals — either is sufficient:
446
+ // 1. standby's pg_is_in_recovery() returns 't' (requires hot_standby on
447
+ // AND consistent recovery state — can take 60-180s after fresh
448
+ // basebackup of a Supabase cluster, especially under fanout-3 load)
449
+ // 2. primary's pg_stat_replication has a row with client_addr matching
450
+ // this standby AND state in ('streaming','catchup','startup')
451
+ //
452
+ // The standby probe alone is fragile: with hot_standby still applying
453
+ // initial WAL, psql gets "Hot standby mode is disabled / database system
454
+ // is not accepting connections" — sshRun returns false (without
455
+ // silent:true), result becomes empty, the 60s budget burns through, and
456
+ // failover (which calls this function for re-seed) hard-fails with
457
+ // "did not enter recovery mode" even though the standby is fine.
458
+ //
459
+ // RCA: compose-ha 2026-05-01 fanout13 — replication was actively
460
+ // streaming (verifyReplication on the primary saw "Streaming replication
461
+ // active") but failover's re-seed still aborted because the standby's
462
+ // psql probe returned empty for the entire 60s. Bumped the budget to
463
+ // 180s and added the primary-side signal.
464
+ const PROBE_BUDGET_S = 180;
465
+ const PROBE_INTERVAL_S = 3;
466
+ let lastProbeResult = '';
467
+ let lastPrimarySignal = '';
468
+ for (let i = 0; i < PROBE_BUDGET_S / PROBE_INTERVAL_S; i++) {
469
+ try {
470
+ const result = sshRun(
471
+ standbyIp,
472
+ sshKeyPath,
473
+ `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
474
+ { timeout: 10_000 },
475
+ );
476
+ lastProbeResult = typeof result === 'string' ? result.trim() : '';
477
+ if (lastProbeResult === 't') return true;
478
+ } catch {
479
+ // Retry — database may still be applying initial WAL, hot_standby off
480
+ }
481
+ try {
482
+ const primaryResult = sshRun(
483
+ primaryIp,
484
+ sshKeyPath,
485
+ `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT state FROM pg_stat_replication WHERE client_addr = '${standbyIp}'::inet"`,
486
+ { timeout: 10_000 },
487
+ );
488
+ lastPrimarySignal = typeof primaryResult === 'string' ? primaryResult.trim() : '';
489
+ if (
490
+ lastPrimarySignal === 'streaming' ||
491
+ lastPrimarySignal === 'catchup' ||
492
+ lastPrimarySignal === 'startup'
493
+ ) {
494
+ return true;
495
+ }
496
+ } catch {
497
+ // Retry
498
+ }
499
+ await new Promise((r) => setTimeout(r, PROBE_INTERVAL_S * 1000));
500
+ }
501
+
502
+ throw new Error(
503
+ `configureStandbyReplication: standby ${standbyIp} did not enter recovery mode ` +
504
+ `within ${PROBE_BUDGET_S}s after pg_basebackup. Last standby pg_is_in_recovery() = ` +
505
+ `${JSON.stringify(lastProbeResult)}, last primary pg_stat_replication.state for this ` +
506
+ `standby = ${JSON.stringify(lastPrimarySignal)}. SSH in and check: docker compose exec ` +
507
+ `db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()' and ` +
508
+ `ls /var/lib/postgresql/data/standby.signal`,
509
+ );
510
+ }
511
+
512
+ // ============================================================================
513
+ // FIREWALL: OPEN REPLICATION PORT BETWEEN SERVERS
514
+ // ============================================================================
515
+
516
+ /**
517
+ * Open PostgreSQL port (5432) between the two servers via UFW.
518
+ * Only allows traffic from the peer's IP — not from the public internet.
519
+ */
520
+ /**
521
+ * Open the dedicated replication port between HA peers.
522
+ *
523
+ * Port 5433 maps directly to the db container's PostgreSQL (bypassing supavisor
524
+ * on port 5432). This is required because supavisor doesn't support PostgreSQL
525
+ * replication protocol, and pg_basebackup / streaming replication need a raw
526
+ * PostgreSQL connection.
527
+ */
528
+ async function openReplicationPort(ip, peerIp, sshKeyPath) {
529
+ await sshRunAsync(ip, sshKeyPath, `ufw allow from ${peerIp} to any port 5433 proto tcp`, {
530
+ timeout: 15_000,
531
+ });
532
+ }
533
+
534
+ /**
535
+ * Add an `in tcp 5433` rule scoped to the peer's IP on a server's Hetzner Cloud
536
+ * firewall. UFW alone isn't enough — Hetzner Cloud Firewall sits in front of
537
+ * the host and blocks SYN before it ever reaches UFW. Without this, every
538
+ * pg_basebackup attempt from the peer hits a TCP `Connection timed out`,
539
+ * configureStandbyReplication's bash script (with set -e + bash) fails on
540
+ * pg_basebackup, the catch path throws "pg_basebackup failed", but historically
541
+ * the script ran under dash (no `set -e -o pipefail` support) and silently
542
+ * proceeded — so the warning surfaced two stages later as "did not enter
543
+ * recovery mode within 60s" with the standby actually running its first-init
544
+ * primary-mode state. RCA: compose-ha 2026-05-01 fanout11.
545
+ *
546
+ * The Pulumi-managed firewall (src/lib/iac/programs/hetzner-compose.js) only
547
+ * declares 22/80/443 because the standby's IP isn't known at primary stack-up
548
+ * time. Patch in 5433 here, after both stacks are up.
549
+ *
550
+ * Idempotent: skips if a matching rule already exists. Non-fatal on error —
551
+ * the deploy continues with a warning so the operator can iterate the rest of
552
+ * the scenario; HA replication just won't actually work until 5433 is open.
553
+ */
554
+ async function openReplicationPortHetznerFirewall(serverName, peerIp, hetznerToken) {
555
+ if (!hetznerToken) return;
556
+ const firewallName = `${serverName}-firewall`;
557
+ const headers = {
558
+ Authorization: `Bearer ${hetznerToken}`,
559
+ 'Content-Type': 'application/json',
560
+ };
561
+ const fwListRes = await fetchWithRetry(
562
+ `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(firewallName)}`,
563
+ { headers },
564
+ );
565
+ const fwListData = await fwListRes.json();
566
+ const firewall = fwListData.firewalls?.find((fw) => fw.name === firewallName);
567
+ if (!firewall) return;
568
+ const existingRules = firewall.rules || [];
569
+ const peerCidr = `${peerIp}/32`;
570
+ const hasReplRule = existingRules.some(
571
+ (r) => r.port === '5433' && r.source_ips?.includes(peerCidr),
572
+ );
573
+ if (hasReplRule) return;
574
+ const updatedRules = [
575
+ ...existingRules,
576
+ {
577
+ direction: 'in',
578
+ protocol: 'tcp',
579
+ port: '5433',
580
+ source_ips: [peerCidr],
581
+ description: 'PostgreSQL replication from HA peer',
582
+ },
583
+ ];
584
+ await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${firewall.id}/actions/set_rules`, {
585
+ method: 'POST',
586
+ headers,
587
+ body: JSON.stringify({ rules: updatedRules }),
588
+ });
589
+ }
590
+
591
+ /**
592
+ * Check whether the primary server is reachable and its Postgres accepting
593
+ * connections. Short timeout — the failover flow needs to decide in seconds
594
+ * whether to attempt a re-seed or skip it and promote with current state.
595
+ *
596
+ * Returns true only when both the SSH connection works AND Postgres inside
597
+ * the db container reports ready. A firewall-level reachable + Postgres-
598
+ * unhealthy state should be treated the same as unreachable, since a
599
+ * re-seed would stall.
600
+ */
601
+ async function isPrimaryReachable(primaryIp, sshKeyPath, remoteDir) {
602
+ try {
603
+ const result = sshRun(
604
+ primaryIp,
605
+ sshKeyPath,
606
+ `cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres -t 3`,
607
+ { timeout: 10_000 },
608
+ );
609
+ return result?.includes('accepting connections') ?? false;
610
+ } catch {
611
+ return false;
612
+ }
613
+ }
614
+
615
+ // ============================================================================
616
+ // DEPLOY
617
+ // ============================================================================
618
+
619
+ /**
620
+ * Deploy Docker Compose HA (2 VPS, 2 regions, PostgreSQL replication)
621
+ *
622
+ * @param {object} options
623
+ * @param {string} options.projectName
624
+ * @param {string} options.environment
625
+ * @param {string} options.sshKeyPath
626
+ * @param {string} options.domain
627
+ * @param {string} options.region - Primary region
628
+ * @param {string} options.serverType
629
+ * @param {string} options.apiToken - Hetzner API token
630
+ * @param {string} [options.cloudflareApiToken]
631
+ * @param {string} [options.cloudflareZoneId]
632
+ * @param {string} [options.dnsProvider]
633
+ * @param {object} [options.services]
634
+ * @param {object} [options.envConfig] - Existing environment config
635
+ * @param {Function} [options.onProgress]
636
+ */
637
+ export async function deployComposeHA(options) {
638
+ const {
639
+ projectName,
640
+ environment,
641
+ sshKeyPath,
642
+ domain,
643
+ region,
644
+ secondaryRegion: requestedSecondaryRegion,
645
+ serverType = 'cpx21',
646
+ apiToken,
647
+ cloudflareApiToken,
648
+ cloudflareZoneId,
649
+ dnsProvider,
650
+ services = {},
651
+ envConfig = {},
652
+ dockerHubCreds = null,
653
+ ghcrPullCreds = null,
654
+ imageRef,
655
+ s3Config = null,
656
+ backupS3Config = null,
657
+ backupConfig = null,
658
+ bundlePath = null,
659
+ operatorCidrs = [],
660
+ onProgress = () => {},
661
+ } = options;
662
+
663
+ if (!imageRef) {
664
+ throw new Error(
665
+ 'deployComposeHA requires options.imageRef (ghcr.io/<owner>/<repo>:<tag>) — build pipeline changed in Lever 1',
666
+ );
667
+ }
668
+
669
+ const _provider = getProvider('hetzner', apiToken);
670
+
671
+ // Use the caller-specified secondary region, or pick the first region that differs from primary
672
+ const HETZNER_REGIONS = ['nbg1', 'hel1', 'fsn1'];
673
+ const standbyRegion =
674
+ requestedSecondaryRegion || HETZNER_REGIONS.find((r) => r !== region) || 'hel1';
675
+
676
+ // Generate SSH key pair
677
+ generateSSHKeyPair(sshKeyPath);
678
+ const publicKey = readFileSync(`${sshKeyPath}.pub`, 'utf-8').trim();
679
+
680
+ // Upload SSH key to Hetzner once, then share its ID across both Pulumi
681
+ // stacks — otherwise primary + standby race to create the same key and
682
+ // one side hits HTTP 409 "SSH key not unique" (same public-key bytes
683
+ // cannot be registered twice in a Hetzner project, regardless of name).
684
+ const sharedSshKeyName = `${projectName}-${environment}-key`;
685
+ const sharedSshKeyId = String(await _provider.createSSHKey(sharedSshKeyName, publicKey));
686
+
687
+ // Provision both VPS via Pulumi — one stack per cluster, concurrent up().
688
+ // Each stack declares SSH key + firewall + server in a single reconcile, so
689
+ // partial failures on one cluster are cleaned up automatically on re-run.
690
+ const { initBackend, upStack } = await import('../../iac/index.js');
691
+ const { buildHetznerComposeProgram } = await import('../../iac/programs/hetzner-compose.js');
692
+ onProgress(`Provisioning VPS in ${region} and ${standbyRegion}...`);
693
+
694
+ // Pre-initialize the Pulumi S3 backend before launching parallel stack ops.
695
+ // RCA from compose-ha iter-perfwave3 (2026-05-01): destroy deletes the S3
696
+ // bucket; restore re-creates it; the parallel upStack(primary)+upStack(standby)
697
+ // then race to `pulumi stack select` against a freshly-created bucket and
698
+ // both fail in <2s with `could not list bucket: blob (code=NotFound):
699
+ // NoSuchBucket` — Hetzner Ceph hasn't propagated the new bucket for list
700
+ // operations yet. K8s-HA already does this same pre-init for the same reason
701
+ // (src/lib/deploy/k8s/ha/index.js:201-207); compose-HA was missing the call.
702
+ await perfAsync('deploy.ha.compose.initBackend', () =>
703
+ initBackend({ s3Config, hcloudToken: apiToken }),
704
+ );
705
+ const allowedSshIps = operatorCidrs.map((e) => e.cidr);
706
+ const primaryProgram = buildHetznerComposeProgram({
707
+ projectName,
708
+ environment: `${environment}-primary`,
709
+ sshPublicKey: publicKey,
710
+ existingSshKeyId: sharedSshKeyId,
711
+ location: region,
712
+ serverType,
713
+ labels: { 'managed-by': 'vibecarbon', role: 'primary' },
714
+ allowedSshIps,
715
+ });
716
+ const standbyProgram = buildHetznerComposeProgram({
717
+ projectName,
718
+ environment: `${environment}-standby`,
719
+ sshPublicKey: publicKey,
720
+ existingSshKeyId: sharedSshKeyId,
721
+ location: standbyRegion,
722
+ serverType,
723
+ labels: { 'managed-by': 'vibecarbon', role: 'standby' },
724
+ allowedSshIps,
725
+ });
726
+ // Pass s3Config so Pulumi state lives in the project's S3 bucket rather
727
+ // than the GLOBAL local backend at ~/.vibecarbon/pulumi-state/. Without
728
+ // this, parallel test scenarios share a backend, and another scenario's
729
+ // orphan-detect path can find compose-ha's mid-deploy stacks and destroy
730
+ // them — observed 2026-04-26 batch run #5 where compose's final-destroy
731
+ // wiped compose-ha's e2-primary/e2-standby while compose-ha was still
732
+ // mid-flight in verify-deploy.
733
+ // Pulumi up of two stacks in parallel — typically the largest single
734
+ // wall-clock chunk in compose-ha deploy (~2-3 min cold; image+firewall
735
+ // creation in two regions concurrently). Per-stack timers expose which
736
+ // region/stack is the slow one in regression analysis.
737
+ const [primaryResult, standbyResult] = await perfAsync('deploy.ha.compose.iac.upStack.fan', () =>
738
+ Promise.all([
739
+ perfAsync('deploy.ha.compose.iac.upStack.primary', () =>
740
+ upStack(`${environment}-primary`, primaryProgram, { hcloudToken: apiToken, s3Config }),
741
+ ),
742
+ perfAsync('deploy.ha.compose.iac.upStack.standby', () =>
743
+ upStack(`${environment}-standby`, standbyProgram, { hcloudToken: apiToken, s3Config }),
744
+ ),
745
+ ]),
746
+ );
747
+ const primaryIp = primaryResult.outputs.serverIp;
748
+ const primaryServerId = primaryResult.outputs.serverId;
749
+ const standbyIp = standbyResult.outputs.serverIp;
750
+ const standbyServerId = standbyResult.outputs.serverId;
751
+
752
+ p.log.success(`Primary: ${c.info(primaryIp)} (${region})`);
753
+ p.log.success(`Standby: ${c.info(standbyIp)} (${standbyRegion})`);
754
+
755
+ // Persist server info immediately so `destroy` can clean up if deploy fails later
756
+ const projectConfig = loadProjectConfig() || { projectName, environments: {} };
757
+ const pendingEnvConfig = {
758
+ ...envConfig,
759
+ status: 'deploying',
760
+ deployMode: 'compose-ha',
761
+ provider: 'hetzner',
762
+ region,
763
+ secondaryRegion: standbyRegion,
764
+ serverType,
765
+ ha: { enabled: true, failoverRegion: standbyRegion },
766
+ servers: [
767
+ {
768
+ name: `${projectName}-${environment}-primary`,
769
+ id: primaryServerId,
770
+ ip: primaryIp,
771
+ region,
772
+ serverType,
773
+ role: 'primary',
774
+ },
775
+ {
776
+ name: `${projectName}-${environment}-standby`,
777
+ id: standbyServerId,
778
+ ip: standbyIp,
779
+ region: standbyRegion,
780
+ serverType,
781
+ role: 'standby',
782
+ },
783
+ ],
784
+ domain: domain || null,
785
+ dnsProvider: dnsProvider || null,
786
+ dns:
787
+ dnsProvider === 'cloudflare' && cloudflareZoneId
788
+ ? { provider: 'cloudflare', cloudflareZoneId }
789
+ : dnsProvider === 'hetzner' && options.hetznerDnsZoneId
790
+ ? { provider: 'hetzner', hetznerZoneId: options.hetznerDnsZoneId }
791
+ : null,
792
+ // s3.accessKey is intentionally NOT persisted — the secret scanner
793
+ // gates every `vibecarbon deploy` invocation against tracked + untracked
794
+ // files in cwd, and .vibecarbon.json sits at the project root with
795
+ // neither a gitignore entry nor an isUniversalLocalEnv match. The
796
+ // orchestrator's skeleton save (src/lib/deploy/orchestrator.js:316)
797
+ // already omits accessKey; compose-ha was the lone outlier that wrote
798
+ // it back here, which is what made warm-deploy fast-fail with
799
+ // "Refusing to deploy: secrets detected" while single-region compose
800
+ // and both k8s modes passed. prompts.js:472-475 falls back to
801
+ // getS3Credentials() (reading ~/.vibecarbon/credentials.json) when
802
+ // envConfig.s3.accessKey is missing, so runtime still resolves.
803
+ ...(s3Config && {
804
+ s3: {
805
+ bucket: s3Config.bucket,
806
+ region: s3Config.region,
807
+ endpoint: s3Config.endpoint,
808
+ },
809
+ }),
810
+ ...(backupS3Config && { backupS3: backupS3Config }),
811
+ ...(backupConfig && { backup: backupConfig }),
812
+ services,
813
+ lastAttempt: new Date().toISOString(),
814
+ };
815
+ saveProjectConfig({
816
+ ...projectConfig,
817
+ provider: 'hetzner',
818
+ environments: {
819
+ ...projectConfig.environments,
820
+ [environment]: pendingEnvConfig,
821
+ },
822
+ });
823
+
824
+ // Wait for SSH on both servers
825
+ onProgress('Waiting for servers to become available...');
826
+ const [primaryReady, standbyReady] = await perfAsync('deploy.ha.compose.waitForSSH.both', () =>
827
+ Promise.all([waitForSSH(primaryIp, sshKeyPath, 30), waitForSSH(standbyIp, sshKeyPath, 30)]),
828
+ );
829
+
830
+ if (!primaryReady || !standbyReady) {
831
+ throw new Error('Timed out waiting for SSH on one or both servers');
832
+ }
833
+
834
+ // Ensure Docker is running, configure firewall, enable auto-updates
835
+ onProgress('Configuring servers...');
836
+ await perfAsync('deploy.ha.compose.cloudInitReady.both', () =>
837
+ Promise.all([setupServer(primaryIp, sshKeyPath), setupServer(standbyIp, sshKeyPath)]),
838
+ );
839
+
840
+ // Wait for Docker daemon to be ready on both servers before logins/pulls/builds.
841
+ // dockerLoginOnServer can fire before Docker is listening, silently "succeed"
842
+ // (errors are swallowed with a warning), then reconcile.sh hits `unauthorized`
843
+ // when pulling from ghcr. The 03:55 UTC run's compose-ha failure was exactly
844
+ // this race on standby — primary completed login first, standby's login ran
845
+ // against a not-yet-ready dockerd, ghcr credentials never persisted, pull
846
+ // failed.
847
+ const { waitForDockerReady } = await import('./index.js');
848
+ await perfAsync('deploy.ha.compose.dockerReady.both', () =>
849
+ Promise.all([
850
+ waitForDockerReady(primaryIp, sshKeyPath),
851
+ waitForDockerReady(standbyIp, sshKeyPath),
852
+ ]),
853
+ );
854
+
855
+ // --- Local-image build + sideload (HA) ---
856
+ // When the image tag is local-only (no `:` registry prefix), it was NOT
857
+ // pushed to a registry. The pre-PR-5 deploy gated this on `direct`; the
858
+ // CLI sweep collapsed `direct` and `local` build modes into a single
859
+ // auto-detected default and `direct` is now mostly false for compose-HA.
860
+ // The trigger that matters here is whether the image will be pullable
861
+ // from `docker compose up -d` on each server — which is purely a
862
+ // function of the tag. So we fire whenever the imageRef is local-only,
863
+ // regardless of how it got there. Without this sideload, standby's
864
+ // reconcile fails with `pull access denied for <project>-app, repository
865
+ // does not exist`.
866
+ //
867
+ // We build natively on BOTH servers in parallel (over DOCKER_HOST=ssh://)
868
+ // rather than building once locally and piping `docker save` cross-region.
869
+ // The previous docker-save-pipe hung at 1475s (= 24.6 min, past the
870
+ // deploy timeout) on the 2026-04-27 matrix when streaming a ~500MB tarball
871
+ // over a single TCP stream between Hetzner regions; per-region builds run
872
+ // in parallel against a tiny build context (~tens of KB) and the resulting
873
+ // image is byte-identical (same Dockerfile, same source). The same swap
874
+ // was made for compose scale; this completes it for compose-ha deploy.
875
+ if (isLocalOnlyImageTag(imageRef)) {
876
+ const { buildRemote } = await import('../remote-build.js');
877
+ // Plumb VITE_* build args through to docker build on each host so the
878
+ // browser bundle ships with real Supabase URL + project name etc.
879
+ // (mirrors the single-region fix in orchestrator.js; without these the
880
+ // bundle gets empty strings and the client throws "Missing Supabase
881
+ // environment variables" at page load).
882
+ const { collectComposeBuildArgs } = await import('./build-args.js');
883
+ const haBuildArgs = collectComposeBuildArgs(process.cwd(), { projectName, domain });
884
+ onProgress('Building image natively on primary + standby (parallel)...');
885
+ const [primaryOk, standbyOk] = await perfAsync('deploy.ha.compose.remoteBuild.fan', () =>
886
+ Promise.all([
887
+ buildRemote(primaryIp, sshKeyPath, imageRef, process.cwd(), haBuildArgs),
888
+ buildRemote(standbyIp, sshKeyPath, imageRef, process.cwd(), haBuildArgs),
889
+ ]),
890
+ );
891
+ if (!primaryOk) throw new Error('Remote build on primary failed');
892
+ if (!standbyOk) throw new Error('Remote build on standby failed');
893
+ }
894
+
895
+ // Docker login and file copy — all independent, run in parallel
896
+ onProgress('Copying project files...');
897
+ const serviceOpts = {
898
+ domain,
899
+ image: imageRef,
900
+ observability: services.observability,
901
+ n8n: services.n8n,
902
+ metabase: services.metabase,
903
+ redis: services.redis,
904
+ bundlePath,
905
+ };
906
+ const setupTasks = [
907
+ perfAsync('deploy.ha.compose.setupFiles.primary', () =>
908
+ setupServerFiles(primaryIp, sshKeyPath, projectName, serviceOpts),
909
+ ),
910
+ perfAsync('deploy.ha.compose.setupFiles.standby', () =>
911
+ setupServerFiles(standbyIp, sshKeyPath, projectName, serviceOpts),
912
+ ),
913
+ perfAsync('deploy.ha.compose.openReplPort.primary', () =>
914
+ openReplicationPort(primaryIp, standbyIp, sshKeyPath),
915
+ ),
916
+ perfAsync('deploy.ha.compose.openReplPort.standby', () =>
917
+ openReplicationPort(standbyIp, primaryIp, sshKeyPath),
918
+ ),
919
+ // Open 5433 in the Hetzner Cloud firewall for both peers. UFW is the
920
+ // host-level layer and was already configured above; without these two
921
+ // calls the Cloud Firewall (Pulumi-declared with only 22/80/443) silently
922
+ // drops every replication packet at the edge. Both directions because
923
+ // post-failover the standby becomes primary and needs its own 5433 open
924
+ // for the new standby (now the old primary) to reconnect.
925
+ perfAsync('deploy.ha.compose.cloudFirewall5433.primary', () =>
926
+ openReplicationPortHetznerFirewall(
927
+ `${projectName}-${environment}-primary`,
928
+ standbyIp,
929
+ apiToken,
930
+ ),
931
+ ),
932
+ perfAsync('deploy.ha.compose.cloudFirewall5433.standby', () =>
933
+ openReplicationPortHetznerFirewall(
934
+ `${projectName}-${environment}-standby`,
935
+ primaryIp,
936
+ apiToken,
937
+ ),
938
+ ),
939
+ ];
940
+ if (dockerHubCreds) {
941
+ setupTasks.push(
942
+ perfAsync('deploy.ha.compose.dockerLogin.primary', () =>
943
+ dockerLoginOnServer(primaryIp, sshKeyPath, dockerHubCreds),
944
+ ),
945
+ perfAsync('deploy.ha.compose.dockerLogin.standby', () =>
946
+ dockerLoginOnServer(standbyIp, sshKeyPath, dockerHubCreds),
947
+ ),
948
+ );
949
+ }
950
+ if (ghcrPullCreds?.owner && ghcrPullCreds?.token) {
951
+ const ghcrLogin = {
952
+ username: ghcrPullCreds.owner,
953
+ token: ghcrPullCreds.token,
954
+ registry: 'ghcr.io',
955
+ };
956
+ setupTasks.push(
957
+ perfAsync('deploy.ha.compose.ghcrLogin.primary', () =>
958
+ dockerLoginOnServer(primaryIp, sshKeyPath, ghcrLogin),
959
+ ),
960
+ perfAsync('deploy.ha.compose.ghcrLogin.standby', () =>
961
+ dockerLoginOnServer(standbyIp, sshKeyPath, ghcrLogin),
962
+ ),
963
+ );
964
+ }
965
+ await perfAsync('deploy.ha.compose.setupFan', () => Promise.all(setupTasks));
966
+
967
+ // Pull app + base images on both servers in parallel. No remote build —
968
+ // image comes from ghcr.io (CI-built, multi-arch manifest; each node
969
+ // pulls its arch). Each server is independent; no cross-cluster dep.
970
+ onProgress('Pulling images on both servers...');
971
+ await perfAsync('deploy.ha.compose.pullFan', () =>
972
+ Promise.all([
973
+ perfAsync('deploy.ha.compose.pullImages.primary', () =>
974
+ pullComposeImages(primaryIp, sshKeyPath, projectName, serviceOpts),
975
+ ),
976
+ perfAsync('deploy.ha.compose.pullImages.standby', () =>
977
+ pullComposeImages(standbyIp, sshKeyPath, projectName, serviceOpts),
978
+ ),
979
+ ]),
980
+ );
981
+
982
+ // Set up DNS BEFORE starting services so Traefik's ACME challenges can succeed
983
+ // immediately (subdomains use DNS-only records that Let's Encrypt validates directly)
984
+ if (domain && cloudflareApiToken && cloudflareZoneId) {
985
+ onProgress('Setting up Cloudflare HA (health checks + load balancer)...');
986
+ try {
987
+ await perfAsync('deploy.ha.compose.dns.cloudflare', () =>
988
+ setupCloudflareHA(cloudflareApiToken, cloudflareZoneId, domain, [
989
+ { name: `${projectName}-${environment}-primary`, ip: primaryIp, region },
990
+ { name: `${projectName}-${environment}-standby`, ip: standbyIp, region: standbyRegion },
991
+ ]),
992
+ );
993
+ } catch (error) {
994
+ p.log.warn(`Cloudflare HA setup failed: ${error.message}`);
995
+ p.log.info('You can configure DNS manually.');
996
+ }
997
+ } else if (domain && options.hetznerDnsZoneId && apiToken) {
998
+ onProgress('Setting up Hetzner DNS for HA...');
999
+ try {
1000
+ const { setupHA: setupHetznerDnsHA } = await import('../../hetzner-dns.js');
1001
+ await perfAsync('deploy.ha.compose.dns.hetzner', () =>
1002
+ setupHetznerDnsHA(apiToken, options.hetznerDnsZoneId, domain, [
1003
+ { name: `${projectName}-${environment}-primary`, ip: primaryIp, region },
1004
+ { name: `${projectName}-${environment}-standby`, ip: standbyIp, region: standbyRegion },
1005
+ ]),
1006
+ );
1007
+ } catch (error) {
1008
+ p.log.warn(`Hetzner DNS HA setup failed: ${error.message}`);
1009
+ p.log.info('You can configure DNS manually.');
1010
+ }
1011
+ } else if (domain && dnsProvider === 'manual') {
1012
+ p.log.info(c.bold('Manual DNS Configuration:'));
1013
+ p.log.message(` Point ${c.bold(domain)} to ${c.bold(primaryIp)} (primary)`);
1014
+ p.log.message(` Standby server: ${c.bold(standbyIp)} (for manual failover)`);
1015
+ }
1016
+
1017
+ // Block compose-up until public resolvers see the apex domain. Traefik's
1018
+ // ACME burst (~7 attempts in ~30s) fails if LE can't resolve the host
1019
+ // when challenges fire. HA has historically hidden this race by accident
1020
+ // because there's substantial setup work between the DNS write above and
1021
+ // compose-up below; making the wait explicit eliminates the surprise on
1022
+ // future deploys that shrink that gap. Same fix lives in orchestrator.js
1023
+ // for single-region compose where the race was actively biting (RCA from
1024
+ // vibecarbon.com cold-deploy 2026-05-19).
1025
+ if (domain && dnsProvider && dnsProvider !== 'manual') {
1026
+ // Surface this in the enclosing tracker spinner so the operator sees
1027
+ // why we're waiting (up to 120s on a cold edge). Without the message
1028
+ // the HA spinner sits on the prior "Configuring servers..." text for
1029
+ // potentially two minutes, looking hung.
1030
+ onProgress(`Waiting for ${domain} to propagate to ${primaryIp}...`);
1031
+ const { waitForDNSPropagation } = await import('../../dns-propagation.js');
1032
+ await perfAsync('deploy.ha.compose.dns.waitForPropagation', () =>
1033
+ waitForDNSPropagation(domain, primaryIp, 120_000),
1034
+ );
1035
+ }
1036
+
1037
+ // Start compose stacks on both servers in parallel
1038
+ onProgress('Starting services on both servers...');
1039
+ await perfAsync('deploy.ha.compose.composeUp.both', () =>
1040
+ Promise.all([
1041
+ startComposeStack(primaryIp, sshKeyPath, projectName, serviceOpts),
1042
+ startComposeStack(standbyIp, sshKeyPath, projectName, serviceOpts),
1043
+ ]),
1044
+ );
1045
+
1046
+ // Run migrations on primary only
1047
+ onProgress('Running database migrations on primary...');
1048
+ try {
1049
+ await perfAsync('deploy.ha.compose.migrations', () =>
1050
+ runMigrations(primaryIp, sshKeyPath, projectName),
1051
+ );
1052
+ } catch {
1053
+ // Migrations may partially fail on first run, non-fatal
1054
+ }
1055
+
1056
+ // Create admin user in production Supabase (same as single-server deploy)
1057
+ onProgress('Creating admin user...');
1058
+ const adminResult = await perfAsync('deploy.ha.compose.createAdminUser', () =>
1059
+ createAdminUser(primaryIp, sshKeyPath, projectName),
1060
+ );
1061
+ if (adminResult.success) {
1062
+ p.log.success(adminResult.message);
1063
+ } else {
1064
+ p.log.warn(`${adminResult.message}. Run \`vibecarbon deploy\` again to retry.`);
1065
+ }
1066
+
1067
+ // Wait for primary Postgres to accept connections before configuring
1068
+ // replication. pg_basebackup needs the PG socket reachable; it doesn't
1069
+ // need GoTrue/Storage to have finished their own migrations.
1070
+ //
1071
+ // Earlier versions of this loop polled GoTrue's /health endpoint on the
1072
+ // assumption that pg_basebackup running before app migrations completed
1073
+ // would leave the standby with an incomplete schema. That's a
1074
+ // misreading of how physical replication works: pg_basebackup takes a
1075
+ // consistent snapshot at LSN T_0 and the standby replays the WAL stream
1076
+ // from T_0 onwards via the replication slot, so any GoTrue/Storage
1077
+ // migrations committed after the snapshot land on the standby
1078
+ // automatically. The previous run (2026-05-18) measured the GoTrue
1079
+ // probe at 2:25 wall-clock on a healthy primary; swapping to
1080
+ // pg_isready collapses that to <10s.
1081
+ //
1082
+ // Wrapped in perfAsync so the distribution stays visible in
1083
+ // perf_substep — same metric name as before for trend continuity, even
1084
+ // though the probe semantics have tightened.
1085
+ onProgress('Waiting for primary Postgres to accept connections...');
1086
+ const healthConfirmed = await perfAsync('deploy.ha.compose.primaryHealthProbe', async () => {
1087
+ for (let i = 0; i < 30; i++) {
1088
+ try {
1089
+ const health = sshRun(
1090
+ primaryIp,
1091
+ sshKeyPath,
1092
+ `cd /opt/${projectName} && docker compose exec -T db pg_isready -h localhost -p 5432 2>/dev/null`,
1093
+ { timeout: 10_000 },
1094
+ );
1095
+ if (health?.includes('accepting connections')) return true;
1096
+ } catch {
1097
+ // Retry — non-zero exit from pg_isready (rejecting / no response)
1098
+ // bubbles up as a thrown error from sshRun; same handling either way.
1099
+ }
1100
+ if (i === 29) break;
1101
+ const ms = i < 5 ? 1000 : 3000;
1102
+ await new Promise((r) => setTimeout(r, ms));
1103
+ }
1104
+ return false;
1105
+ });
1106
+ if (!healthConfirmed) {
1107
+ // Breadcrumb so a downstream replication failure isn't surprising —
1108
+ // the 2026-05-16 e2e matrix's compose-ha cancel left the deploy
1109
+ // log with no signal that the wait had given up unconfirmed before
1110
+ // the SSH retries downstream blew up on a flaky local network.
1111
+ // Proceeding anyway: PG may still come up in time for the
1112
+ // replication step, and hard-failing here would mask genuine
1113
+ // late-init successes.
1114
+ console.error(
1115
+ `[ha] primary Postgres did not accept connections within ~6min; proceeding optimistically — configurePrimaryReplication is the next failure surface if PG is truly dead`,
1116
+ );
1117
+ }
1118
+
1119
+ // Expose raw PostgreSQL on port 5433 for replication (supavisor owns 5432).
1120
+ // Add a docker-compose.replication.yml overlay that maps db:5432 → host:5433.
1121
+ // Fan out across primary + standby — the overlay write + db-only `compose up`
1122
+ // touch disjoint VPS hosts and need to land on both before
1123
+ // configurePrimaryReplication grants pub access. Previously serial via a
1124
+ // for-loop using sync sshRun (~4 ssh calls × 2-3s each = ~8-12s blocking the
1125
+ // event loop); Promise.all halves wall-clock and unblocks any concurrent
1126
+ // perfAsync timers from being charged for the dead time.
1127
+ const replOverlay = `version: "3.8"\nservices:\n db:\n ports:\n - "5433:5432"\n`;
1128
+ await Promise.all(
1129
+ [primaryIp, standbyIp].map(async (ip) => {
1130
+ await sshRunAsync(
1131
+ ip,
1132
+ sshKeyPath,
1133
+ `cat > /opt/${projectName}/docker-compose.replication.yml << 'REPL'\n${replOverlay}REPL`,
1134
+ { timeout: 10_000 },
1135
+ );
1136
+ // Recreate only the db service with the new port mapping
1137
+ await sshRunAsync(
1138
+ ip,
1139
+ sshKeyPath,
1140
+ `cd /opt/${projectName} && docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.replication.yml up -d db 2>&1`,
1141
+ { timeout: 60_000 },
1142
+ );
1143
+ }),
1144
+ );
1145
+
1146
+ // Configure PostgreSQL streaming replication.
1147
+ // Wrap the entire setup (primary + standby + slot creation + pg_basebackup)
1148
+ // so the perf trace shows the headline replication cost — typical 30-90s,
1149
+ // dominated by pg_basebackup for the standby seed.
1150
+ await perfAsync('deploy.ha.replication.setup', async () => {
1151
+ onProgress('Configuring PostgreSQL replication on primary...');
1152
+ configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
1153
+
1154
+ onProgress('Configuring standby as hot replica...');
1155
+ let replicationOk = false;
1156
+ try {
1157
+ replicationOk = await configureStandbyReplication(
1158
+ standbyIp,
1159
+ primaryIp,
1160
+ sshKeyPath,
1161
+ projectName,
1162
+ );
1163
+ } catch (err) {
1164
+ // Soft-fail in deploy: replication has been chronically broken
1165
+ // (see project_replication_broken.md) and hard-failing every
1166
+ // compose-ha deploy here would block iteration on the rest of
1167
+ // the scenario. Surface the captured pg_basebackup output so
1168
+ // we can root-cause it, then continue with a clear warning.
1169
+ // Failover-path still hard-fails (see line ~1185) so we don't
1170
+ // cascade into a data-wipe + CrashLoopBackOff there.
1171
+ p.log.warn(`Replication setup error (continuing with warning): ${err.message}`);
1172
+ }
1173
+ if (!replicationOk) {
1174
+ p.log.warn('Replication setup may not have completed — verify manually');
1175
+ }
1176
+ });
1177
+
1178
+ // Verify streaming replication is active on the primary. Once the standby
1179
+ // catches up, pg_stat_replication.state flips to 'streaming' usually
1180
+ // within 1-2s of standby start. Exponential pacing instead of the old
1181
+ // hardcoded 1s/3s shell-out: fast happy path (probe at 250ms, 750ms,
1182
+ // 1.75s, …) without giving up too early under load. Total budget ~25s.
1183
+ onProgress('Verifying replication...');
1184
+ const REPL_DELAYS_MS = [250, 500, 1000, 2000, 3000, 3000, 3000, 3000, 3000, 3000];
1185
+ let replActive = false;
1186
+ for (let i = 0; i < REPL_DELAYS_MS.length; i++) {
1187
+ try {
1188
+ const count = sshRun(
1189
+ primaryIp,
1190
+ sshKeyPath,
1191
+ `cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'"`,
1192
+ { timeout: 10_000 },
1193
+ );
1194
+ if (Number.parseInt(count?.trim(), 10) > 0) {
1195
+ replActive = true;
1196
+ break;
1197
+ }
1198
+ } catch {
1199
+ // Retry
1200
+ }
1201
+ if (i === REPL_DELAYS_MS.length - 1) break;
1202
+ await new Promise((r) => setTimeout(r, REPL_DELAYS_MS[i]));
1203
+ }
1204
+ if (replActive) {
1205
+ p.log.success('Streaming replication active');
1206
+ } else {
1207
+ p.log.warn('Streaming replication not detected — standby may fall behind');
1208
+ }
1209
+
1210
+ // Finalize config — promote pending → deployed
1211
+ saveProjectConfig({
1212
+ ...projectConfig,
1213
+ provider: 'hetzner',
1214
+ environments: {
1215
+ ...projectConfig.environments,
1216
+ [environment]: {
1217
+ ...pendingEnvConfig,
1218
+ status: 'deployed',
1219
+ deployedAt: new Date().toISOString(),
1220
+ lastAttempt: undefined,
1221
+ },
1222
+ },
1223
+ });
1224
+ registerProject(projectName, process.cwd());
1225
+
1226
+ // Summary
1227
+ p.log.success('');
1228
+ if (domain) {
1229
+ p.log.message(` ${c.dim('URL')} ${c.info(`https://${domain}`)}`);
1230
+ }
1231
+ p.log.message(` ${c.dim('Primary')} ${c.info(primaryIp)} (${region})`);
1232
+ p.log.message(` ${c.dim('Standby')} ${c.info(standbyIp)} (${standbyRegion})`);
1233
+ p.log.message(` ${c.dim('Deploy method')} Docker Compose HA`);
1234
+ p.log.message(` ${c.dim('Replication')} PostgreSQL streaming`);
1235
+ p.log.message('');
1236
+
1237
+ return {
1238
+ success: true,
1239
+ primaryIp,
1240
+ standbyIp,
1241
+ domain,
1242
+ deployMode: 'compose-ha',
1243
+ };
1244
+ }
1245
+
1246
+ // ============================================================================
1247
+ // DESTROY
1248
+ // ============================================================================
1249
+
1250
+ /**
1251
+ * Destroy a Compose HA environment (both VPS, firewalls, SSH keys, DNS/LB)
1252
+ *
1253
+ * @param {object} options
1254
+ * @param {string} options.projectName
1255
+ * @param {string} options.environment
1256
+ * @param {object} options.envConfig
1257
+ * @param {string} options.hetznerToken
1258
+ * @param {string} [options.cloudflareToken]
1259
+ * @param {Function} [options.onProgress]
1260
+ */
1261
+ export async function destroyComposeHA(options) {
1262
+ const {
1263
+ projectName,
1264
+ environment,
1265
+ envConfig,
1266
+ hetznerToken,
1267
+ cloudflareToken,
1268
+ onProgress = () => {},
1269
+ } = options;
1270
+
1271
+ const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
1272
+ const hetznerHeaders = { Authorization: `Bearer ${hetznerToken}` };
1273
+
1274
+ // Stop compose services on both servers in parallel
1275
+ onProgress('Stopping services on all servers...');
1276
+ if (existsSync(sshKeyPath)) {
1277
+ await Promise.allSettled(
1278
+ (envConfig.servers || [])
1279
+ .filter((s) => s.ip)
1280
+ .map((server) =>
1281
+ sshRunAsync(
1282
+ server.ip,
1283
+ sshKeyPath,
1284
+ `cd /opt/${projectName} && docker compose down -v --remove-orphans 2>/dev/null; rm -rf /opt/${projectName}`,
1285
+ { timeout: 120_000, ignoreError: true },
1286
+ ),
1287
+ ),
1288
+ );
1289
+ }
1290
+
1291
+ // Delete both VPS in parallel.
1292
+ //
1293
+ // Hetzner's DELETE /servers/{id} is async: it returns 202 immediately
1294
+ // and the actual VM removal continues for several seconds afterwards.
1295
+ // The previous code only awaited the 202 — Pulumi state was wiped
1296
+ // moments later when the S3 bucket got deleted, then compose-ha
1297
+ // restore re-ran the deploy with a fresh Pulumi stack and tried to
1298
+ // create a server with the same `${projectName}-${environment}-primary`
1299
+ // name. Hetzner rejected with HTTP 409 'server name is already used
1300
+ // (uniqueness_error)' because the old VM was still draining
1301
+ // (observed in compose-ha restore matrix runs).
1302
+ //
1303
+ // Issue DELETE first, then poll GET /servers/{id} on each one until
1304
+ // it returns 404. 90s deadline is comfortable: empirical Hetzner
1305
+ // delete time is 5-30s on cx23.
1306
+ onProgress('Deleting VPS...');
1307
+ const serversToDelete = await resolveHaServers({
1308
+ projectName,
1309
+ environment,
1310
+ envConfig,
1311
+ hetznerToken,
1312
+ hetznerHeaders,
1313
+ });
1314
+ await Promise.allSettled(
1315
+ serversToDelete.map((server) =>
1316
+ fetchWithRetry(`https://api.hetzner.cloud/v1/servers/${server.id}`, {
1317
+ method: 'DELETE',
1318
+ headers: hetznerHeaders,
1319
+ }).catch(() => {}),
1320
+ ),
1321
+ );
1322
+ if (serversToDelete.length > 0) {
1323
+ onProgress('Waiting for VPS deletion to complete...');
1324
+ const deadline = Date.now() + 90_000;
1325
+ const remaining = new Map(serversToDelete.map((s) => [s.id, s]));
1326
+ while (remaining.size > 0 && Date.now() < deadline) {
1327
+ await Promise.allSettled(
1328
+ Array.from(remaining.entries()).map(async ([id]) => {
1329
+ try {
1330
+ const resp = await fetch(`https://api.hetzner.cloud/v1/servers/${id}`, {
1331
+ headers: hetznerHeaders,
1332
+ });
1333
+ if (resp.status === 404) remaining.delete(id);
1334
+ } catch {
1335
+ // Transient — retry next loop iteration
1336
+ }
1337
+ }),
1338
+ );
1339
+ if (remaining.size > 0) await new Promise((r) => setTimeout(r, 2_000));
1340
+ }
1341
+ }
1342
+
1343
+ // Delete firewalls — fetch list once, delete matching in parallel.
1344
+ // The Pulumi program (hetzner-compose.js) names firewalls
1345
+ // `${projectName}-${stackEnv}-firewall`
1346
+ // where stackEnv is `${environment}-primary` or `${environment}-standby`
1347
+ // (set by deployComposeHA above). Earlier the names tried below were
1348
+ // `vibecarbon-${projectName}-${environment}[-primary|-standby]` — none of
1349
+ // those match what Pulumi created, so destroy left every firewall behind
1350
+ // and the next deploy hit "name is already used (uniqueness_error)" on
1351
+ // pulumi up. Match the actual Pulumi naming convention.
1352
+ //
1353
+ // Detach from servers first — server deletion above is async (Hetzner
1354
+ // returns 202 and finishes the delete behind the scenes), so the firewall
1355
+ // can still be `applied_to` a deleting server when we hit DELETE. That
1356
+ // returns 409 "still attached" which the previous `.catch(() => {})` was
1357
+ // silently swallowing, leaving the firewall behind. The next re-deploy
1358
+ // (compose-ha restore step) then hit "name is already used
1359
+ // (uniqueness_error)" on Pulumi up. Mirror the detach + retry logic
1360
+ // from `hetznerDeleteFirewall` in src/destroy.js.
1361
+ // The previous implementation listed `/v1/firewalls` without `?name=` and
1362
+ // without pagination. Hetzner returns `per_page=25` by default, so once
1363
+ // the project (or test matrix) accumulates 25+ firewalls the project's
1364
+ // firewalls aren't in page 1 → the destroy silently no-ops → the next
1365
+ // deploy hits "name is already used (uniqueness_error)" on the firewall
1366
+ // create. Look up each expected name with the exact-name filter (no
1367
+ // pagination concern), then poll until DELETE actually removes it.
1368
+ onProgress('Deleting firewalls...');
1369
+ const fwNames = [
1370
+ `${projectName}-${environment}-primary-firewall`,
1371
+ `${projectName}-${environment}-standby-firewall`,
1372
+ `${projectName}-${environment}-firewall`,
1373
+ ];
1374
+ await Promise.allSettled(fwNames.map((name) => deleteFirewallByName(name, hetznerHeaders)));
1375
+
1376
+ // Delete SSH key + DNS resources in parallel
1377
+ onProgress('Cleaning up resources...');
1378
+ await Promise.allSettled([
1379
+ // SSH key deletion. deployComposeHA creates a SHARED ssh key named
1380
+ // `${projectName}-${environment}-key`
1381
+ // (see line ~385 — `_provider.createSSHKey(sharedSshKeyName, ...)`)
1382
+ // and passes its ID into both Pulumi programs as existingSshKeyId so
1383
+ // Pulumi never creates per-cluster keys. The old name pattern here
1384
+ // (`vibecarbon-${projectName}-${environment}`) didn't match anything
1385
+ // Pulumi or HA actually created — the shared key always leaked.
1386
+ (async () => {
1387
+ const sshKeyName = `${projectName}-${environment}-key`;
1388
+ try {
1389
+ const listResponse = await fetchWithRetry('https://api.hetzner.cloud/v1/ssh_keys', {
1390
+ headers: hetznerHeaders,
1391
+ });
1392
+ const { ssh_keys } = await listResponse.json();
1393
+ const key = ssh_keys?.find((k) => k.name === sshKeyName);
1394
+ if (key) {
1395
+ await fetchWithRetry(`https://api.hetzner.cloud/v1/ssh_keys/${key.id}`, {
1396
+ method: 'DELETE',
1397
+ headers: hetznerHeaders,
1398
+ });
1399
+ }
1400
+ } catch {
1401
+ // Non-fatal
1402
+ }
1403
+ })(),
1404
+ // DNS resource deletion (ownership-filtered — only records pointing at
1405
+ // this env's server IPs are removed; shared zones keep their other
1406
+ // tenants' records intact).
1407
+ (async () => {
1408
+ const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
1409
+ if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
1410
+ const { deleteDNSRecord, deleteHealthCheck } = await import('../../cloudflare.js');
1411
+ const zoneId = envConfig.dns.cloudflareZoneId;
1412
+ try {
1413
+ await deleteDNSRecord(cloudflareToken, zoneId, envConfig.domain, ownedIps);
1414
+ await Promise.allSettled(
1415
+ (envConfig.servers || []).map((server) =>
1416
+ deleteHealthCheck(cloudflareToken, zoneId, `${server.name}-health`),
1417
+ ),
1418
+ );
1419
+ } catch {
1420
+ // Non-fatal
1421
+ }
1422
+ } else if (
1423
+ envConfig.dns?.provider === 'hetzner' &&
1424
+ envConfig.dns?.hetznerZoneId &&
1425
+ envConfig.domain
1426
+ ) {
1427
+ const { deleteDNSRecord: hetznerDeleteDNS, getZone: hetznerGetZone } = await import(
1428
+ '../../hetzner-dns.js'
1429
+ );
1430
+ const hToken = hetznerToken || process.env.HETZNER_API_TOKEN;
1431
+ if (hToken) {
1432
+ try {
1433
+ const zone = await hetznerGetZone(hToken, envConfig.dns.hetznerZoneId);
1434
+ await Promise.allSettled([
1435
+ hetznerDeleteDNS(
1436
+ hToken,
1437
+ envConfig.dns.hetznerZoneId,
1438
+ zone.name,
1439
+ envConfig.domain,
1440
+ ownedIps,
1441
+ ),
1442
+ hetznerDeleteDNS(
1443
+ hToken,
1444
+ envConfig.dns.hetznerZoneId,
1445
+ zone.name,
1446
+ `*.${envConfig.domain}`,
1447
+ ownedIps,
1448
+ ),
1449
+ ]);
1450
+ } catch {
1451
+ // Non-fatal
1452
+ }
1453
+ }
1454
+ }
1455
+ })(),
1456
+ ]);
1457
+
1458
+ // Clean up local SSH key files
1459
+ for (const f of [sshKeyPath, `${sshKeyPath}.pub`]) {
1460
+ if (existsSync(f)) {
1461
+ try {
1462
+ rmSync(f);
1463
+ } catch {
1464
+ // Non-fatal
1465
+ }
1466
+ }
1467
+ }
1468
+ }
1469
+
1470
+ // ============================================================================
1471
+ // STATUS
1472
+ // ============================================================================
1473
+
1474
+ /**
1475
+ * Get status of both Compose HA servers in parallel
1476
+ */
1477
+ export async function getComposeHAStatus(envConfig, sshKeyPath, projectName) {
1478
+ const results = await Promise.all(
1479
+ (envConfig.servers || []).map(async (server) => {
1480
+ try {
1481
+ const remoteDir = `/opt/${projectName}`;
1482
+ const output = await sshRunAsync(
1483
+ server.ip,
1484
+ sshKeyPath,
1485
+ `cd ${remoteDir} && docker compose ps --format json`,
1486
+ { timeout: 15_000 },
1487
+ );
1488
+ if (!output?.trim()) {
1489
+ return { ...server, running: false, services: [] };
1490
+ }
1491
+ const services = output
1492
+ .trim()
1493
+ .split('\n')
1494
+ .filter(Boolean)
1495
+ .map((line) => {
1496
+ try {
1497
+ return JSON.parse(line);
1498
+ } catch {
1499
+ return null;
1500
+ }
1501
+ })
1502
+ .filter(Boolean);
1503
+ return {
1504
+ ...server,
1505
+ running: services.some((s) => s.State === 'running'),
1506
+ services,
1507
+ };
1508
+ } catch {
1509
+ return { ...server, running: false, error: 'Unreachable' };
1510
+ }
1511
+ }),
1512
+ );
1513
+ return results;
1514
+ }
1515
+
1516
+ // ============================================================================
1517
+ // FAILOVER
1518
+ // ============================================================================
1519
+
1520
+ /**
1521
+ * Perform failover for a Compose HA environment.
1522
+ * Promotes the standby database and updates DNS if Cloudflare configured.
1523
+ */
1524
+ export async function failoverComposeHA(envName, envConfig, projectConfig, parsed, tracker) {
1525
+ const servers = envConfig.servers || [];
1526
+ const primaryServer = servers.find((s) => s.role === 'primary');
1527
+ const standbyServer = servers.find((s) => s.role === 'standby');
1528
+
1529
+ if (!primaryServer || !standbyServer) {
1530
+ p.log.error('Could not identify primary and standby servers from config');
1531
+ p.log.info('Ensure Compose HA deployment was set up with vibecarbon deploy --ha');
1532
+ process.exit(1);
1533
+ }
1534
+
1535
+ const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${envName}`);
1536
+ if (!existsSync(sshKeyPath)) {
1537
+ p.log.error(`SSH key not found: ${sshKeyPath}`);
1538
+ process.exit(1);
1539
+ }
1540
+
1541
+ const projectName = projectConfig.projectName;
1542
+ const remoteDir = `/opt/${projectName}`;
1543
+
1544
+ p.log.info(c.bold('Current Compose HA state:'));
1545
+ p.log.message(` ${c.dim(`Primary (${primaryServer.region})`.padEnd(22))} ${primaryServer.ip}`);
1546
+ p.log.message(` ${c.dim(`Standby (${standbyServer.region})`.padEnd(22))} ${standbyServer.ip}`);
1547
+
1548
+ p.log.info(c.bold('This will:'));
1549
+ p.log.message(' 1. Promote the standby database to primary (docker compose exec)');
1550
+ p.log.message(' 2. Update Cloudflare DNS (if configured)');
1551
+ p.log.message(' 3. Print DNS update instructions (if manual DNS)');
1552
+
1553
+ if (parsed.dryRun) {
1554
+ p.log.info(c.dim('Dry run — no changes made'));
1555
+ return;
1556
+ }
1557
+
1558
+ if (!parsed.yes) {
1559
+ const confirm = await p.confirm({
1560
+ message: `Execute failover for ${c.bold(envName)}?`,
1561
+ });
1562
+ if (p.isCancel(confirm) || !confirm) {
1563
+ p.cancel('Operation cancelled.');
1564
+ process.exit(0);
1565
+ }
1566
+ }
1567
+
1568
+ const s = tracker.spinner();
1569
+
1570
+ // Step 0: Re-seed standby from primary if primary is still reachable.
1571
+ //
1572
+ // Streaming replication is best-effort; without a replication slot (which
1573
+ // we don't configure), the standby can fall behind if primary churns
1574
+ // through WAL faster than `wal_keep_size`. A stale standby yields
1575
+ // "undefined column" (PG code 42703) errors post-promotion because recent
1576
+ // migrations on primary never streamed over.
1577
+ //
1578
+ // For planned failover (the e2e-test case), primary is still alive
1579
+ // — run pg_basebackup to guarantee schema parity, then promote. For a
1580
+ // real disaster failover where primary is unreachable, skip the re-seed
1581
+ // and promote whatever state standby has (best-effort recovery).
1582
+ const primaryReachable = await isPrimaryReachable(primaryServer.ip, sshKeyPath, remoteDir);
1583
+ if (primaryReachable) {
1584
+ s.start('Re-seeding standby from primary (pg_basebackup)');
1585
+ try {
1586
+ await configureStandbyReplication(
1587
+ standbyServer.ip,
1588
+ primaryServer.ip,
1589
+ sshKeyPath,
1590
+ projectName,
1591
+ );
1592
+ s.stop('Standby re-seeded with primary state');
1593
+ } catch (err) {
1594
+ // Was: catch + warn + proceed. That was actively misleading —
1595
+ // configureStandbyReplication starts with `rm -rf .../data/*`,
1596
+ // so on failure there is NO "existing standby state" to fall
1597
+ // back to; the dir is wiped and (with the script's `set -e`)
1598
+ // basebackup didn't complete. Promoting from this state hits
1599
+ // a CrashLoopBackOff (initdb-refuses-non-empty-dir loop). Hard
1600
+ // fail so the operator sees the issue instead of a confusing
1601
+ // pg_promote failure 5 minutes later.
1602
+ s.stop('Re-seed failed — aborting failover');
1603
+ throw new Error(
1604
+ `Failover aborted: re-seed failed and the standby's data directory ` +
1605
+ `is now in an inconsistent state. Manual recovery required ` +
1606
+ `(redeploy the standby or restore from backup).\nUnderlying error: ${err.message}`,
1607
+ );
1608
+ }
1609
+ } else {
1610
+ p.log.warn('Primary unreachable — skipping re-seed. Standby will promote with current state.');
1611
+ }
1612
+
1613
+ // Step 1: Promote standby database.
1614
+ //
1615
+ // Use SQL `pg_promote(wait => true, wait_seconds => 120)` instead of
1616
+ // `pg_ctl promote`. Why: pg_ctl is fire-and-forget — it writes the promote
1617
+ // signal file and returns. If the postmaster hasn't fully bound the socket
1618
+ // yet (configureStandbyReplication's `docker compose start db` finished
1619
+ // mere seconds earlier), pg_ctl can fail or no-op silently, leaving the
1620
+ // standby in recovery mode. The downstream poll loop then sees `t` for its
1621
+ // entire budget and we declare "Promotion may still be in progress" while
1622
+ // the function continues to restart app-tier + DNS-flip — verify-failover
1623
+ // ends up hitting a read-only DB and fails with 500 "Database error finding
1624
+ // user" (auth) + 544 "DatabaseTimeout" (storage).
1625
+ // (Observed compose-ha 2026-04-29 — 2m 40s of polling with promoted=false.)
1626
+ //
1627
+ // pg_promote() is server-side and synchronous: with `wait=>true` it blocks
1628
+ // until the standby has actually exited recovery (or `wait_seconds` lapses)
1629
+ // and returns the result. We retry a handful of times because the very
1630
+ // first call can race the postmaster startup ("connection refused" /
1631
+ // "the database system is starting up").
1632
+ // `silent: true` is load-bearing: ha.js's local sshRun wraps runCommand,
1633
+ // which on non-zero exit returns `false` (not throws) unless silent — so
1634
+ // without it, a still-booting postmaster's exit-1 manifests downstream as
1635
+ // `false?.trim is not a function`, the catch records it, and all 5 attempts
1636
+ // fail with the same TypeError instead of actually retrying the SQL call.
1637
+ // Helper: probe pg_is_in_recovery() and return 't'/'f'/'' (unknown).
1638
+ // Used after pg_promote to disambiguate three cases:
1639
+ // - pg_promote returned 't' → success
1640
+ // - pg_promote returned 'f' (timed out internally) → check probe
1641
+ // - pg_promote threw (psql exit 1, e.g. "recovery is not in progress"
1642
+ // because a previous attempt actually promoted) → check probe
1643
+ // Without the catch-side probe, the first attempt's success was silently
1644
+ // converted to "did not exit recovery mode after 5 attempts" because
1645
+ // attempts 2-5 hit the same not-in-recovery error and never re-checked.
1646
+ // RCA: compose-ha 2026-05-01 fanout4b failover.
1647
+ const probeRecovery = () => {
1648
+ try {
1649
+ const r = sshRun(
1650
+ standbyServer.ip,
1651
+ sshKeyPath,
1652
+ `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
1653
+ { silent: true, timeout: 10_000 },
1654
+ );
1655
+ return typeof r === 'string' ? r.trim() : '';
1656
+ } catch {
1657
+ return '';
1658
+ }
1659
+ };
1660
+
1661
+ s.start('Promoting standby database');
1662
+ // Use SQL pg_promote(wait => true). hot_standby=on (set in carbon/
1663
+ // docker-compose.yml) guarantees the standby accepts read connections,
1664
+ // so psql can reach pg_promote even before promotion finishes. Verified
1665
+ // 2026-05-01 fanout13 — without hot_standby the standby rejects every
1666
+ // psql connection with "Hot standby mode is disabled" and pg_promote
1667
+ // can't be invoked at all.
1668
+ let promoted = false;
1669
+ let lastPromoteError = null;
1670
+ const MAX_PROMOTE_ATTEMPTS = 5;
1671
+ for (let attempt = 1; attempt <= MAX_PROMOTE_ATTEMPTS && !promoted; attempt++) {
1672
+ try {
1673
+ const result = sshRun(
1674
+ standbyServer.ip,
1675
+ sshKeyPath,
1676
+ `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT pg_promote(wait => true, wait_seconds => 120)"`,
1677
+ { silent: true, timeout: 150_000 },
1678
+ );
1679
+ const trimmed = typeof result === 'string' ? result.trim() : '';
1680
+ if (trimmed === 't') {
1681
+ promoted = true;
1682
+ break;
1683
+ }
1684
+ const probeTrimmed = probeRecovery();
1685
+ if (probeTrimmed === 'f') {
1686
+ promoted = true;
1687
+ break;
1688
+ }
1689
+ lastPromoteError = new Error(
1690
+ `pg_promote returned ${JSON.stringify(trimmed)}; pg_is_in_recovery=${JSON.stringify(probeTrimmed)}`,
1691
+ );
1692
+ } catch (err) {
1693
+ const probeTrimmed = probeRecovery();
1694
+ if (probeTrimmed === 'f') {
1695
+ promoted = true;
1696
+ break;
1697
+ }
1698
+ lastPromoteError = err;
1699
+ }
1700
+ if (!promoted && attempt < MAX_PROMOTE_ATTEMPTS) {
1701
+ await new Promise((r) => setTimeout(r, 5_000));
1702
+ }
1703
+ }
1704
+
1705
+ if (!promoted) {
1706
+ // Capture diagnostics so the operator can see why the promotion failed
1707
+ // instead of guessing. Best-effort — failover-step abort is what matters.
1708
+ let dbLogs = '';
1709
+ try {
1710
+ dbLogs = sshRun(
1711
+ standbyServer.ip,
1712
+ sshKeyPath,
1713
+ `cd ${remoteDir} && docker compose logs db --tail=60`,
1714
+ { silent: true, timeout: 30_000 },
1715
+ );
1716
+ if (typeof dbLogs !== 'string') dbLogs = '(db logs returned non-string)';
1717
+ } catch (err) {
1718
+ dbLogs = `(failed to capture db logs: ${err.message})`;
1719
+ }
1720
+ s.stop('Promotion failed — standby still in recovery mode');
1721
+ throw new Error(
1722
+ `Failover aborted: standby ${standbyServer.ip} did not exit recovery mode after ${MAX_PROMOTE_ATTEMPTS} pg_promote attempts. ` +
1723
+ `Last error: ${lastPromoteError?.message || 'unknown'}.\n` +
1724
+ `--- db container logs (last 60 lines) ---\n${dbLogs}`,
1725
+ );
1726
+ }
1727
+
1728
+ s.stop('Standby database promoted to primary');
1729
+
1730
+ // Step 1b: Restart app-tier services on the new primary so they re-pool
1731
+ // against the now-read-write DB. These services were started during deploy
1732
+ // while the local DB was in recovery — supavisor's tenant pools and the
1733
+ // app/auth/rest/realtime/storage DB handles can hold stale connections that
1734
+ // surface as 500 "Database error" / 544 DatabaseTimeout right after
1735
+ // promotion. Restart is cheap (~10s) and forces a clean reconnect.
1736
+ s.start('Restarting new-primary app-tier');
1737
+ try {
1738
+ sshRun(
1739
+ standbyServer.ip,
1740
+ sshKeyPath,
1741
+ `cd ${remoteDir} && docker compose restart supavisor auth rest realtime storage app`,
1742
+ { timeout: 120_000 },
1743
+ );
1744
+ } catch (err) {
1745
+ p.log.warn(`Restart on new primary failed: ${err.message}`);
1746
+ }
1747
+ s.stop('New-primary app-tier restarted');
1748
+
1749
+ // Step 2: Stop old primary services to prevent split-brain
1750
+ s.start('Stopping old primary services');
1751
+ try {
1752
+ sshRun(
1753
+ primaryServer.ip,
1754
+ sshKeyPath,
1755
+ `cd ${remoteDir} && docker compose stop app auth rest realtime storage supavisor`,
1756
+ { timeout: 60_000 },
1757
+ );
1758
+ } catch {
1759
+ // Old primary may be unreachable — that's often why we're failing over
1760
+ p.log.warn('Could not reach old primary to stop services');
1761
+ }
1762
+ s.stop('Old primary services stopped');
1763
+
1764
+ // Step 3: Update DNS if Cloudflare configured
1765
+ if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
1766
+ let cfToken = process.env.CLOUDFLARE_API_TOKEN;
1767
+ if (!cfToken) {
1768
+ const creds = loadCredentials();
1769
+ if (creds.cloudflare?.apiToken) {
1770
+ cfToken = creds.cloudflare.apiToken;
1771
+ }
1772
+ }
1773
+ if (cfToken) {
1774
+ const domain = envConfig.domain;
1775
+ const zoneId = envConfig.dns.cloudflareZoneId;
1776
+
1777
+ // Clean up legacy load balancer if present from older deployments
1778
+ try {
1779
+ const { createDNSRecord } = await import('../../cloudflare.js');
1780
+
1781
+ // Update DNS A record to standby IP. proxied:false matches
1782
+ // the deploy-time setup in cloudflare.js setupHA (Cloudflare
1783
+ // LBs were removed 2026-03-26 — orange cloud is no longer
1784
+ // needed and was triggering 404 caching at CF's edge during
1785
+ // cert provisioning).
1786
+ s.start('Updating DNS A record');
1787
+ await createDNSRecord(cfToken, zoneId, {
1788
+ type: 'A',
1789
+ name: domain,
1790
+ content: standbyServer.ip,
1791
+ proxied: false,
1792
+ });
1793
+ await createDNSRecord(cfToken, zoneId, {
1794
+ type: 'A',
1795
+ name: `*.${domain}`,
1796
+ content: standbyServer.ip,
1797
+ proxied: false,
1798
+ });
1799
+ s.stop(`DNS updated: ${domain} → ${standbyServer.ip}`);
1800
+ } catch (error) {
1801
+ s.stop(`DNS update failed: ${error.message}`);
1802
+ p.log.warn(`Manually update the A record for ${domain} to ${standbyServer.ip}`);
1803
+ }
1804
+ } else {
1805
+ p.log.warn('CLOUDFLARE_API_TOKEN not set — update DNS A record manually');
1806
+ }
1807
+ } else if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId) {
1808
+ let hetznerToken = process.env.HETZNER_API_TOKEN;
1809
+ if (!hetznerToken) {
1810
+ const creds = loadCredentials();
1811
+ if (creds.hetzner?.apiToken) {
1812
+ hetznerToken = creds.hetzner.apiToken;
1813
+ }
1814
+ }
1815
+ if (hetznerToken) {
1816
+ const { createDNSRecord, getZone } = await import('../../hetzner-dns.js');
1817
+ const zone = await getZone(hetznerToken, envConfig.dns.hetznerZoneId);
1818
+ s.start('Updating DNS A record');
1819
+ try {
1820
+ await createDNSRecord(hetznerToken, envConfig.dns.hetznerZoneId, zone.name, {
1821
+ type: 'A',
1822
+ name: envConfig.domain,
1823
+ value: standbyServer.ip,
1824
+ ttl: 60,
1825
+ });
1826
+ await createDNSRecord(hetznerToken, envConfig.dns.hetznerZoneId, zone.name, {
1827
+ type: 'A',
1828
+ name: `*.${envConfig.domain}`,
1829
+ value: standbyServer.ip,
1830
+ ttl: 60,
1831
+ });
1832
+ s.stop(`DNS updated: ${envConfig.domain} → ${standbyServer.ip}`);
1833
+ } catch (error) {
1834
+ s.stop(`DNS update failed: ${error.message}`);
1835
+ p.log.warn(`Manually update the A record for ${envConfig.domain} to ${standbyServer.ip}`);
1836
+ }
1837
+ } else {
1838
+ p.log.warn('Hetzner API token not found — update DNS A record manually');
1839
+ }
1840
+ } else {
1841
+ // Manual DNS instructions
1842
+ const domain = envConfig.domain || '<your-domain>';
1843
+ p.log.info(c.bold('Update DNS to complete failover:'));
1844
+ p.log.message(` ${c.dim('Domain'.padEnd(14))} ${c.bold(domain)}`);
1845
+ p.log.message(` ${c.dim('New IP'.padEnd(14))} ${c.bold(standbyServer.ip)} (promoted standby)`);
1846
+ }
1847
+
1848
+ // Update config: swap primary/standby roles so subsequent commands reflect reality
1849
+ const updatedServers = servers.map((s) => ({
1850
+ ...s,
1851
+ role: s.role === 'primary' ? 'standby' : 'primary',
1852
+ }));
1853
+ const updatedEnvConfig = {
1854
+ ...envConfig,
1855
+ servers: updatedServers,
1856
+ region: standbyServer.region,
1857
+ secondaryRegion: primaryServer.region,
1858
+ ha: { ...envConfig.ha, failoverRegion: primaryServer.region },
1859
+ lastFailover: new Date().toISOString(),
1860
+ };
1861
+ saveProjectConfig({
1862
+ ...projectConfig,
1863
+ environments: {
1864
+ ...projectConfig.environments,
1865
+ [envName]: updatedEnvConfig,
1866
+ },
1867
+ });
1868
+
1869
+ p.log.success('Failover complete');
1870
+ p.log.info(c.bold('Recovery instructions:'));
1871
+ p.log.message(` 1. Fix the issue on the original primary (${primaryServer.ip})`);
1872
+ p.log.message(' 2. Re-initialize it as a standby replica');
1873
+ p.log.message(' 3. Redeploy to update configuration');
1874
+ }