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,1032 @@
1
+ /**
2
+ * Vibecarbon Failover Command
3
+ *
4
+ * Promotes the standby region of an HA deployment to primary. Handles
5
+ * four scenarios: HA+Cloudflare DNS, HA+Hetzner DNS, HA+manual DNS,
6
+ * and single-server (where failover is a no-op + recovery instructions).
7
+ *
8
+ * Form rule: vibecarbon uses single-dash flags only — see
9
+ * memory:feedback_cli_single_dash_flags. `-dry` (not `-dry-run` / `-n`)
10
+ * previews the failover plan without executing.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import * as p from '@clack/prompts';
15
+ import { renderHelp } from './lib/cli/help.js';
16
+ import { parseFlags } from './lib/cli/parse-flags.js';
17
+ import { selectEnvironment } from './lib/cli/select-environment.js';
18
+ import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
19
+ import { createDNSRecord } from './lib/cloudflare.js';
20
+ import { c, printBanner } from './lib/colors.js';
21
+ import { runCommand } from './lib/command.js';
22
+ import { loadCredentials, loadProjectConfig } from './lib/config.js';
23
+ import {
24
+ createDNSRecord as hetznerDnsCreateRecord,
25
+ getZone as hetznerDnsGetZone,
26
+ } from './lib/hetzner-dns.js';
27
+ import { requireLicense } from './lib/licensing/index.js';
28
+ import { ensureOperatorIpAccess } from './lib/operator-ip.js';
29
+ import { perfTimer } from './lib/perf.js';
30
+ import { assertInProjectDir } from './lib/project-guard.js';
31
+ import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRun } from './lib/ssh.js';
32
+ import { createTracker } from './lib/tracker.js';
33
+ import { VERSION } from './lib/version.js';
34
+
35
+ // ============================================================================
36
+ // COMMAND SPEC — single source of truth for argv parsing AND help output.
37
+ // ============================================================================
38
+
39
+ /** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, description?: string, examples?: Array<{ command: string, description?: string }> }} */
40
+ const SPEC = {
41
+ name: 'failover',
42
+ summary: 'Promote the standby region to primary',
43
+ description: [
44
+ 'Scenarios:',
45
+ ' HA + Cloudflare DNS Promotes standby DB, switches DNS A record automatically',
46
+ ' HA + Hetzner DNS Promotes standby DB, switches DNS A record automatically',
47
+ ' HA + Manual DNS Promotes standby DB, prints DNS update instructions',
48
+ ' Single Server Prints backup/restore recovery steps',
49
+ ].join('\n'),
50
+ positional: [
51
+ {
52
+ name: 'env',
53
+ optional: true,
54
+ description: 'Environment to fail over (skips the env prompt)',
55
+ },
56
+ ],
57
+ flags: [
58
+ { name: 'h', boolean: true, description: 'Show this help' },
59
+ { name: 'v', boolean: true, description: 'Show version' },
60
+ { name: 'y', boolean: true, description: 'Skip confirmation prompts' },
61
+ { name: 'dry', boolean: true, description: 'Show plan without executing' },
62
+ { name: 'env', value: '<name>', description: 'Environment seed (alternative to positional)' },
63
+ ],
64
+ examples: [
65
+ { command: 'vibecarbon failover', description: 'prompts for env' },
66
+ { command: 'vibecarbon failover prod -dry', description: 'preview the failover plan' },
67
+ { command: 'vibecarbon failover prod -y', description: 'execute without confirmation' },
68
+ ],
69
+ };
70
+
71
+ // ============================================================================
72
+ // SCENARIO DETECTION
73
+ // ============================================================================
74
+
75
+ export function detectScenario(envConfig) {
76
+ const ha = envConfig.ha?.enabled || envConfig.ha === true;
77
+ const cloudflare = envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId;
78
+ const hetznerDns = envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId;
79
+ if (ha && cloudflare) return 'ha_cloudflare';
80
+ if (ha && hetznerDns) return 'ha_hetzner_dns';
81
+ if (ha) return 'ha_manual';
82
+ return 'single_server';
83
+ }
84
+
85
+ /**
86
+ * Identify primary and standby servers from the environment config.
87
+ * HA deployments store server info in ha.primary/ha.standby within the environment,
88
+ * or as separate entries in the servers array with region identifiers in their names.
89
+ */
90
+ export function identifyServers(envName, envConfig, _projectConfig) {
91
+ // Check for HA config with primary/standby info nested in the environment
92
+ if (envConfig.ha?.primary && envConfig.ha?.standby) {
93
+ return {
94
+ primary: {
95
+ ip: envConfig.ha.primary.masterIp,
96
+ supabaseIp: envConfig.ha.primary.supabaseIp,
97
+ floatingIp: envConfig.ha.primary.floatingIp || envConfig.ha.primary.masterIp,
98
+ region: envConfig.ha.primary.region,
99
+ envKey: envName,
100
+ },
101
+ standby: {
102
+ ip: envConfig.ha.standby.masterIp,
103
+ supabaseIp: envConfig.ha.standby.supabaseIp,
104
+ floatingIp: envConfig.ha.standby.floatingIp || envConfig.ha.standby.masterIp,
105
+ region: envConfig.ha.standby.region,
106
+ envKey: envName,
107
+ },
108
+ };
109
+ }
110
+
111
+ // Fall back to servers array with role/region matching
112
+ const servers = envConfig.servers || [];
113
+ const primaryRegion = envConfig.region;
114
+ const standbyRegion = envConfig.secondaryRegion || envConfig.ha?.failoverRegion;
115
+
116
+ if (servers.length >= 2 && primaryRegion && standbyRegion) {
117
+ // Prefer name-based match (k8s HA stores { name: 'primary' / 'standby' });
118
+ // fall back to region match (pre-gitops compose-HA used server names
119
+ // that embedded the region).
120
+ const byName = (role) => servers.find((s) => s.name === role);
121
+ const byRegion = (region) => servers.find((s) => s.name?.includes(region));
122
+ const primary = byName('primary') || byRegion(primaryRegion) || servers[0];
123
+ const standby = byName('standby') || byRegion(standbyRegion) || servers[1];
124
+
125
+ return {
126
+ primary: {
127
+ ip: primary.ip,
128
+ supabaseIp: primary.supabaseIp,
129
+ region: primaryRegion,
130
+ envKey: envName,
131
+ },
132
+ standby: {
133
+ ip: standby.ip,
134
+ supabaseIp: standby.supabaseIp,
135
+ region: standbyRegion,
136
+ envKey: envName,
137
+ },
138
+ };
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ // ============================================================================
145
+ // PROMOTION + SERVICE SCALE HELPERS
146
+ // ============================================================================
147
+
148
+ /**
149
+ * Check whether the primary cluster's postgres is reachable. Used to decide
150
+ * whether to attempt a pg_basebackup re-seed before promoting the standby.
151
+ * Primary-dead failovers skip the re-seed (can't; best-effort promote).
152
+ */
153
+ function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
154
+ try {
155
+ const pod = getPostgresPod(primaryIp, sshKeyPath);
156
+ const result = sshKubectl(primaryIp, sshKeyPath, [
157
+ 'exec',
158
+ '-n',
159
+ 'vibecarbon',
160
+ pod,
161
+ '--',
162
+ 'pg_isready',
163
+ '-U',
164
+ 'postgres',
165
+ '-t',
166
+ '3',
167
+ ]);
168
+ return result?.includes('accepting connections') ?? false;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Re-seed the standby Postgres from the primary via pg_basebackup before
176
+ * promotion. Closes the drift window that opens when streaming replication
177
+ * silently falls behind (no replication slot) or when the standby's pod
178
+ * restarted and missed WAL. Without re-seeding, promoted standby can serve
179
+ * requests against a schema that's missing recent migrations, yielding
180
+ * "undefined column" (PG 42703) errors in auth/storage after failover.
181
+ *
182
+ * Requires `primarySupabaseIp` — the Hetzner private IP of the primary's
183
+ * supabase node (where the postgres pod runs with hostPort 5432). Returns
184
+ * true if re-seed succeeded, false if primary was unreachable or re-seed
185
+ * itself failed (caller proceeds to promote with existing state).
186
+ */
187
+ function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
188
+ if (!primarySupabaseIp) return false;
189
+ try {
190
+ // REPL_PASSWORD is written to both primary and standby .env.local at
191
+ // create time; read it from the standby's filesystem (easier than
192
+ // threading through the failover args).
193
+ const replPassword = sshRun(standbyIp, sshKeyPath, [
194
+ 'bash',
195
+ '-c',
196
+ 'grep -oE "^REPL_PASSWORD=[\\"\']?[^\\"\'\\n]+" /opt/*/.env.local 2>/dev/null | head -1 | sed -E "s/^REPL_PASSWORD=[\\"\']?//; s/[\\"\']?$//"',
197
+ ]);
198
+ if (!replPassword?.trim()) return false;
199
+
200
+ const pod = getPostgresPod(standbyIp, sshKeyPath);
201
+ const script = [
202
+ `rm -rf /var/lib/postgresql/data/*`,
203
+ `PGPASSWORD='${replPassword.trim()}' pg_basebackup -h ${primarySupabaseIp} -p 5432 -U replicator -D /var/lib/postgresql/data -Fp -Xs -R -c fast -S vibecarbon_standby_slot`,
204
+ `echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data/postgresql.auto.conf`,
205
+ `chown -R postgres:postgres /var/lib/postgresql/data`,
206
+ ].join('\n');
207
+ sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
208
+ input: script,
209
+ });
210
+ // Restart the pod to pick up the new PGDATA + recovery config.
211
+ sshKubectl(standbyIp, sshKeyPath, [
212
+ 'exec',
213
+ '-n',
214
+ 'vibecarbon',
215
+ pod,
216
+ '--',
217
+ 'su',
218
+ 'postgres',
219
+ '-c',
220
+ 'pg_ctl restart -D /var/lib/postgresql/data -m fast',
221
+ ]);
222
+ return true;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ function promoteStandbyDatabase(standbyIp, sshKeyPath) {
229
+ const _t = perfTimer('failover.promoteStandby');
230
+ try {
231
+ return _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath);
232
+ } finally {
233
+ _t.end();
234
+ }
235
+ }
236
+
237
+ function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
238
+ // Get the postgres pod name via a separate SSH call (avoids local shell expansion of $())
239
+ const pod = getPostgresPod(standbyIp, sshKeyPath);
240
+
241
+ // Promote PostgreSQL on standby
242
+ try {
243
+ sshKubectl(standbyIp, sshKeyPath, [
244
+ 'exec',
245
+ '-n',
246
+ 'vibecarbon',
247
+ pod,
248
+ '--',
249
+ 'su',
250
+ 'postgres',
251
+ '-c',
252
+ 'pg_ctl promote -D /var/lib/postgresql/data',
253
+ ]);
254
+ } catch {
255
+ // pg_ctl promote may return non-zero even on success
256
+ }
257
+
258
+ // Poll until standby is no longer in recovery
259
+ for (let i = 0; i < 30; i++) {
260
+ try {
261
+ const result = sshKubectl(standbyIp, sshKeyPath, [
262
+ 'exec',
263
+ '-n',
264
+ 'vibecarbon',
265
+ pod,
266
+ '--',
267
+ 'psql',
268
+ '-U',
269
+ 'supabase_admin',
270
+ '-tAc',
271
+ 'SELECT pg_is_in_recovery()',
272
+ ]);
273
+ if (result.trim() === 'f') return true;
274
+ } catch {
275
+ // Retry
276
+ }
277
+ runCommand(['sleep', '2'], { silent: true, ignoreError: true });
278
+ }
279
+
280
+ return false;
281
+ }
282
+
283
+ function scaleUpServices(ip, sshKeyPath) {
284
+ const _t = perfTimer('failover.scaleUpServices');
285
+ try {
286
+ return _scaleUpServicesImpl(ip, sshKeyPath);
287
+ } finally {
288
+ _t.end();
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Deployments scaled during failover. The user's `app` deployment is
294
+ * named verbatim in carbon/k8s/base/app/deployment.yaml. Supabase
295
+ * back-ends are owned by the Helm chart with `<release>-<chart>-<comp>`
296
+ * naming — release `supabase`, chart `supabase` → `supabase-supabase-*`.
297
+ *
298
+ * The previous implementation scaled `auth`/`rest`/`realtime`/`storage`
299
+ * (the bare component names) — kubectl returned "no objects passed to
300
+ * scale" and the calls became silent no-ops. Result: failover scaled
301
+ * ONLY the `app` deployment up on standby, leaving Supabase back-ends
302
+ * stuck at whatever replica count the chart left them at, and the
303
+ * old primary's auth/rest were never scaled to 0 — split-brain
304
+ * possible. Caught by k8s-ha matrix verify-failover.
305
+ */
306
+ const FAILOVER_DEPLOYMENTS = [
307
+ { name: 'app', upReplicas: 2 },
308
+ { name: 'supabase-supabase-auth', upReplicas: 2 },
309
+ { name: 'supabase-supabase-rest', upReplicas: 2 },
310
+ { name: 'supabase-supabase-realtime', upReplicas: 2 },
311
+ { name: 'supabase-supabase-storage', upReplicas: 1 },
312
+ ];
313
+
314
+ /**
315
+ * Scale a list of deployments to N replicas via SSH+kubectl, swallowing
316
+ * per-deployment "not found" so one missing chart-name doesn't abort
317
+ * the whole failover. Reports each result so the operator can see what
318
+ * actually scaled.
319
+ */
320
+ function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
321
+ for (const dep of deployments) {
322
+ try {
323
+ sshKubectl(ip, sshKeyPath, [
324
+ 'scale',
325
+ 'deployment',
326
+ dep.name,
327
+ '-n',
328
+ 'vibecarbon',
329
+ `--replicas=${replicas}`,
330
+ ]);
331
+ } catch (err) {
332
+ const msg = err instanceof Error ? err.message : String(err);
333
+ // "no objects passed to scale" / "deployments.apps not found" → log and continue.
334
+ // Anything else (auth failure, network) we still want to surface.
335
+ const isMissing = /not found|no objects passed/i.test(msg);
336
+ if (isMissing) {
337
+ p.log.warn(`${label}: ${dep.name} not found — skipped`);
338
+ } else {
339
+ p.log.warn(`${label}: ${dep.name} failed: ${msg.split('\n')[0]}`);
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ function _scaleUpServicesImpl(ip, sshKeyPath) {
346
+ // Group by upReplicas so we don't touch the same deployment twice.
347
+ const byReplicas = new Map();
348
+ for (const dep of FAILOVER_DEPLOYMENTS) {
349
+ if (!byReplicas.has(dep.upReplicas)) byReplicas.set(dep.upReplicas, []);
350
+ byReplicas.get(dep.upReplicas).push(dep);
351
+ }
352
+ for (const [replicas, deps] of byReplicas.entries()) {
353
+ scaleDeployments(ip, sshKeyPath, deps, replicas, `scale-up standby (replicas=${replicas})`);
354
+ }
355
+ }
356
+
357
+ function waitForHealth(ip, sshKeyPath) {
358
+ const _t = perfTimer('failover.waitForHealth');
359
+ try {
360
+ return _waitForHealthImpl(ip, sshKeyPath);
361
+ } finally {
362
+ _t.end();
363
+ }
364
+ }
365
+
366
+ function _waitForHealthImpl(ip, sshKeyPath) {
367
+ // The previous implementation curled http://localhost:3000/api/health from
368
+ // the standby master — but K8s app pods don't run on the master's host
369
+ // network (no hostPort:3000), so localhost:3000 has nothing listening and
370
+ // every probe returned "FAIL". 30 retries × 2s = 60s wasted. Use kubectl
371
+ // rollout status against the actual deployment, which is the cluster's
372
+ // own readiness signal — gives `app` time to come up after scaleUp and
373
+ // surfaces the real failure (ImagePullBackOff, CrashLoop, missing secret)
374
+ // in the kubectl error rather than a generic timeout.
375
+ try {
376
+ sshKubectl(ip, sshKeyPath, [
377
+ 'rollout',
378
+ 'status',
379
+ 'deployment/app',
380
+ '-n',
381
+ 'vibecarbon',
382
+ '--timeout=300s',
383
+ ]);
384
+ return true;
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+
390
+ // ============================================================================
391
+ // FAILOVER: HA + CLOUDFLARE
392
+ // ============================================================================
393
+
394
+ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, tracker) {
395
+ const servers = identifyServers(envName, envConfig, projectConfig);
396
+ if (!servers) {
397
+ p.log.error('Could not identify primary and standby servers');
398
+ p.log.info('Ensure HA deployment was set up with vibecarbon deploy');
399
+ process.exit(1);
400
+ }
401
+
402
+ const sshKeyPath = getSSHKeyPath(envName);
403
+ if (!existsSync(sshKeyPath)) {
404
+ p.log.error(`SSH key not found: ${sshKeyPath}`);
405
+ process.exit(1);
406
+ }
407
+
408
+ // Get Cloudflare token — check env var, then credentials file, then prompt
409
+ let cfToken = process.env.CLOUDFLARE_API_TOKEN;
410
+ if (!cfToken) {
411
+ const creds = loadCredentials();
412
+ if (creds.cloudflare?.apiToken) {
413
+ cfToken = creds.cloudflare.apiToken;
414
+ p.log.info('Using Cloudflare API token from ~/.vibecarbon/credentials.json');
415
+ }
416
+ }
417
+ if (!cfToken) {
418
+ cfToken = await p.text({
419
+ message: 'Cloudflare API token:',
420
+ validate: (v) => (!v ? 'Required' : undefined),
421
+ });
422
+ if (p.isCancel(cfToken)) {
423
+ p.cancel('Operation cancelled.');
424
+ process.exit(0);
425
+ }
426
+ }
427
+
428
+ const zoneId = envConfig.dns.cloudflareZoneId;
429
+ const domain = envConfig.domain || envConfig.dns?.domain;
430
+ const standbyFloatingIp = servers.standby.floatingIp || servers.standby.ip;
431
+
432
+ const s = tracker.spinner();
433
+
434
+ // Display current state
435
+ p.log.info(c.bold('Current HA state:'));
436
+ p.log.message(
437
+ ` ${c.dim(`Primary (${servers.primary.region})`.padEnd(22))} ${servers.primary.floatingIp || servers.primary.ip}`,
438
+ );
439
+ p.log.message(
440
+ ` ${c.dim(`Standby (${servers.standby.region})`.padEnd(22))} ${standbyFloatingIp}`,
441
+ );
442
+
443
+ p.log.info(c.bold('This will:'));
444
+ p.log.message(' 1. Promote the standby database to primary');
445
+ p.log.message(' 2. Scale down old primary services');
446
+ p.log.message(' 3. Scale up standby services');
447
+ p.log.message(` 4. Update DNS A record → ${standbyFloatingIp}`);
448
+
449
+ // Dry run stops here
450
+ if (parsed.dryRun) {
451
+ p.log.info(c.dim('Dry run — no changes made'));
452
+ return;
453
+ }
454
+
455
+ // Confirmation
456
+ if (!parsed.yes) {
457
+ const confirm = await p.confirm({
458
+ message: `Execute failover for ${c.bold(envName)}?`,
459
+ });
460
+ if (p.isCancel(confirm) || !confirm) {
461
+ p.cancel('Operation cancelled.');
462
+ process.exit(0);
463
+ }
464
+ }
465
+
466
+ // Step 0: Re-seed standby from primary (if primary is still reachable).
467
+ // Streaming replication can silently fall behind; a stale standby promotes
468
+ // to serve queries against an out-of-date schema and hits "undefined
469
+ // column" (PG 42703) errors. Skipped when primary is unreachable (true
470
+ // disaster failover) — promotion proceeds with whatever state standby
471
+ // has. Requires primarySupabaseIp from identifyServers (persisted in
472
+ // envConfig.servers by orchestrator.js post-deploy).
473
+ if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
474
+ s.start('Re-seeding standby from primary (pg_basebackup)');
475
+ const reseeded = reseedStandbyFromPrimary(
476
+ servers.standby.ip,
477
+ sshKeyPath,
478
+ servers.primary.supabaseIp,
479
+ );
480
+ s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
481
+ } else {
482
+ p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
483
+ }
484
+
485
+ // Step 1: Promote standby database
486
+ s.start('Promoting standby database');
487
+ const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
488
+ s.stop(
489
+ promoted
490
+ ? 'Standby database promoted to primary'
491
+ : 'Database promotion may still be in progress',
492
+ );
493
+ if (!promoted) {
494
+ p.log.warn('Could not confirm promotion completed — verify manually');
495
+ }
496
+
497
+ // Step 2: Scale down old primary services to prevent split-brain.
498
+ // Per-deployment errors (not-found, unreachable) are logged inside
499
+ // scaleDeployments — don't abort the whole failover on one missing
500
+ // chart name.
501
+ s.start('Scaling down old primary services');
502
+ scaleDeployments(servers.primary.ip, sshKeyPath, FAILOVER_DEPLOYMENTS, 0, 'scale-down primary');
503
+ s.stop('Old primary services scaled down');
504
+
505
+ // Step 3: Scale up standby services
506
+ s.start('Scaling up standby services');
507
+ try {
508
+ scaleUpServices(servers.standby.ip, sshKeyPath);
509
+ } catch (error) {
510
+ s.stop('Some services failed to scale');
511
+ p.log.warn(`Scale error: ${error.message}`);
512
+ }
513
+ s.stop('Standby services scaled up');
514
+
515
+ // Wait for health endpoint
516
+ s.start('Waiting for standby health check');
517
+ const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
518
+ s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
519
+
520
+ // Step 4: Switch DNS A record to standby floating IP. proxied:false
521
+ // matches the deploy-time setup (Cloudflare LBs removed 2026-03-26).
522
+ s.start('Updating DNS A record');
523
+ try {
524
+ await createDNSRecord(cfToken, zoneId, {
525
+ type: 'A',
526
+ name: domain,
527
+ content: standbyFloatingIp,
528
+ proxied: false,
529
+ });
530
+ await createDNSRecord(cfToken, zoneId, {
531
+ type: 'A',
532
+ name: `*.${domain}`,
533
+ content: standbyFloatingIp,
534
+ proxied: false,
535
+ });
536
+ s.stop(`DNS updated: ${domain} → ${standbyFloatingIp}`);
537
+ } catch (error) {
538
+ s.stop('DNS update failed');
539
+ p.log.error(error.message);
540
+ p.log.warn(`Manually update the A record for ${domain} to ${standbyFloatingIp}`);
541
+ }
542
+
543
+ // Success
544
+ p.log.success('Failover complete');
545
+ p.log.info(c.bold('Recovery instructions:'));
546
+ p.log.message(' To resync the original primary as a new standby:');
547
+ p.log.message(` 1. Fix the issue on the original primary (${servers.primary.ip})`);
548
+ p.log.message(' 2. Re-initialize it as a standby replica');
549
+ p.log.message(' 3. Redeploy to update configuration');
550
+ }
551
+
552
+ // ============================================================================
553
+ // FAILOVER: HA + HETZNER DNS
554
+ // ============================================================================
555
+
556
+ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, tracker) {
557
+ const servers = identifyServers(envName, envConfig, projectConfig);
558
+ if (!servers) {
559
+ p.log.error('Could not identify primary and standby servers');
560
+ p.log.info('Ensure HA deployment was set up with vibecarbon deploy');
561
+ process.exit(1);
562
+ }
563
+
564
+ const sshKeyPath = getSSHKeyPath(envName);
565
+ if (!existsSync(sshKeyPath)) {
566
+ p.log.error(`SSH key not found: ${sshKeyPath}`);
567
+ process.exit(1);
568
+ }
569
+
570
+ // Get Hetzner API token (same token used for cloud operations)
571
+ let apiToken = process.env.HETZNER_API_TOKEN;
572
+ if (!apiToken) {
573
+ const creds = loadCredentials();
574
+ apiToken = creds.hetzner?.apiToken;
575
+ }
576
+ if (!apiToken) {
577
+ apiToken = await p.text({
578
+ message: 'Hetzner API token:',
579
+ validate: (v) => (!v ? 'Required' : undefined),
580
+ });
581
+ if (p.isCancel(apiToken)) {
582
+ p.cancel('Operation cancelled.');
583
+ process.exit(0);
584
+ }
585
+ }
586
+
587
+ const zoneId = envConfig.dns.hetznerZoneId;
588
+ const domain = envConfig.domain || envConfig.dns?.domain;
589
+ const standbyFloatingIp = servers.standby.floatingIp || servers.standby.ip;
590
+
591
+ const s = tracker.spinner();
592
+
593
+ // Display current state
594
+ p.log.info(c.bold('Current HA state:'));
595
+ p.log.message(
596
+ ` ${c.dim(`Primary (${servers.primary.region})`.padEnd(22))} ${servers.primary.floatingIp || servers.primary.ip}`,
597
+ );
598
+ p.log.message(
599
+ ` ${c.dim(`Standby (${servers.standby.region})`.padEnd(22))} ${standbyFloatingIp}`,
600
+ );
601
+
602
+ p.log.info(c.bold('This will:'));
603
+ p.log.message(' 1. Promote the standby database to primary');
604
+ p.log.message(' 2. Scale down old primary services');
605
+ p.log.message(' 3. Scale up standby services');
606
+ p.log.message(` 4. Update DNS A record → ${standbyFloatingIp}`);
607
+
608
+ if (parsed.dryRun) {
609
+ p.log.info(c.dim('Dry run — no changes made'));
610
+ return;
611
+ }
612
+
613
+ if (!parsed.yes) {
614
+ const confirm = await p.confirm({
615
+ message: `Execute failover for ${c.bold(envName)}?`,
616
+ });
617
+ if (p.isCancel(confirm) || !confirm) {
618
+ p.cancel('Operation cancelled.');
619
+ process.exit(0);
620
+ }
621
+ }
622
+
623
+ // Step 0: Re-seed standby from primary (if primary is still reachable).
624
+ // Streaming replication can silently fall behind; a stale standby promotes
625
+ // to serve queries against an out-of-date schema and hits "undefined
626
+ // column" (PG 42703) errors. Skipped when primary is unreachable (true
627
+ // disaster failover) — promotion proceeds with whatever state standby
628
+ // has. Requires primarySupabaseIp from identifyServers (persisted in
629
+ // envConfig.servers by orchestrator.js post-deploy).
630
+ if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
631
+ s.start('Re-seeding standby from primary (pg_basebackup)');
632
+ const reseeded = reseedStandbyFromPrimary(
633
+ servers.standby.ip,
634
+ sshKeyPath,
635
+ servers.primary.supabaseIp,
636
+ );
637
+ s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
638
+ } else {
639
+ p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
640
+ }
641
+
642
+ // Step 1: Promote standby database
643
+ s.start('Promoting standby database');
644
+ const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
645
+ s.stop(
646
+ promoted
647
+ ? 'Standby database promoted to primary'
648
+ : 'Database promotion may still be in progress',
649
+ );
650
+ if (!promoted) {
651
+ p.log.warn('Could not confirm promotion completed — verify manually');
652
+ }
653
+
654
+ // Step 2: Scale down old primary services to prevent split-brain.
655
+ // See FAILOVER_DEPLOYMENTS for the chart-canonical names — bare
656
+ // `auth`/`rest`/etc. silently no-op'd in an earlier version.
657
+ s.start('Scaling down old primary services');
658
+ scaleDeployments(servers.primary.ip, sshKeyPath, FAILOVER_DEPLOYMENTS, 0, 'scale-down primary');
659
+ s.stop('Old primary services scaled down');
660
+
661
+ // Step 3: Scale up standby services
662
+ s.start('Scaling up standby services');
663
+ try {
664
+ scaleUpServices(servers.standby.ip, sshKeyPath);
665
+ } catch (error) {
666
+ s.stop('Some services failed to scale');
667
+ p.log.warn(`Scale error: ${error.message}`);
668
+ }
669
+ s.stop('Standby services scaled up');
670
+
671
+ // Wait for health endpoint
672
+ s.start('Waiting for standby health check');
673
+ const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
674
+ s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
675
+
676
+ // Step 4: Update DNS A record via Hetzner DNS API
677
+ s.start('Updating DNS A record');
678
+ try {
679
+ const zone = await hetznerDnsGetZone(apiToken, zoneId);
680
+ await hetznerDnsCreateRecord(apiToken, zoneId, zone.name, {
681
+ type: 'A',
682
+ name: domain,
683
+ value: standbyFloatingIp,
684
+ ttl: 60,
685
+ });
686
+ await hetznerDnsCreateRecord(apiToken, zoneId, zone.name, {
687
+ type: 'A',
688
+ name: `*.${domain}`,
689
+ value: standbyFloatingIp,
690
+ ttl: 60,
691
+ });
692
+ s.stop(`DNS updated: ${domain} → ${standbyFloatingIp}`);
693
+ } catch (error) {
694
+ s.stop('DNS update failed');
695
+ p.log.error(error.message);
696
+ p.log.warn(`Manually update the A record for ${domain} to ${standbyFloatingIp}`);
697
+ }
698
+
699
+ // Success
700
+ p.log.success('Failover complete');
701
+ p.log.info(c.bold('Recovery instructions:'));
702
+ p.log.message(' To resync the original primary as a new standby:');
703
+ p.log.message(` 1. Fix the issue on the original primary (${servers.primary.ip})`);
704
+ p.log.message(' 2. Re-initialize it as a standby replica');
705
+ p.log.message(' 3. Redeploy to update configuration');
706
+ }
707
+
708
+ // ============================================================================
709
+ // FAILOVER: HA + MANUAL DNS
710
+ // ============================================================================
711
+
712
+ async function failoverHAManual(envName, envConfig, projectConfig, parsed, tracker) {
713
+ const servers = identifyServers(envName, envConfig, projectConfig);
714
+ if (!servers) {
715
+ p.log.error('Could not identify primary and standby servers');
716
+ p.log.info('Ensure HA deployment was set up with vibecarbon deploy');
717
+ process.exit(1);
718
+ }
719
+
720
+ const sshKeyPath = getSSHKeyPath(envName);
721
+ if (!existsSync(sshKeyPath)) {
722
+ p.log.error(`SSH key not found: ${sshKeyPath}`);
723
+ process.exit(1);
724
+ }
725
+
726
+ const domain = envConfig.domain || envConfig.dns?.domain || '<your-domain>';
727
+
728
+ p.log.info(c.bold('Current HA state:'));
729
+ p.log.message(
730
+ ` ${c.dim(`Primary (${servers.primary.region})`.padEnd(22))} ${servers.primary.ip}`,
731
+ );
732
+ p.log.message(
733
+ ` ${c.dim(`Standby (${servers.standby.region})`.padEnd(22))} ${servers.standby.ip}`,
734
+ );
735
+ p.log.info(c.bold('This will:'));
736
+ p.log.message(' 1. Promote the standby database to primary');
737
+ p.log.message(' 2. Scale up standby services');
738
+ p.log.message(' 3. Print DNS update instructions for you to complete');
739
+
740
+ if (parsed.dryRun) {
741
+ p.log.info(c.dim('Dry run — no changes made'));
742
+ return;
743
+ }
744
+
745
+ if (!parsed.yes) {
746
+ const confirm = await p.confirm({
747
+ message: `Execute failover for ${c.bold(envName)}?`,
748
+ });
749
+ if (p.isCancel(confirm) || !confirm) {
750
+ p.cancel('Operation cancelled.');
751
+ process.exit(0);
752
+ }
753
+ }
754
+
755
+ const s = tracker.spinner();
756
+
757
+ // Promote standby database
758
+ s.start('Promoting standby database');
759
+ const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
760
+ s.stop(promoted ? 'Standby database promoted' : 'Promotion may still be in progress');
761
+
762
+ // Scale down old primary services to prevent split-brain
763
+ s.start('Scaling down old primary services');
764
+ try {
765
+ sshKubectl(servers.primary.ip, sshKeyPath, [
766
+ 'scale',
767
+ 'deployment',
768
+ 'app',
769
+ '-n',
770
+ 'vibecarbon',
771
+ '--replicas=0',
772
+ ]);
773
+ sshKubectl(servers.primary.ip, sshKeyPath, [
774
+ 'scale',
775
+ 'deployment',
776
+ 'auth',
777
+ '-n',
778
+ 'vibecarbon',
779
+ '--replicas=0',
780
+ ]);
781
+ sshKubectl(servers.primary.ip, sshKeyPath, [
782
+ 'scale',
783
+ 'deployment',
784
+ 'rest',
785
+ '-n',
786
+ 'vibecarbon',
787
+ '--replicas=0',
788
+ ]);
789
+ sshKubectl(servers.primary.ip, sshKeyPath, [
790
+ 'scale',
791
+ 'deployment',
792
+ 'realtime',
793
+ '-n',
794
+ 'vibecarbon',
795
+ '--replicas=0',
796
+ ]);
797
+ } catch {
798
+ p.log.warn('Could not reach old primary to scale down services');
799
+ }
800
+ s.stop('Old primary services scaled down');
801
+
802
+ // Scale up services
803
+ s.start('Scaling up standby services');
804
+ try {
805
+ scaleUpServices(servers.standby.ip, sshKeyPath);
806
+ } catch (error) {
807
+ s.stop('Some services failed to scale');
808
+ p.log.warn(`Scale error: ${error.message}`);
809
+ }
810
+ s.stop('Standby services scaled up');
811
+
812
+ // Wait for health
813
+ s.start('Waiting for standby health check');
814
+ const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
815
+ s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
816
+
817
+ // Print DNS instructions
818
+ p.log.success('Standby region activated');
819
+ p.log.info(c.bold('Update DNS to complete failover:'));
820
+ p.log.message(` ${c.dim('Domain'.padEnd(14))} ${c.bold(domain)}`);
821
+ p.log.message(` ${c.dim('New IP'.padEnd(14))} ${c.bold(servers.standby.ip)} (standby master)`);
822
+ p.log.info(c.bold('Steps:'));
823
+ p.log.message(' 1. Log into your DNS provider');
824
+ p.log.message(` 2. Update the A record for ${domain} to ${c.bold(servers.standby.ip)}`);
825
+ p.log.message(` 3. Wait for DNS propagation (check: ${c.dim(`dig ${domain}`)})`);
826
+ }
827
+
828
+ // ============================================================================
829
+ // FAILOVER: SINGLE SERVER (RECOVERY GUIDE)
830
+ // ============================================================================
831
+
832
+ async function failoverSingleServer(envName, envConfig) {
833
+ const serverIp = envConfig.servers?.[0]?.ip;
834
+ const sshKeyPath = getSSHKeyPath(envName);
835
+
836
+ p.log.warn('This environment has no standby region');
837
+ p.log.info('Recovery requires deploying a new server and restoring from backup');
838
+
839
+ // Check for available backups
840
+ if (serverIp && existsSync(sshKeyPath)) {
841
+ const s = p.spinner();
842
+ s.start('Checking for available backups');
843
+ try {
844
+ const pod = sshKubectl(serverIp, sshKeyPath, [
845
+ 'get',
846
+ 'pods',
847
+ '-n',
848
+ 'vibecarbon',
849
+ '-l',
850
+ 'app.kubernetes.io/name=supabase-db',
851
+ '-o',
852
+ 'jsonpath={.items[0].metadata.name}',
853
+ ]);
854
+ const output = sshKubectl(serverIp, sshKeyPath, [
855
+ 'exec',
856
+ '-n',
857
+ 'vibecarbon',
858
+ pod,
859
+ '--',
860
+ 'sh',
861
+ '-c',
862
+ 'ls -lt /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null | head -5 || echo NO_BACKUPS',
863
+ ]);
864
+ s.stop('Backup check complete');
865
+
866
+ if (output !== 'NO_BACKUPS' && output) {
867
+ p.log.info(c.bold('Available backups:'));
868
+ for (const line of output
869
+ .split('\n')
870
+ .filter((l) => l.includes('.tar.gz') || l.includes('.sql.gz'))) {
871
+ p.log.message(` ${line.trim()}`);
872
+ }
873
+ } else {
874
+ p.log.info('No backups found on the server');
875
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
876
+ }
877
+ } catch {
878
+ s.stop('Could not reach server');
879
+ p.log.warn('Server may be unreachable — backups may not be accessible');
880
+ }
881
+ }
882
+
883
+ p.log.info(c.bold('Recovery steps:'));
884
+ p.log.message(` 1. ${c.info(`vibecarbon deploy ${envName}`)}`);
885
+ p.log.message(` 2. ${c.info(`vibecarbon restore ${envName}`)}`);
886
+ p.log.message(` 3. ${c.info(`vibecarbon status ${envName}`)}`);
887
+ p.log.info('To enable HA for one-command failover in the future:');
888
+ p.log.message(` ${c.info(`vibecarbon deploy ${envName} --ha`)}`);
889
+ }
890
+
891
+ // ============================================================================
892
+ // MAIN
893
+ // ============================================================================
894
+
895
+ export async function run(args) {
896
+ const { values, positional, errors } = parseFlags(args, SPEC);
897
+
898
+ if (errors.length > 0) {
899
+ for (const e of errors) {
900
+ process.stderr.write(`${c.error('✗')} ${e}\n`);
901
+ }
902
+ process.stderr.write(`Run ${c.info('vibecarbon failover -h')} for usage.\n`);
903
+ process.exit(1);
904
+ }
905
+
906
+ if (values.h) {
907
+ process.stdout.write(renderHelp(SPEC));
908
+ return;
909
+ }
910
+ if (values.v) {
911
+ console.log(`vibecarbon v${VERSION}`);
912
+ return;
913
+ }
914
+
915
+ // Project guard runs before banner so an accidental `vibecarbon
916
+ // failover` from a parent directory emits the canonical message.
917
+ assertInProjectDir();
918
+
919
+ requireLicense('failover');
920
+ printBanner();
921
+ p.intro(`${c.bold('vibecarbon failover')} ${c.dim(`v${VERSION}`)}`);
922
+
923
+ const projectConfig = loadProjectConfig();
924
+ const envCount = Object.keys(projectConfig.environments || {}).length;
925
+ const envSeed =
926
+ /** @type {string|undefined} */ (positional.env) ||
927
+ /** @type {string|null} */ (values.env) ||
928
+ null;
929
+
930
+ requireTTYOrFlags({
931
+ requirements: [
932
+ {
933
+ flag: 'env',
934
+ description: 'name an environment to fail over',
935
+ satisfied: !!envSeed || envCount <= 1,
936
+ },
937
+ ],
938
+ });
939
+
940
+ const { envName, envConfig } = await selectEnvironment(projectConfig, {
941
+ actionVerb: 'fail over',
942
+ seed: envSeed,
943
+ });
944
+
945
+ // Compatibility shim — the existing failover orchestration helpers
946
+ // read `parsed.dryRun` and `parsed.yes` from the legacy struct shape.
947
+ // Build that shape from the new flag values without changing the
948
+ // helpers (each is hundreds of lines and well-tested).
949
+ const parsed = {
950
+ env: envName,
951
+ dryRun: !!values.dry,
952
+ yes: !!values.y,
953
+ };
954
+
955
+ // Failover hits the active and standby clusters via SSH + kubectl, so the
956
+ // operator's IP must be in the firewall allowlist for both.
957
+ const apiToken = loadCredentials()?.hetzner?.apiToken;
958
+ if (apiToken) {
959
+ try {
960
+ const result = await ensureOperatorIpAccess({
961
+ projectConfig,
962
+ environment: envName,
963
+ isHA: true,
964
+ apiToken,
965
+ yes: !!parsed.yes,
966
+ onMessage: (msg) => p.log.info(msg),
967
+ });
968
+ if (result.added) {
969
+ p.log.success(`Firewall updated: SSH/k8s API now allow ${result.cidr}`);
970
+ }
971
+ } catch (err) {
972
+ p.log.error(`Operator-IP check failed: ${err.message}`);
973
+ process.exit(1);
974
+ }
975
+ }
976
+
977
+ // Single-server compose has no failover target
978
+ if (envConfig.deployMode === 'compose') {
979
+ p.log.info('Failover is not available for single-server Compose deployments.');
980
+ p.log.info(`Environment "${envName}" uses Docker Compose on a single VPS.`);
981
+ p.log.info('');
982
+ p.log.info(`${c.dim('To enable failover, redeploy with --ha:')}`);
983
+ p.log.info(` ${c.info(`vibecarbon deploy ${envName} --ha`)}`);
984
+ p.outro('');
985
+ return;
986
+ }
987
+
988
+ // Compose-HA failover uses docker compose exec instead of kubectl
989
+ if (envConfig.deployMode === 'compose-ha') {
990
+ const composeHATracker = createTracker('failover', {
991
+ environment: envName,
992
+ scenario: 'compose_ha',
993
+ });
994
+ const { failoverComposeHA } = await import('./lib/deploy/compose/ha.js');
995
+ await failoverComposeHA(envName, envConfig, projectConfig, parsed, composeHATracker);
996
+ composeHATracker.finish();
997
+ p.outro(c.success('Done'));
998
+ return;
999
+ }
1000
+
1001
+ // Detect scenario
1002
+ const scenario = detectScenario(envConfig);
1003
+ const tracker = createTracker('failover', { environment: envName, scenario });
1004
+
1005
+ switch (scenario) {
1006
+ case 'ha_cloudflare':
1007
+ p.log.info(`Scenario: ${c.bold('HA + Cloudflare')} (DNS failover)`);
1008
+ await failoverHACloudflare(envName, envConfig, projectConfig, parsed, tracker);
1009
+ break;
1010
+
1011
+ case 'ha_hetzner_dns':
1012
+ p.log.info(`Scenario: ${c.bold('HA + Hetzner DNS')} (automatic DNS failover)`);
1013
+ await failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, tracker);
1014
+ break;
1015
+
1016
+ case 'ha_manual':
1017
+ p.log.info(`Scenario: ${c.bold('HA + Manual DNS')}`);
1018
+ await failoverHAManual(envName, envConfig, projectConfig, parsed, tracker);
1019
+ break;
1020
+
1021
+ case 'single_server':
1022
+ p.log.info(`Scenario: ${c.bold('Single Server')} (no HA configured)`);
1023
+ await failoverSingleServer(envName, envConfig);
1024
+ break;
1025
+ }
1026
+
1027
+ tracker.finish();
1028
+ p.outro(c.success('Done'));
1029
+ }
1030
+
1031
+ // Exported for tests.
1032
+ export { SPEC };